Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions components/AppNavbar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,11 @@
>Dashboard</nuxt-link>
</li>
<li class="nav-item">
<!-- Use document navigation to avoid overlapping with unresolved dashboard setup. -->
<nuxt-link
class="nav-link nav-link-item"
to="/workspace/create"
external
@click="closeOnMobile"
>Create Workspace</nuxt-link>
</li>
Expand Down
3 changes: 2 additions & 1 deletion components/dashboard/WorkspaceJobFailureDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ import { BModal } from 'bootstrap-vue-next/components/BModal';
import type { ComponentExposed } from 'vue-component-type-helpers';
import { workspacesClient } from '~/services/index';
import { resolveHttpErrorMessage } from '~/services/http';
import { findFailedImportJob } from '~/util/workspace-jobs';

import type { WorkspaceId, WorkspaceJob } from '~/types/workspaces';

Expand All @@ -216,7 +217,7 @@ async function show(workspaceId: WorkspaceId): Promise<void> {
try {
const jobs = await workspacesClient.getWorkspaceJobs(workspaceId);
if (activeRequestId === requestId) {
job.value = jobs[0] ?? null;
job.value = findFailedImportJob(jobs) ?? jobs[0] ?? null;
}
}
catch (error: unknown) {
Expand Down
7 changes: 6 additions & 1 deletion components/review/Overlay.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
v-model:show-details="showDetails"
v-model:show-discussion="showDiscussion"
:item="item"
:workspace-type="workspaceType"
@edit="props.onEdit"
@resolve="$emit('resolve')"
@back="emit('back')"
Expand All @@ -24,10 +25,14 @@

<script setup lang="ts">
import type { ReviewListItem } from '~/services/review';
import type { WorkspaceType } from '~/types/workspaces';

type RapidEditor = 'rapid' | 'rapid3';

interface Props {
item: ReviewListItem;
onEdit: () => void;
workspaceType: WorkspaceType;
onEdit: (editor?: RapidEditor) => void;
}

const props = defineProps<Props>();
Expand Down
43 changes: 40 additions & 3 deletions components/review/Toolbar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,35 @@
:class="typeClass"
/><span class="d-none d-sm-inline fw-bold text-uppercase">{{ props.item.displayType }}:</span> #{{ props.item.id }}
</span>
<b-dropdown
v-if="isOsw && rapid3Available"
split
size="sm"
split-variant="primary"
variant="primary"
class="review-editor-dropdown"
teleport-to="body"
strategy="fixed"
@split-click="edit()"
>
<template #button-content>
<app-icon
variant="edit_location_alt"
no-margin
/>
<span class="d-none d-sm-inline ms-2">Edit Here</span>
</template>
<b-dropdown-item @click="edit('rapid')">
Rapid 2 (default)
</b-dropdown-item>
<b-dropdown-item @click="edit('rapid3')">
Rapid 3 (beta)
</b-dropdown-item>
</b-dropdown>
<button
v-else
class="btn btn-sm btn-primary"
@click="edit"
@click="edit()"
>
<app-icon
variant="edit_location_alt"
Expand Down Expand Up @@ -105,24 +131,31 @@
</template>

<script setup lang="ts">
import { rapid3Manager } from '~/services/index';
import type { ReviewListItem } from '~/services/review';
import type { WorkspaceType } from '~/types/workspaces';

type RapidEditor = 'rapid' | 'rapid3';

interface Props {
item: ReviewListItem;
workspaceType: WorkspaceType;
}

const props = defineProps<Props>();
const emit = defineEmits(['back', 'edit', 'resolve']);

const { isValidator } = useWorkspaceRole();
const rapid3Available = Boolean(rapid3Manager);
const isOsw = computed(() => props.workspaceType === 'osw');

const typeClass = computed(() => props.item.badgeClass.replace('bg-', 'text-'));

const showDetails = defineModel<boolean>('showDetails');
const showDiscussion = defineModel<boolean>('showDiscussion');

function edit() {
emit('edit');
function edit(editor: RapidEditor = 'rapid') {
emit('edit', editor);
}

function resolveChangeset() {
Expand Down Expand Up @@ -156,4 +189,8 @@ function toggleDiscussion() {
& { padding: 0.5rem; }
}
}

.review-editor-dropdown {
flex-shrink: 0;
}
</style>
10 changes: 9 additions & 1 deletion components/settings/panel/Delete.vue
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,15 @@ async function submitDelete() {
isDeleting.value = true;

try {
await workspacesClient.deleteWorkspace(workspace.id);
const { osmRequestSucceeded: osmCleanupSucceeded } = await workspacesClient.deleteWorkspace(workspace.id);

if (!osmCleanupSucceeded) {
toast.info(
'The workspace has been deleted, but the data may persist for some more time.',
{ clearOnUrlChange: false }
);
}

await navigateTo('/dashboard');
}
catch (error) {
Expand Down
25 changes: 25 additions & 0 deletions pages/dashboard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ import {
import { toast } from 'vue3-toastify';
import 'vue3-toastify/dist/index.css';
import { parsePositiveIntegerQuery } from '~/util/route-query';
import { findFailedImportJob } from '~/util/workspace-jobs';

import type { Workspace, WorkspaceCenter } from '~/types/workspaces';

Expand Down Expand Up @@ -313,6 +314,7 @@ const workspaceSearch = ref('');
const pinnedWorkspaceIds = ref<Set<number>>(new Set());
const refreshingWorkspaces = ref(false);
const jobFailureDialog = useTemplateRef<JobFailureDialog>('jobFailureDialog');
const verifiedCompletedImports = new Set<number>();

const currentWorkspaces = computed<Workspace[]>(() =>
currentProjectGroup.value
Expand Down Expand Up @@ -435,6 +437,7 @@ function selectWorkspace(
updateRoute: boolean = true
): void {
currentWorkspace.value = workspace;
void verifyCompletedImport(workspace);

if (
updateRoute
Expand All @@ -450,6 +453,27 @@ function selectWorkspace(
}
}

async function verifyCompletedImport(workspace: Workspace): Promise<void> {
if (
workspace.importStatus !== 'completed'
|| verifiedCompletedImports.has(workspace.id)
) {
return;
}

verifiedCompletedImports.add(workspace.id);

try {
const jobs = await workspacesClient.getWorkspaceJobs(workspace.id);
if (findFailedImportJob(jobs)) {
workspace.importStatus = 'failed';
}
}
catch {
verifiedCompletedImports.delete(workspace.id);
}
}

function loadWorkspacePins(): void {
if (!tdeiAuth.subject) {
return;
Expand Down Expand Up @@ -498,6 +522,7 @@ async function refreshWorkspaces(): Promise<void> {

try {
const refreshedWorkspaces = await workspacesClient.getMyWorkspaces();
verifiedCompletedImports.clear();
const existingWorkspaces = new Map(
workspaces.value.map(workspace => [workspace.id, workspace])
);
Expand Down
2 changes: 1 addition & 1 deletion pages/workspace/[id]/export/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
</p>
<p>
<em>
Note: press the "Start Preparing File for Download" button to prepare the download which may take a minute; when it's ready, the button will change to "Start", when you can then press again to download the file to your device.
Note: press the "Start Preparing File for Download" button to prepare the download which may take a minute; when it's ready, the button will change to "Save", which you can then press again to download the file to your device.
</em>
</p>
</div>
Expand Down
8 changes: 6 additions & 2 deletions pages/workspace/[id]/review.vue
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ shows the proper details in the map and attribute diff panel (playwright snapsho
<review-overlay
v-if="currentItem"
:item="currentItem"
:workspace-type="workspace.type"
@edit="openEditor"
@resolve="resolveCurrentChangeset"
@back="selectedItem = undefined"
Expand Down Expand Up @@ -297,7 +298,7 @@ async function resolveCurrentChangeset() {
}
}

async function openEditor() {
async function openEditor(editor: 'rapid' | 'rapid3' = 'rapid') {
if (!map.value) {
return;
}
Expand All @@ -306,7 +307,10 @@ async function openEditor() {

await navigateTo({
path: `/workspace/${workspaceId}/edit`,
query: { datatype: reviewList.workspace?.type },
query: {
datatype: reviewList.workspace?.type,
...(reviewList.workspace?.type === 'osw' ? { editor } : {})
},
hash: `#map=${zoom}/${lat}/${lon}`,
});
}
Expand Down
12 changes: 10 additions & 2 deletions services/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ export class WorkspacesClientError extends Error {
}
}

export interface DeleteWorkspaceResult {
osmRequestSucceeded: boolean;
}

export class WorkspacesClient extends BaseHttpClient implements ICancelableClient {
#tdeiClient: TdeiClient;
#osmClient: OsmApiClient;
Expand Down Expand Up @@ -241,11 +245,15 @@ export class WorkspacesClient extends BaseHttpClient implements ICancelableClien
);
}

async deleteWorkspace(id: WorkspaceId): Promise<void> {
await Promise.all([
async deleteWorkspace(id: WorkspaceId): Promise<DeleteWorkspaceResult> {
const [workspaceDelete, osmDelete] = await Promise.allSettled([
this._delete(`workspaces/${id}`),
this.#osmClient.deleteWorkspace(id),
]);
if (workspaceDelete.status === 'rejected') {
throw workspaceDelete.reason;
}
return { osmRequestSucceeded: osmDelete.status === 'fulfilled' };
}

async getLongFormQuestSettings(id: WorkspaceId): Promise<QuestSettings> {
Expand Down
81 changes: 72 additions & 9 deletions test/e2e/dashboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ test.describe('dashboard', () => {
expect(bboxRequestCount).toBe(1);
});

test('clicking a failed import status loads the latest job and shows its failure response', async ({ page }) => {
test('treats a completed import with an unsuccessful response as failed', async ({ page }) => {
const failedWorkspace = {
id: 1769,
type: 'osw',
Expand All @@ -357,18 +357,16 @@ test.describe('dashboard', () => {
externalAppAccess: 0,
kartaViewToken: null,
role: 'lead',
importStatus: 'failed'
importStatus: 'completed'
};
const failureMessage = 'Step \'Create Changeset\' failed: no input file was found';
let jobsRequestCount = 0;

await seedAuthenticatedSession(page);
await seedProjectGroupSelection(page, { id: PROJECT_GROUP_ID, name: 'Puget Sound' });
await page.route('**/workspaces/mine', route => route.fulfill({ json: [failedWorkspace] }));
await page.route('**/project-group-roles/**', route => route.fulfill({ json: projectGroups }));
await page.route('**/workspaces/1769/bbox', route => route.fulfill({ status: 204 }));
await page.route('**/workspaces/1769/jobs', (route) => {
jobsRequestCount++;
return route.fulfill({
json: [{
id: 2,
Expand All @@ -390,20 +388,85 @@ test.describe('dashboard', () => {
});

await page.goto('/dashboard');
await expect(page.locator('.workspace-card-updated')).toContainText('Updated');
await expect(page.locator('.dashboard-workspace-updated')).toContainText('Updated');
await expect(page.getByText('Updated At', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'View import failure details' }).click();
const failedBadge = page.getByRole('button', { name: 'View import failure details' });
await expect(failedBadge).toContainText(
'Setup failed. Select to view details',
{ timeout: 30_000 }
);
await failedBadge.click();

const dialog = page.getByRole('dialog', { name: 'Workspace import failed' });
await expect(dialog).toBeVisible();
await expect(dialog).toContainText(failureMessage);
await expect(dialog).toContainText('STEP_EXECUTION_FAILED');
await expect(dialog).toContainText('Create Changeset');
await expect(dialog).not.toContainText('must-not-be-rendered');
expect(jobsRequestCount).toBe(1);
});
test('navigates to Create Workspace while a large dashboard is loading', async ({ page }) => {
await seedAuthenticatedSession(page);

const largeWorkspaceList = Array.from({ length: 1_500 }, (_, index) => ({
...aWorkspace,
id: index + 1,
title: `Workspace ${index + 1}`,
createdAt: new Date(
Date.UTC(2026, 0, 1, 0, 0, index)
).toISOString()
}));

let notifyMineStarted!: () => void;
const mineStarted = new Promise<void>((resolve) => {
notifyMineStarted = resolve;
});

await page.route(`${TEST_API_BASE}workspaces/mine`, async (route) => {
notifyMineStarted();

await new Promise(resolve => setTimeout(resolve, 3_000));
await route.fulfill({ json: largeWorkspaceList }).catch(() => {});
});

await page.route(
`${TEST_API_BASE}tdei-user/project-group-roles/**`,
route => route.fulfill({ json: projectGroups })
);

await page.goto('/');

await page
.locator('.app-navbar')
.getByRole('link', { name: 'Dashboard', exact: true })
.click();

await mineStarted;

const createWorkspaceLink = page
.locator('.app-navbar')
.getByRole('link', {
name: 'Create Workspace',
exact: true
});

await expect(createWorkspaceLink).toBeVisible();
await createWorkspaceLink.click();

await expect(page).toHaveURL('/workspace/create');
await expect(
page.getByRole('heading', { name: 'Create a Workspace' })
).toBeVisible();

await expect(
page.getByText('Blank Workspace', { exact: true })
).toBeVisible();

await expect(
page.getByText('From TDEI', { exact: true })
).toBeVisible();

await expect(
page.getByText('From File', { exact: true })
).toBeVisible();
});
// @test e2e: clicking on a dataset updates the metadata panel on the right and shows the data extent in the map
// BLOCKED: needs the details panel + maplibre map to render. The map calls
// getWorkspaceBbox (OSM API) and inits maplibre-gl, which requires WebGL in
Expand Down
Loading
Loading