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
106 changes: 72 additions & 34 deletions pages/dashboard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -304,19 +304,24 @@ import {
keepOnePinPerProjectGroup,
writeWorkspacePins,
} from '~/util/workspace-pins';
import {
readDashboardSelection,
resolveProjectGroupId,
writeDashboardSelection,
} from '~/util/dashboard-selection';
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';
import type { DashboardSelection } from '~/util/dashboard-selection';

type JobFailureDialog = {
show: (workspaceId: number) => Promise<void>;
};

const STORAGE_KEY_PROJECT_GROUP = 'tdei-selected-project-group';
const STORAGE_KEY_WORKSPACE = 'tdei-selected-workspace';
const LEGACY_STORAGE_KEY_PROJECT_GROUP = 'tdei-selected-project-group';
const route = useRoute();
const router = useRouter();
const requestedWorkspaceNotFound = ref(false);
Expand Down Expand Up @@ -346,8 +351,12 @@ const workspacesByProjectGroup = computed(() =>
Map.groupBy(workspaces.value, workspace => workspace.tdeiProjectGroupId)
);

const dashboardSelection = loadDashboardSelection();
const currentProjectGroup = ref<string | null>(
getLastProjectGroupId() ?? myProjectGroups[0]?.tdei_project_group_id ?? null
resolveProjectGroupId(
dashboardSelection.projectGroupId ?? getLegacyProjectGroupId(),
myProjectGroups.map(projectGroup => projectGroup.tdei_project_group_id)
)
);
const currentWorkspace = ref<Workspace>();
const workspaceSearch = ref('');
Expand Down Expand Up @@ -391,9 +400,14 @@ const workspaceUpdatedTime = computed(() =>
? formatElapsed(currentWorkspace.value.updatedAt ?? currentWorkspace.value.createdAt)
: ''
);
watch(currentProjectGroup, (projectGroupId) => {
dashboardSelection.projectGroupId = projectGroupId;
saveDashboardSelection();
}, { immediate: true });
watch(currentWorkspace, (workspace) => {
if (workspace) {
setLastWorkspaceId(workspace.id);
dashboardSelection.workspaceByProjectGroup[workspace.tdeiProjectGroupId] = workspace.id;
saveDashboardSelection(workspace.tdeiProjectGroupId, workspace.id);
}
});

