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:
parent
b069b9b038
commit
4dbf26e153
@ -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 {
|
||||||
|
@ -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 = '',
|
||||||
|
@ -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('-');
|
||||||
|
}
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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==');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
@ -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 => {
|
||||||
|
@ -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.');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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();
|
||||||
|
|
||||||
|
@ -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();
|
||||||
|
@ -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: [
|
||||||
|
Loading…
Reference in New Issue
Block a user