web/satellite/vuetify-poc: shorten project ID in URLs

This change shortens the project ID path segment in Vuetify URLs in
order to make the URLs more aesthetically pleasing and allow users to
see more of the URL in the address bar. The ID path segment is now 11
characters long instead of the previous 36, but in rare cases where a
user is a member of multiple projects with the same ID prefix, it
expands to preserve uniqueness.

Resolves #6308

Change-Id: I25a51d05b72d2cc701c0aa2cd3a6d070080c4b1e
This commit is contained in:
Jeremy Wharton 2023-09-27 22:43:38 -05:00 committed by Storj Robot
parent b069b9b038
commit 4dbf26e153
16 changed files with 218 additions and 50 deletions

View File

@ -20,9 +20,13 @@ import {
} from '@/types/projects'; } from '@/types/projects';
import { ProjectsHttpApi } from '@/api/projects'; import { ProjectsHttpApi } from '@/api/projects';
import { DEFAULT_PAGE_LIMIT } from '@/types/pagination'; import { DEFAULT_PAGE_LIMIT } from '@/types/pagination';
import { hexToBase64 } from '@/utils/strings';
const DEFAULT_PROJECT = new Project('', '', '', '', '', true, 0); const DEFAULT_PROJECT = new Project('', '', '', '', '', true, 0);
const DEFAULT_INVITATION = new ProjectInvitation('', '', '', '', new Date()); const DEFAULT_INVITATION = new ProjectInvitation('', '', '', '', new Date());
const MAXIMUM_URL_ID_LENGTH = 22; // UUID (16 bytes) is 22 base64 characters
export const MINIMUM_URL_ID_LENGTH = 11;
export const DEFAULT_PROJECT_LIMITS = readonly(new ProjectLimits()); export const DEFAULT_PROJECT_LIMITS = readonly(new ProjectLimits());
export class ProjectsState { export class ProjectsState {
@ -53,12 +57,51 @@ export const useProjectsStore = defineStore('projects', () => {
return projects; return projects;
} }
function calculateURLIds(): void {
type urlIdInfo = {
project: Project;
base64Id: string;
urlIdLength: number;
};
const occupied: Record<string, urlIdInfo[]> = {};
state.projects.forEach(p => {
const b64Id = hexToBase64(p.id.replaceAll('-', ''));
const info: urlIdInfo = {
project: p,
base64Id: b64Id,
urlIdLength: MINIMUM_URL_ID_LENGTH,
};
for (; info.urlIdLength <= MAXIMUM_URL_ID_LENGTH; info.urlIdLength++) {
const urlId = b64Id.substring(0, info.urlIdLength);
const others = occupied[urlId];
if (others) {
if (info.urlIdLength === others[0].urlIdLength && info.urlIdLength !== MAXIMUM_URL_ID_LENGTH) {
others.forEach(other => {
occupied[other.base64Id.substring(0, ++other.urlIdLength)] = [other];
});
}
others.push(info);
} else {
occupied[urlId] = [info];
break;
}
}
});
Object.keys(occupied).forEach(urlId => {
const infos = occupied[urlId];
if (infos.length !== 1) return;
infos[0].project.urlId = urlId;
});
}
function setProjects(projects: Project[]): void { function setProjects(projects: Project[]): void {
state.projects = projects; state.projects = projects;
calculateURLIds();
if (!state.selectedProject.id) {
return;
}
const projectsCount = state.projects.length; const projectsCount = state.projects.length;
@ -93,12 +136,13 @@ export const useProjectsStore = defineStore('projects', () => {
state.chartDataBefore = payload.before; state.chartDataBefore = payload.before;
} }
async function createProject(createProjectFields: ProjectFields): Promise<string> { async function createProject(createProjectFields: ProjectFields): Promise<Project> {
const createdProject = await api.create(createProjectFields); const createdProject = await api.create(createProjectFields);
state.projects.push(createdProject); state.projects.push(createdProject);
calculateURLIds();
return createdProject.id; return createdProject;
} }
async function createDefaultProject(userID: string): Promise<void> { async function createDefaultProject(userID: string): Promise<void> {
@ -111,9 +155,9 @@ export const useProjectsStore = defineStore('projects', () => {
userID, userID,
); );
const createdProjectId = await createProject(project); const createdProject = await createProject(project);
selectProject(createdProjectId); selectProject(createdProject.id);
} }
function selectProject(projectID: string): void { function selectProject(projectID: string): void {

View File

@ -108,6 +108,8 @@ export const MAX_DESCRIPTION_LENGTH = 100;
* Project is a type, used for creating new project in backend. * Project is a type, used for creating new project in backend.
*/ */
export class Project { export class Project {
public urlId: string;
public constructor( public constructor(
public id: string = '', public id: string = '',
public name: string = '', public name: string = '',

View File

@ -7,3 +7,17 @@
export function getId(): string { export function getId(): string {
return '_' + Math.random().toString(36).substr(2, 9); return '_' + Math.random().toString(36).substr(2, 9);
} }
/**
* Returns random UUID in "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" format.
*/
export function randomUUID(): string {
const randHex = (numBytes: number): string => {
let str = '';
for (let i = 0; i < numBytes; i++) {
str += Math.round(Math.random()*255).toString(16).padStart(2, '0');
}
return str;
};
return [randHex(4), randHex(2), randHex(2), randHex(2), randHex(6)].join('-');
}

View File

@ -110,3 +110,54 @@ export function humanizeArray(arr: string[]): string {
default: return `${arr.slice(0, len-1).join(', ')}, and ${arr[len-1]}`; default: return `${arr.slice(0, len-1).join(', ')}, and ${arr[len-1]}`;
} }
} }
const b64Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
/**
* Returns the URL-safe base64 representation of a hexadecimal string.
* @param str - the hex string
*/
export function hexToBase64(str: string): string {
if (!str) return '';
if (str.length % 2) {
throw new Error(`Invalid length ${str.length} for hex string`);
}
const bytes = new Uint8Array(str.length/2);
for (let i = 0; i < str.length; i += 2) {
const byteStr = str.substring(i, i+2);
const n = parseInt(byteStr, 16);
if (isNaN(n)) {
throw new Error(`Invalid hex byte '${byteStr}' at position ${i}`);
}
bytes[i/2] = parseInt(str.substring(i, i+2), 16);
}
let out = '';
for (let i = 0; i < bytes.length; i += 3) {
out += b64Chars[(bytes[i] & 0b11111100) >> 2];
let nextSextet = (bytes[i] & 0b00000011) << 4;
if (i + 1 >= bytes.length) {
out += b64Chars[nextSextet];
break;
}
nextSextet |= (bytes[i+1] & 0b11110000) >> 4;
out += b64Chars[nextSextet];
nextSextet = (bytes[i+1] & 0b00001111) << 2;
if (i + 2 >= bytes.length) {
out += b64Chars[nextSextet];
break;
}
nextSextet |= (bytes[i+2] & 0b11000000) >> 6;
out += b64Chars[nextSextet];
out += b64Chars[bytes[i+2] & 0b00111111];
}
if (out.length % 4) {
return out.padEnd(out.length + (4 - (out.length % 4)), '=');
}
return out;
}

View File

@ -1,18 +1,19 @@
// Copyright (C) 2019 Storj Labs, Inc. // Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information. // See LICENSE for copying information.
import { vi } from 'vitest'; import { describe, beforeEach, it, expect, vi } from 'vitest';
import { createPinia, setActivePinia } from 'pinia'; import { createPinia, setActivePinia } from 'pinia';
import { ProjectsHttpApi } from '@/api/projects'; import { ProjectsHttpApi } from '@/api/projects';
import { Project, ProjectFields, ProjectLimits } from '@/types/projects'; import { Project, ProjectFields, ProjectLimits } from '@/types/projects';
import { useProjectsStore } from '@/store/modules/projectsStore'; import { useProjectsStore } from '@/store/modules/projectsStore';
import { randomUUID } from '@/utils/idGenerator';
const limits = new ProjectLimits(15, 12, 14, 13); const limits = new ProjectLimits(15, 12, 14, 13);
const project = new Project('11', 'name', 'descr', '23', 'testOwnerId'); const project = new Project(randomUUID(), 'name', 'descr', '23', 'testOwnerId');
const projects = [ const projects = [
new Project( new Project(
'11', randomUUID(),
'name', 'name',
'descr', 'descr',
'23', '23',
@ -20,7 +21,7 @@ const projects = [
false, false,
), ),
new Project( new Project(
'1', randomUUID(),
'name2', 'name2',
'descr2', 'descr2',
'24', '24',
@ -39,9 +40,9 @@ describe('actions', () => {
const store = useProjectsStore(); const store = useProjectsStore();
store.state.projects = projects; store.state.projects = projects;
store.selectProject('11'); store.selectProject(projects[0].id);
expect(store.state.selectedProject.id).toBe('11'); expect(store.state.selectedProject.id).toBe(projects[0].id);
expect(store.state.currentLimits.bandwidthLimit).toBe(0); expect(store.state.currentLimits.bandwidthLimit).toBe(0);
}); });

View File

@ -1,7 +1,9 @@
// Copyright (C) 2023 Storj Labs, Inc. // Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information. // See LICENSE for copying information.
import { decimalShift, formatPrice } from '@/utils/strings'; import { describe, beforeEach, it, expect, vi } from 'vitest';
import { decimalShift, formatPrice, hexToBase64 } from '@/utils/strings';
describe('decimalShift', (): void => { describe('decimalShift', (): void => {
it('handles empty strings', (): void => { it('handles empty strings', (): void => {
@ -65,3 +67,23 @@ describe('formatPrice', (): void => {
}); });
}); });
}); });
describe('hexToBase64', () => {
it('rejects non-hex strings', () => {
expect(() => hexToBase64('foobar')).toThrowError();
});
it('rejects short strings', () => {
expect(() => hexToBase64('abc')).toThrowError();
});
it('handles empty strings', () => {
expect(hexToBase64('')).toBe('');
});
it('encodes properly', () => {
expect(hexToBase64('14fb9c03d97e')).toBe('FPucA9l-');
expect(hexToBase64('14fb9c03d9')).toBe('FPucA9k=');
expect(hexToBase64('14fb9c03')).toBe('FPucAw==');
});
});

View File

@ -379,7 +379,7 @@ function onFileClick(file: BrowserObject): void {
uriParts.unshift(...filePath.value.split('/')); uriParts.unshift(...filePath.value.split('/'));
} }
const pathAndKey = uriParts.map(part => encodeURIComponent(part)).join('/'); const pathAndKey = uriParts.map(part => encodeURIComponent(part)).join('/');
router.push(`/projects/${projectsStore.state.selectedProject.id}/buckets/${bucketName.value}/${pathAndKey}`); router.push(`/projects/${projectsStore.state.selectedProject.urlId}/buckets/${bucketName.value}/${pathAndKey}`);
return; return;
} }

View File

@ -299,7 +299,7 @@ async function openBucket(bucketName: string): Promise<void> {
} }
analyticsStore.pageVisit(RouteConfig.Buckets.with(RouteConfig.UploadFile).path); analyticsStore.pageVisit(RouteConfig.Buckets.with(RouteConfig.UploadFile).path);
router.push(`/projects/${projectsStore.state.selectedProject.id}/buckets/${bucketsStore.state.fileComponentBucketName}`); router.push(`/projects/${projectsStore.state.selectedProject.urlId}/buckets/${bucketsStore.state.fileComponentBucketName}`);
return; return;
} }
passphraseDialogCallback = () => openBucket(selectedBucketName.value); passphraseDialogCallback = () => openBucket(selectedBucketName.value);

View File

@ -16,7 +16,7 @@
<v-menu activator="parent" location="end" transition="scale-transition"> <v-menu activator="parent" location="end" transition="scale-transition">
<v-list class="pa-2"> <v-list class="pa-2">
<v-list-item link rounded="lg" :to="`/projects/${item.id}/settings`"> <v-list-item link rounded="lg" @click="() => onSettingsClick()">
<template #prepend> <template #prepend>
<icon-settings /> <icon-settings />
</template> </template>
@ -130,10 +130,19 @@ const isDeclining = ref<boolean>(false);
function openProject(): void { function openProject(): void {
if (!props.item) return; if (!props.item) return;
projectsStore.selectProject(props.item.id); projectsStore.selectProject(props.item.id);
router.push(`/projects/${props.item.id}/dashboard`); router.push(`/projects/${projectsStore.state.selectedProject.urlId}/dashboard`);
analyticsStore.pageVisit('/projects/dashboard'); analyticsStore.pageVisit('/projects/dashboard');
} }
/**
* Selects the project and navigates to the project's settings.
*/
function onSettingsClick(): void {
if (!props.item) return;
projectsStore.selectProject(props.item.id);
router.push(`/projects/${projectsStore.state.selectedProject.urlId}/dashboard`);
}
/** /**
* Declines the project invitation. * Declines the project invitation.
*/ */

View File

@ -82,7 +82,7 @@
<v-menu activator="parent" location="bottom end" transition="scale-transition"> <v-menu activator="parent" location="bottom end" transition="scale-transition">
<v-list class="pa-0"> <v-list class="pa-0">
<template v-if="item.raw.role === ProjectRole.Owner"> <template v-if="item.raw.role === ProjectRole.Owner">
<v-list-item link :to="`/projects/${item.raw.id}/settings`"> <v-list-item link @click="() => onSettingsClick(item.raw)">
<template #prepend> <template #prepend>
<icon-settings /> <icon-settings />
</template> </template>
@ -186,10 +186,18 @@ function getFormattedDate(date: Date): string {
*/ */
function openProject(item: ProjectItemModel): void { function openProject(item: ProjectItemModel): void {
projectsStore.selectProject(item.id); projectsStore.selectProject(item.id);
router.push(`/projects/${item.id}/dashboard`); router.push(`/projects/${projectsStore.state.selectedProject.urlId}/dashboard`);
analyticsStore.pageVisit('/projects/dashboard'); analyticsStore.pageVisit('/projects/dashboard');
} }
/**
* Selects the project and navigates to the project's settings.
*/
function onSettingsClick(item: ProjectItemModel): void {
projectsStore.selectProject(item.id);
router.push(`/projects/${projectsStore.state.selectedProject.urlId}/dashboard`);
}
/** /**
* Declines the project invitation. * Declines the project invitation.
*/ */

View File

@ -129,7 +129,7 @@ const model = computed<boolean>({
}); });
function redirectToBucketsPage(): void { function redirectToBucketsPage(): void {
router.push(`/projects/${projectsStore.state.selectedProject.id}/buckets`); router.push(`/projects/${projectsStore.state.selectedProject.urlId}/buckets`);
} }
const bucket = computed((): Bucket => { const bucket = computed((): Bucket => {

View File

@ -124,7 +124,7 @@ import {
} from 'vuetify/components'; } from 'vuetify/components';
import { RequiredRule, ValidationRule } from '@poc/types/common'; import { RequiredRule, ValidationRule } from '@poc/types/common';
import { MAX_DESCRIPTION_LENGTH, MAX_NAME_LENGTH, ProjectFields } from '@/types/projects'; import { MAX_DESCRIPTION_LENGTH, MAX_NAME_LENGTH, Project, ProjectFields } from '@/types/projects';
import { useLoading } from '@/composables/useLoading'; import { useLoading } from '@/composables/useLoading';
import { useProjectsStore } from '@/store/modules/projectsStore'; import { useProjectsStore } from '@/store/modules/projectsStore';
import { useUsersStore } from '@/store/modules/usersStore'; import { useUsersStore } from '@/store/modules/usersStore';
@ -171,17 +171,17 @@ const descriptionRules: ValidationRule<string>[] = [
async function onCreateClicked(): Promise<void> { async function onCreateClicked(): Promise<void> {
if (!formValid.value) return; if (!formValid.value) return;
await withLoading(async () => { await withLoading(async () => {
let id: string; let project: Project;
try { try {
const fields = new ProjectFields(name.value, description.value, usersStore.state.user.id); const fields = new ProjectFields(name.value, description.value, usersStore.state.user.id);
id = await projectsStore.createProject(fields); project = await projectsStore.createProject(fields);
} catch (error) { } catch (error) {
error.message = `Failed to create project. ${error.message}`; error.message = `Failed to create project. ${error.message}`;
notify.notifyError(error, AnalyticsErrorEventSource.CREATE_PROJECT_MODAL); notify.notifyError(error, AnalyticsErrorEventSource.CREATE_PROJECT_MODAL);
return; return;
} }
model.value = false; model.value = false;
router.push(`/projects/${id}/dashboard`); router.push(`/projects/${project.urlId}/dashboard`);
notify.success('Project created.'); notify.success('Project created.');
}); });
} }

View File

@ -110,7 +110,7 @@ const isDeclining = ref<boolean>(false);
function openProject(): void { function openProject(): void {
projectsStore.selectProject(props.id); projectsStore.selectProject(props.id);
notify.success('Invite accepted!'); notify.success('Invite accepted!');
router.push(`/projects/${props.id}/dashboard`); router.push(`/projects/${projectsStore.state.selectedProject.urlId}/dashboard`);
analyticsStore.pageVisit('/projects/dashboard'); analyticsStore.pageVisit('/projects/dashboard');
} }

View File

@ -30,7 +30,7 @@ import { Project } from '@/types/projects';
import { useBillingStore } from '@/store/modules/billingStore'; import { useBillingStore } from '@/store/modules/billingStore';
import { useUsersStore } from '@/store/modules/usersStore'; import { useUsersStore } from '@/store/modules/usersStore';
import { useABTestingStore } from '@/store/modules/abTestingStore'; import { useABTestingStore } from '@/store/modules/abTestingStore';
import { useProjectsStore } from '@/store/modules/projectsStore'; import { MINIMUM_URL_ID_LENGTH, useProjectsStore } from '@/store/modules/projectsStore';
import { useAppStore } from '@poc/store/appStore'; import { useAppStore } from '@poc/store/appStore';
import { useAnalyticsStore } from '@/store/modules/analyticsStore'; import { useAnalyticsStore } from '@/store/modules/analyticsStore';
import { useAccessGrantsStore } from '@/store/modules/accessGrantsStore'; import { useAccessGrantsStore } from '@/store/modules/accessGrantsStore';
@ -55,29 +55,46 @@ const agStore = useAccessGrantsStore();
const isLoading = ref<boolean>(true); const isLoading = ref<boolean>(true);
/** /**
* Selects the project with the given ID, redirecting to the * Selects the project with the given URL ID, redirecting to the
* all projects dashboard if no such project exists. * all projects dashboard if no such project exists.
*/ */
async function selectProject(projectId: string): Promise<void> { async function selectProject(urlId: string): Promise<void> {
const goToDashboard = () => {
const path = '/projects';
router.push(path);
analyticsStore.pageVisit(path);
};
if (urlId.length < MINIMUM_URL_ID_LENGTH) {
goToDashboard();
return;
}
let projects: Project[]; let projects: Project[];
try { try {
projects = await projectsStore.getProjects(); projects = await projectsStore.getProjects();
} catch (_) { } catch (_) {
const path = '/projects'; goToDashboard();
router.push(path);
analyticsStore.pageVisit(path);
return; return;
} }
if (!projects.some(p => p.id === projectId)) {
const path = '/projects'; const project = projects.find(p => {
router.push(path); let prefixEnd = 0;
analyticsStore.pageVisit(path); while (
p.urlId[prefixEnd] === urlId[prefixEnd]
&& prefixEnd < p.urlId.length
&& prefixEnd < urlId.length
) prefixEnd++;
return prefixEnd === p.urlId.length || prefixEnd === urlId.length;
});
if (!project) {
goToDashboard();
return; return;
} }
projectsStore.selectProject(projectId); projectsStore.selectProject(project.id);
} }
watch(() => route.params.projectId, async newId => { watch(() => route.params.id, async newId => {
if (newId === undefined) return; if (newId === undefined) return;
isLoading.value = true; isLoading.value = true;
await selectProject(newId as string); await selectProject(newId as string);
@ -111,7 +128,7 @@ onBeforeMount(async () => {
notify.notifyError(error, AnalyticsErrorEventSource.OVERALL_APP_WRAPPER_ERROR); notify.notifyError(error, AnalyticsErrorEventSource.OVERALL_APP_WRAPPER_ERROR);
} }
await selectProject(route.params.projectId as string); await selectProject(route.params.id as string);
if (!agStore.state.accessGrantsWebWorker) await agStore.startWorker(); if (!agStore.state.accessGrantsWebWorker) await agStore.startWorker();

View File

@ -30,7 +30,7 @@
:key="project.id" :key="project.id"
rounded="lg" rounded="lg"
:active="project.isSelected" :active="project.isSelected"
@click="() => onProjectSelected(project.id)" @click="() => onProjectSelected(project)"
> >
<template v-if="project.isSelected" #prepend> <template v-if="project.isSelected" #prepend>
<img src="@poc/assets/icon-check-color.svg" alt="Selected Project"> <img src="@poc/assets/icon-check-color.svg" alt="Selected Project">
@ -62,7 +62,7 @@
:key="project.id" :key="project.id"
rounded="lg" rounded="lg"
:active="project.isSelected" :active="project.isSelected"
@click="() => onProjectSelected(project.id)" @click="() => onProjectSelected(project)"
> >
<template v-if="project.isSelected" #prepend> <template v-if="project.isSelected" #prepend>
<img src="@poc/assets/icon-check-color.svg" alt="Selected Project"> <img src="@poc/assets/icon-check-color.svg" alt="Selected Project">
@ -76,7 +76,7 @@
</template> </template>
<!-- Project Settings --> <!-- Project Settings -->
<v-list-item link rounded="lg" :to="`/projects/${selectedProject.id}/settings`"> <v-list-item link rounded="lg" :to="`/projects/${selectedProject.urlId}/settings`">
<template #prepend> <template #prepend>
<IconSettings /> <IconSettings />
</template> </template>
@ -136,7 +136,7 @@
<v-divider class="my-2" /> <v-divider class="my-2" />
<v-list-item link router-link :to="`/projects/${selectedProject.id}/dashboard`" class="my-1 py-3" rounded="lg" @click="() => registerLinkClick('/dashboard')"> <v-list-item link router-link :to="`/projects/${selectedProject.urlId}/dashboard`" class="my-1 py-3" rounded="lg" @click="() => registerLinkClick('/dashboard')">
<template #prepend> <template #prepend>
<IconDashboard /> <IconDashboard />
</template> </template>
@ -145,7 +145,7 @@
</v-list-item-title> </v-list-item-title>
</v-list-item> </v-list-item>
<v-list-item link router-link :to="`/projects/${selectedProject.id}/buckets`" class="my-1" rounded="lg" @click="() => registerLinkClick('/buckets')"> <v-list-item link router-link :to="`/projects/${selectedProject.urlId}/buckets`" class="my-1" rounded="lg" @click="() => registerLinkClick('/buckets')">
<template #prepend> <template #prepend>
<IconBucket /> <IconBucket />
</template> </template>
@ -154,7 +154,7 @@
</v-list-item-title> </v-list-item-title>
</v-list-item> </v-list-item>
<v-list-item link router-link :to="`/projects/${selectedProject.id}/access`" class="my-1" rounded="lg" @click="() => registerLinkClick('/access')"> <v-list-item link router-link :to="`/projects/${selectedProject.urlId}/access`" class="my-1" rounded="lg" @click="() => registerLinkClick('/access')">
<template #prepend> <template #prepend>
<IconAccess size="18" /> <IconAccess size="18" />
</template> </template>
@ -163,7 +163,7 @@
</v-list-item-title> </v-list-item-title>
</v-list-item> </v-list-item>
<v-list-item link router-link :to="`/projects/${selectedProject.id}/team`" class="my-1" rounded="lg" @click="() => registerLinkClick('/team')"> <v-list-item link router-link :to="`/projects/${selectedProject.urlId}/team`" class="my-1" rounded="lg" @click="() => registerLinkClick('/team')">
<template #prepend> <template #prepend>
<IconTeam size="18" /> <IconTeam size="18" />
</template> </template>
@ -375,13 +375,13 @@ function compareProjects(a: Project, b: Project): number {
/** /**
* Handles click event for items in the project dropdown. * Handles click event for items in the project dropdown.
*/ */
async function onProjectSelected(projectId: string): Promise<void> { async function onProjectSelected(project: Project): Promise<void> {
analyticsStore.eventTriggered(AnalyticsEvent.NAVIGATE_PROJECTS); analyticsStore.eventTriggered(AnalyticsEvent.NAVIGATE_PROJECTS);
await router.push({ await router.push({
name: route.name || undefined, name: route.name || undefined,
params: { params: {
...route.params, ...route.params,
projectId, id: project.urlId,
}, },
}); });
bucketsStore.clearS3Data(); bucketsStore.clearS3Data();

View File

@ -61,7 +61,7 @@ const routes: RouteRecordRaw[] = [
], ],
}, },
{ {
path: '/projects/:projectId', path: '/projects/:id',
name: RouteName.Project, name: RouteName.Project,
component: () => import('@poc/layouts/default/Default.vue'), component: () => import('@poc/layouts/default/Default.vue'),
children: [ children: [