Expand All @@ -414,19 +428,13 @@ watch(

onMounted(() => {
loadWorkspacePins();
if (route.query.workspace == null) {
const lastWorkspace = workspaces.value.find(workspace => workspace.id === getLastWorkspaceId());
if (lastWorkspace) {
currentProjectGroup.value = lastWorkspace.tdeiProjectGroupId;
selectWorkspace(lastWorkspace, false);
}
}
applyWorkspaceFromRoute();
if (!requestedWorkspaceNotFound.value) {
syncSelectedWorkspace(currentWorkspaces.value);
}
});

/** Synchronizes the active workspace with the currently selected project group. */
function syncSelectedWorkspace(
availableWorkspaces: Workspace[]
): void {
Expand All @@ -436,16 +444,34 @@ function syncSelectedWorkspace(

if (availableWorkspaces.length === 0) {
currentWorkspace.value = undefined;
clearWorkspaceQuery();
return;
}

// Restore the last selected workspace for this project group, or fall back to the first.
const rememberedWorkspaceId = currentProjectGroup.value
? dashboardSelection.workspaceByProjectGroup[currentProjectGroup.value]
: undefined;
const selectedWorkspace = availableWorkspaces.find(
workspace => workspace.id === currentWorkspace.value?.id
) ?? availableWorkspaces.find(
workspace => workspace.id === rememberedWorkspaceId
);

selectWorkspace(selectedWorkspace ?? availableWorkspaces[0]!);
}

/** Clears a stale `?workspace=` query parameter when switching to an empty project group. */
function clearWorkspaceQuery(): void {
if (route.query.workspace == null) {
return;
}

const query = { ...route.query };
delete query.workspace;
void router.replace({ path: '/dashboard', query });
}

function applyWorkspaceFromRoute(): void {
const workspaceQueryValue = route.query.workspace;
const routeWorkspaceId = workspaceQueryValue == null
Expand Down Expand Up @@ -600,51 +626,63 @@ function onCenterLoaded(center: WorkspaceCenter): void {
}
}

function getLastProjectGroupId(): string | null {
if (import.meta.server) {
return null;
/** Loads the saved dashboard project group and workspace selection for the current user. */
function loadDashboardSelection(): DashboardSelection {
if (import.meta.server || !tdeiAuth.subject) {
return { projectGroupId: null, workspaceByProjectGroup: {} };
}

try {
const rawSelection = sessionStorage.getItem(STORAGE_KEY_PROJECT_GROUP);
if (!rawSelection) {
return null;
}

const storedSelection = JSON.parse(rawSelection) as { id?: unknown };
return typeof storedSelection.id === 'string' ? storedSelection.id : null;
return readDashboardSelection(localStorage, tdeiAuth.subject);
}
catch {
return null;
return { projectGroupId: null, workspaceByProjectGroup: {} };
}
}

function getLastWorkspaceId(): number | null {
if (import.meta.server) {
return null;
/** Re-reads stored selection, merges the latest update, and writes back to localStorage. */
function saveDashboardSelection(updatedGroupId?: string, updatedWorkspaceId?: number): void {
if (import.meta.server || !tdeiAuth.subject) {
return;
}

try {
const storedWorkspaceId = Number(sessionStorage.getItem(STORAGE_KEY_WORKSPACE));
return Number.isFinite(storedWorkspaceId) && storedWorkspaceId > 0
? storedWorkspaceId
: null;
const latest = readDashboardSelection(localStorage, tdeiAuth.subject);
const workspaceByProjectGroup = {
...latest.workspaceByProjectGroup,
...dashboardSelection.workspaceByProjectGroup,
};
if (updatedGroupId && updatedWorkspaceId !== undefined) {
workspaceByProjectGroup[updatedGroupId] = updatedWorkspaceId;
}
dashboardSelection.workspaceByProjectGroup = workspaceByProjectGroup;
writeDashboardSelection(localStorage, tdeiAuth.subject, {
projectGroupId: dashboardSelection.projectGroupId,
workspaceByProjectGroup,
});
}
catch {
return null;
// Browsers can disable storage; selection persistence is optional.
}
}

function setLastWorkspaceId(workspaceId: number): void {
/** Reads the legacy session-scoped project group selection as a fallback. */
function getLegacyProjectGroupId(): string | null {
if (import.meta.server) {
return;
return null;
}

try {
sessionStorage.setItem(STORAGE_KEY_WORKSPACE, String(workspaceId));
const rawSelection = sessionStorage.getItem(LEGACY_STORAGE_KEY_PROJECT_GROUP);
if (!rawSelection) {
return null;
}

const storedSelection = JSON.parse(rawSelection) as { id?: unknown };
return typeof storedSelection.id === 'string' ? storedSelection.id : null;
}
catch {
// Browsers can disable storage. Selection persistence is optional.
return null;
}
}
</script>
Expand Down
18 changes: 15 additions & 3 deletions pages/workspace/[id]/settings/members.vue
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,11 @@
show
>
<app-icon variant="info" />
No one has a TDEI POC role in this project group.
{{
accessDenied
? 'You do not have permission to view the member list.'
: 'No one has a TDEI POC role in this project group.'
}}
</b-alert>
</div>

Expand Down Expand Up @@ -127,7 +131,11 @@
show
>
<app-icon variant="info" />
No one has a TDEI data generator role in this project group.
{{
accessDenied
? 'You do not have permission to view the member list.'
: 'No one has a TDEI data generator role in this project group.'
}}
</b-alert>
</div>

Expand Down Expand Up @@ -229,7 +237,11 @@
show
>
<app-icon variant="info" />
There are no unprivileged members in this project group.
{{
accessDenied
? 'You do not have permission to view the member list.'
: 'There are no unprivileged members in this project group.'
}}
</b-alert>
</div>
</b-col>
Expand Down
8 changes: 8 additions & 0 deletions test/e2e/settings-members.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,14 @@ test.describe('workspace member roles', () => {
await expect(
page.getByText('You need a TDEI POC role in this project group')
).toBeVisible();

const permissionNotice = 'You do not have permission to view the member list.';
const content = page.locator('.workspace-setting-tab-column-right');
const sections = content.locator('.members-group');
await expect(content.getByText(permissionNotice)).toHaveCount(3);
await expect(sections.nth(0).getByText(permissionNotice)).toBeVisible();
await expect(sections.nth(1).getByText(permissionNotice)).toBeVisible();
await expect(sections.nth(2).getByText(permissionNotice)).toBeVisible();
});

// @test e2e: POCs should be displayed on this page, matching the results of the
Expand Down
82 changes: 82 additions & 0 deletions test/unit/util/dashboard-selection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
getDashboardSelectionStorageKey,
readDashboardSelection,
resolveProjectGroupId,
writeDashboardSelection,
} from '~/util/dashboard-selection';

describe('dashboard selection persistence', () => {
const userId = 'user-123';

beforeEach(() => {
localStorage.removeItem(getDashboardSelectionStorageKey(userId));
});

it('uses a user-scoped storage key', () => {
expect(getDashboardSelectionStorageKey(userId)).toBe('tdei-dashboard-selection:user-123');
});

it('round-trips the selected project group and per-group workspaces', () => {
writeDashboardSelection(localStorage, userId, {
projectGroupId: 'pg-2',
workspaceByProjectGroup: { 'pg-1': 10, 'pg-2': 20 },
});

expect(readDashboardSelection(localStorage, userId)).toEqual({
projectGroupId: 'pg-2',
workspaceByProjectGroup: { 'pg-1': 10, 'pg-2': 20 },
});
});

it('ignores malformed stored values', () => {
localStorage.setItem(getDashboardSelectionStorageKey(userId), JSON.stringify({
projectGroupId: '',
workspaceByProjectGroup: { 'pg-1': 10, 'pg-2': 'bad', 'pg-3': -5 },
}));

expect(readDashboardSelection(localStorage, userId)).toEqual({
projectGroupId: null,
workspaceByProjectGroup: { 'pg-1': 10 },
});

localStorage.setItem(getDashboardSelectionStorageKey(userId), '{invalid json');
expect(readDashboardSelection(localStorage, userId)).toEqual({
projectGroupId: null,
workspaceByProjectGroup: {},
});
});

it('resolves the remembered project group if available or falls back to the first', () => {
expect(resolveProjectGroupId('pg-2', ['pg-1', 'pg-2'])).toBe('pg-2');
expect(resolveProjectGroupId('pg-missing', ['pg-1', 'pg-2'])).toBe('pg-1');
expect(resolveProjectGroupId(null, ['pg-1', 'pg-2'])).toBe('pg-1');
expect(resolveProjectGroupId('pg-1', [])).toBeNull();
});

it('returns an empty selection when storage.getItem throws', () => {
const throwingGetStorage = {
getItem: () => {
throw new Error('Storage access blocked');
},
} as unknown as Storage;

expect(readDashboardSelection(throwingGetStorage, userId)).toEqual({
projectGroupId: null,
workspaceByProjectGroup: {},
});
});

it('does not throw when storage.setItem fails', () => {
const throwingSetStorage = {
setItem: () => {
throw new Error('QuotaExceededError');
},
} as unknown as Storage;

expect(() => writeDashboardSelection(throwingSetStorage, userId, {
projectGroupId: 'pg-1',
workspaceByProjectGroup: { 'pg-1': 1 },
})).not.toThrow();
});
});
Loading
Loading