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
85 changes: 85 additions & 0 deletions composables/useWorkspaceTitleAvailability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { workspacesClient } from '~/services/index';

import type { Ref } from 'vue';

const CHECK_DELAY = 300;

export function useWorkspaceTitleAvailability(
title: Ref<string>,
projectGroupId: Ref<string | null>
) {
const available = ref<boolean | null>(null);
const checking = ref(false);
const error = ref<Error | null>(null);
let requestId = 0;
let checkTimer: ReturnType<typeof setTimeout> | undefined;

function reset() {
clearTimeout(checkTimer);
available.value = null;
checking.value = false;
error.value = null;
}

async function check() {
const currentRequestId = ++requestId;
const normalizedTitle = title.value.trim();
const currentProjectGroupId = projectGroupId.value;

if (!normalizedTitle || !currentProjectGroupId) {
reset();
return;
}

checking.value = true;
error.value = null;

try {
const result = await workspacesClient.checkWorkspaceTitleAvailability({
title: normalizedTitle,
tdeiProjectGroupId: currentProjectGroupId,
});
if (currentRequestId === requestId) {
available.value = result.available;
}
}
catch (reason: unknown) {
if (currentRequestId === requestId) {
available.value = null;
error.value = reason instanceof Error
? reason
: new Error('Unable to check workspace title availability.');
}
}
finally {
if (currentRequestId === requestId) {
checking.value = false;
}
}
}

watch([title, projectGroupId], () => {
++requestId;
reset();

const normalizedTitle = title.value.trim();
if (!normalizedTitle || !projectGroupId.value) {
return;
}

checkTimer = setTimeout(() => {
void check();
}, CHECK_DELAY);
});

onUnmounted(() => {
++requestId;
clearTimeout(checkTimer);
});

return {
available,
checking,
error,
};
}
26 changes: 26 additions & 0 deletions pages/workspace/create/blank.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,27 @@
class="form-control"
>
</label>
<div
v-if="checkingTitleAvailability"
class="text-secondary mb-3"
role="status"
>
Checking workspace title availability...
</div>
<div
v-else-if="titleAvailable === false"
class="alert alert-warning mb-3"
role="alert"
>
A workspace with this title already exists in the selected project group.
</div>
<div
v-else-if="titleAvailabilityError"
class="alert alert-secondary mb-3"
role="alert"
>
Unable to check workspace title availability. You can still create this workspace.
</div>

<div class="mb-3">
<label
Expand Down Expand Up @@ -103,6 +124,11 @@ const creating = reactive(new LoadingContext());
const workspaceTitle = ref('');
const projectGroupId = ref<string | null>(null);
const datasetType = ref<string | null>('osw');
const {
available: titleAvailable,
checking: checkingTitleAvailability,
error: titleAvailabilityError,
} = useWorkspaceTitleAvailability(workspaceTitle, projectGroupId);

const complete = computed(() =>
workspaceTitle.value.trim().length > 0
Expand Down
26 changes: 26 additions & 0 deletions pages/workspace/create/file.vue
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,27 @@
required
>
</label>
<div
v-if="checkingTitleAvailability"
class="text-secondary mb-3"
role="status"
>
Checking workspace title availability...
</div>
<div
v-else-if="titleAvailable === false"
class="alert alert-warning mb-3"
role="alert"
>
A workspace with this title already exists in the selected project group.
</div>
<div
v-else-if="titleAvailabilityError"
class="alert alert-secondary mb-3"
role="alert"
>
Unable to check workspace title availability. You can still create this workspace.
</div>

<div class="mb-3">
<label
Expand Down Expand Up @@ -159,6 +180,11 @@ const creationInitiatedModal = useTemplateRef<ComponentExposed<typeof WorkspaceC
const workspaceTitle = ref('');
const projectGroupId = ref<string | null>(null);
const datasetType = ref<string | null>(null);
const {
available: titleAvailable,
checking: checkingTitleAvailability,
error: titleAvailabilityError,
} = useWorkspaceTitleAvailability(workspaceTitle, projectGroupId);
const datasetFile = ref<File | null>(null);
const archiveInspection = ref<DatasetArchiveInspection | null>(null);
const archiveChecking = ref(false);
Expand Down
26 changes: 26 additions & 0 deletions pages/workspace/create/tdei.vue
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,27 @@
required
>
</label>
<div
v-if="checkingTitleAvailability"
class="text-secondary mb-3"
role="status"
>
Checking workspace title availability...
</div>
<div
v-else-if="titleAvailable === false"
class="alert alert-warning mb-3"
role="alert"
>
A workspace with this title already exists in the selected project group.
</div>
<div
v-else-if="titleAvailabilityError"
class="alert alert-secondary mb-3"
role="alert"
>
Unable to check workspace title availability. You can still create this workspace.
</div>

<div class="mb-3">
<label
Expand Down Expand Up @@ -240,6 +261,11 @@ let mapInitId = 0;
const workspaceTitle = ref('');
const projectGroupId = ref<string | null>(null);
const datasetError = ref<string | null>(null);
const {
available: titleAvailable,
checking: checkingTitleAvailability,
error: titleAvailabilityError,
} = useWorkspaceTitleAvailability(workspaceTitle, projectGroupId);
const createdWorkspaceId = ref<number | undefined>();
const creationInitiatedModal = useTemplateRef<ComponentExposed<typeof WorkspaceCreationModal>>('creationInitiatedModal');

Expand Down
17 changes: 17 additions & 0 deletions services/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import type {
WorkspacePatch,
WorkspaceRole,
WorkspaceTeam,
WorkspaceTitleAvailability,
WorkspaceTitleAvailabilityRequest,
} from '~/types/workspaces';

export function compareWorkspaceCreatedAtDesc(a: Workspace, b: Workspace) {
Expand Down Expand Up @@ -166,6 +168,21 @@ export class WorkspacesClient extends BaseHttpClient implements ICancelableClien
return workspaceId;
}

async checkWorkspaceTitleAvailability(
request: WorkspaceTitleAvailabilityRequest
): Promise<WorkspaceTitleAvailability> {
const originalBaseUrl = this._baseUrl;
this._baseUrl = this.#newApiUrl;

try {
const response = await this._post('workspaces/check', request);
return await response.json();
}
finally {
this._baseUrl = originalBaseUrl;
}
}

async createWorkspaceFromFile(file: Blob, workspace: WorkspaceCreation): Promise<WorkspaceId> {
const formData = new FormData();
formData.append('file', file);
Expand Down
10 changes: 9 additions & 1 deletion test/e2e/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,12 @@ function isNewApiRequest(req: Request): boolean {

// Begin recording. Returns a function that validates everything seen so far and
// returns the list of contract violations (empty == conformant).
export function recordContract(page: Page) {
export function recordContract(
page: Page,
options: { ignoredPaths?: readonly string[] } = {}
) {
const calls: RecordedCall[] = [];
const ignoredPaths = new Set(options.ignoredPaths);

page.on('response', async (response) => {
const req = response.request();
Expand All @@ -109,6 +113,10 @@ export function recordContract(page: Page) {
violations(): ContractViolation[] {
const out: ContractViolation[] = [];
for (const c of calls) {
if (ignoredPaths.has(c.recordedPath)) {
continue;
}

const specKey = '/api/v1/' + c.recordedPath;
const match = PATH_MATCHERS.find(m => m.re.test(specKey));
if (!match) {
Expand Down
23 changes: 21 additions & 2 deletions test/e2e/create-blank.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,18 @@ const createdWorkspace = {
// `postBehavior` lets a test swap in an error/slow response for the create POST.
async function stubCreateFlow(
page: import('@playwright/test').Page,
postBehavior?: (route: import('@playwright/test').Route) => unknown
postBehavior?: (route: import('@playwright/test').Route) => unknown,
titleAvailable = true
) {
// Project group picker (TDEI user API).
await page.route('**/tdei-user/project-group-roles/**', route =>
route.fulfill({ json: projectGroups })
);

await page.route('**/workspaces/check', route =>
route.fulfill({ status: 200, json: { available: titleAvailable } })
);

// POST workspaces -> { workspaceId } (spec 201 is additionalProperties:integer).
await page.route('**/workspaces', (route) => {
if (route.request().method() !== 'POST') {
Expand Down Expand Up @@ -128,7 +133,7 @@ test.describe('create blank workspace', () => {
await seedProjectGroupSelection(page, { id: PROJECT_GROUP_ID, name: 'Puget Sound' });
await stubCreateFlow(page);

const contract = recordContract(page);
const contract = recordContract(page, { ignoredPaths: ['workspaces/check'] });

await page.goto('/workspace/create/blank');
await fillForm(page);
Expand All @@ -138,6 +143,20 @@ test.describe('create blank workspace', () => {
expect(contract.violations()).toEqual([]);
});

test('warns when the title already exists in the selected project group', async ({ page }) => {
await seedAuthenticatedSession(page);
await seedProjectGroupSelection(page, { id: PROJECT_GROUP_ID, name: 'Puget Sound' });
await stubCreateFlow(page, undefined, false);

await page.goto('/workspace/create/blank');
await fillForm(page);

await expect(page.getByRole('alert')).toContainText(
'A workspace with this title already exists in the selected project group.'
);
await expect(page.getByRole('button', { name: 'Create Workspace' })).toBeEnabled();
});

// @test e2e: if an API error occurs when creating a workspace from either form, an error message is shown
test('shows an error toast on create failure', async ({ page }) => {
await seedAuthenticatedSession(page);
Expand Down
25 changes: 23 additions & 2 deletions test/e2e/create-file.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,19 @@ const COLD_ROUTE_TIMEOUT = 30_000;

// Stubs every endpoint the create-from-file flow hits so nothing 500s.
// `opts.failCreate` forces POST /workspaces/from-file to 500 for the error path.
async function stubCreateFlow(page: import('@playwright/test').Page, opts: { failCreate?: boolean } = {}) {
async function stubCreateFlow(
page: import('@playwright/test').Page,
opts: { failCreate?: boolean; titleAvailable?: boolean } = {}
) {
// Project group picker (TDEI user API).
await page.route('**/project-group-roles/**', route =>
route.fulfill({ json: projectGroups })
);

await page.route('**/workspaces/check', route =>
route.fulfill({ status: 200, json: { available: opts.titleAvailable ?? true } })
);

// new-API: create the workspace from file. Spec: 201/200 with { workspaceId: <integer> }.
await page.route(`${TEST_API_BASE}workspaces/from-file`, (route) => {
if (route.request().method() !== 'POST') return route.fallback();
Expand Down Expand Up @@ -131,6 +138,20 @@ test.describe('create workspace from file', () => {
await expect(page).toHaveURL(new RegExp('/dashboard\\?workspace=' + NEW_WORKSPACE_ID));
});

test('warns when the title already exists in the selected project group', async ({ page }) => {
await seedAuthenticatedSession(page);
await seedProjectGroupSelection(page, { id: PROJECT_GROUP_ID, name: 'Puget Sound' });
await stubCreateFlow(page, { titleAvailable: false });

await page.goto('/workspace/create/file');
await fillForm(page, VALID_ZIP_FILE);

await expect(page.getByRole('alert')).toContainText(
'A workspace with this title already exists in the selected project group.'
);
await expect(page.getByRole('button', { name: 'Create Workspace' })).toBeEnabled();
});

test('an invalid file type is rejected and surfaces an error', async ({ page }) => {
await seedAuthenticatedSession(page);
await seedProjectGroupSelection(page, { id: PROJECT_GROUP_ID, name: 'Puget Sound' });
Expand Down Expand Up @@ -179,7 +200,7 @@ test.describe('create workspace from file', () => {
await seedProjectGroupSelection(page, { id: PROJECT_GROUP_ID, name: 'Puget Sound' });
await stubCreateFlow(page);

const contract = recordContract(page);
const contract = recordContract(page, { ignoredPaths: ['workspaces/check'] });

await page.goto('/workspace/create/file');
await fillForm(page, VALID_ZIP_FILE);
Expand Down
Loading
Loading