From 01a558a1f15d167d73f1c6b2679af08c5e333957 Mon Sep 17 00:00:00 2001 From: shweta2101 Date: Thu, 7 May 2026 10:56:40 +0530 Subject: [PATCH 01/11] Enhance project group picker and persist selection Refactor ProjectGroupPicker UI and behavior: rebuild dropdown markup/CSS, show result counts and a scroll-to-load hint, add debounced input and click-to-open behavior, change infinite-scroll trigger to 80% of list height, and make hasMore/totalCount reactive. Improve selection logic to prefer current model or first item and avoid unnecessary reloads when unfiltered results are present. Persist selection across sessions: add localStorage getters/setters for selected project group and workspace, restore them on mount, and save updates via watchers; add a min-width for the picker in dashboard styles. API/client changes: clear stored selection keys on logout and update TdeiUserClient.getMyProjectGroups to return { items, total }, accept a sort_by param, and parse X-Total-Count for total results (with fallback on parse errors). These changes support accurate pagination and the new picker UI. --- components/ProjectGroupPicker.vue | 162 ++++++++++++++++++++++-------- pages/dashboard.vue | 25 ++++- services/tdei.ts | 12 ++- 3 files changed, 148 insertions(+), 51 deletions(-) diff --git a/components/ProjectGroupPicker.vue b/components/ProjectGroupPicker.vue index 549bfcb..e2b7891 100644 --- a/components/ProjectGroupPicker.vue +++ b/components/ProjectGroupPicker.vue @@ -7,37 +7,56 @@ :disabled="props.disabled" placeholder="Search project groups..." @focus="onFocus" + @click="onInputClick" + @input="onInput" @keydown="onKeydown" /> - + + + @@ -55,14 +74,16 @@ const isOpen = ref(false) const projectGroups = ref<{ id: string; name: string }[]>([]) const selectedGroupName = ref('') const loading = ref(false) +const totalCount = ref(null) const pickerRef = ref(null) const listRef = ref(null) const activeIndex = ref(-1) let pageNo = 1 -let hasMore = true +const hasMore = ref(true) let pendingReset = false const pageSize = 10 +let hasUnfilteredResults = false const loadGroups = async (reset = false) => { if (loading.value) { @@ -72,11 +93,12 @@ const loadGroups = async (reset = false) => { if (reset) { pageNo = 1 - hasMore = true + hasMore.value = true projectGroups.value = [] activeIndex.value = -1 + totalCount.value = null } - if (!hasMore) return + if (!hasMore.value) return loading.value = true try { @@ -85,12 +107,16 @@ const loadGroups = async (reset = false) => { if (query === selectedGroupName.value) { query = '' } + if (reset) { + hasUnfilteredResults = query === '' + } - const newGroups = await tdeiUserClient.getMyProjectGroups(pageNo, query, pageSize) + const { items: newGroups, total } = await tdeiUserClient.getMyProjectGroups(pageNo, query, pageSize) + if (total !== null) totalCount.value = total projectGroups.value.push(...newGroups) if (newGroups.length < pageSize) { - hasMore = false + hasMore.value = false } else { pageNo++ } @@ -108,14 +134,25 @@ const loadGroups = async (reset = false) => { } let timeoutId: ReturnType -watch(searchText, (newVal, oldVal) => { - if (!isOpen.value) return +const onInputClick = () => { + if (!isOpen.value) { + isOpen.value = true + if (!hasUnfilteredResults || projectGroups.value.length === 0) { + loadGroups(true) + } + } +} + +const onInput = () => { + if (!isOpen.value) { + isOpen.value = true + } clearTimeout(timeoutId) timeoutId = setTimeout(() => { loadGroups(true) }, 300) -}) +} watch(model, (newId) => { const pg = projectGroups.value.find(p => p.id === newId) @@ -127,7 +164,7 @@ watch(model, (newId) => { const onScroll = (e: Event) => { const target = e.target as HTMLElement - if (target.scrollTop + target.clientHeight >= target.scrollHeight - 10) { + if (target.scrollTop + target.clientHeight >= target.scrollHeight * 0.8) { loadGroups() } } @@ -144,7 +181,9 @@ const selectGroup = (id: string) => { const onFocus = (e: Event) => { isOpen.value = true - loadGroups(true) + if (!hasUnfilteredResults || projectGroups.value.length === 0) { + loadGroups(true) + } const target = e.target as HTMLInputElement if (target) { target.select() @@ -229,14 +268,11 @@ onMounted(async () => { await loadGroups(true) if (projectGroups.value.length > 0) { - if (!model.value) { - model.value = projectGroups.value[0]?.id - } const selected = projectGroups.value.find(pg => pg.id === model.value) - if (selected) { - searchText.value = selected.name - selectedGroupName.value = selected.name - } + const effective = selected ?? projectGroups.value[0]! + model.value = effective.id + searchText.value = effective.name + selectedGroupName.value = effective.name } }) @@ -249,8 +285,48 @@ onUnmounted(() => { .cursor-pointer { cursor: pointer; } +.pg-dropdown { + background: #fff; + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 0.375rem; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); + overflow: hidden; + z-index: 1000; +} +.pg-header { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 12px; + border-bottom: 1px solid #e9ecef; + background: #f8f9fa; + min-height: 30px; +} +.pg-count { + font-size: 0.74rem; + color: #6c757d; + flex: 1; +} +.pg-scroll-hint { + color: #0d6efd; +} +.pg-list-wrap { + position: relative; + max-height: 220px; + overflow-y: auto; +} +.pg-list-wrap.pg-has-more::after { + content: ''; + display: block; + position: sticky; + bottom: 0; + height: 44px; + margin-top: -44px; + background: linear-gradient(to bottom, transparent, rgba(255, 255, 255, 0.95)); + pointer-events: none; +} .list-group-item.highlighted { - background-color: rgba(13, 110, 253, 0.25); + background-color: rgba(13, 110, 253, 0.15); color: inherit; } diff --git a/pages/dashboard.vue b/pages/dashboard.vue index f4cc716..123ce2e 100644 --- a/pages/dashboard.vue +++ b/pages/dashboard.vue @@ -44,8 +44,22 @@ @@ -71,7 +78,8 @@ const route = useRoute(); const workspaces = (await workspacesClient.getMyWorkspaces()).sort(compareWorkspaceCreatedAtDesc); const workspacesByProjectGroup = Map.groupBy(workspaces, w => w.tdeiProjectGroupId); -const currentProjectGroup = ref(null); +const currentProjectGroup = ref(getLastProjectGroupId()); +const currentProjectGroupName = ref(getLastProjectGroupName()); const currentWorkspace = ref({}); const currentWorkspaces = computed(() => workspacesByProjectGroup.get(currentProjectGroup.value)); @@ -84,6 +92,7 @@ for (const w of workspaces) { onMounted(() => { watch(currentWorkspace, (val) => { if (val?.id) setLastWorkspaceId(val.id) }); watch(currentProjectGroup, (val) => { if (val) setLastProjectGroupId(val) }); + watch(currentProjectGroupName, (val) => { if (val) setLastProjectGroupName(val) }); watch(currentWorkspaces, onCurrentWorkspacesChange); autoSelectPreferredView(); @@ -113,10 +122,7 @@ function autoSelectPreferredView() { } } - const lastProjectGroupId = getLastProjectGroupId(); - if (lastProjectGroupId) { - currentProjectGroup.value = lastProjectGroupId; - } + // currentProjectGroup is already initialized from localStorage synchronously } async function onCurrentWorkspacesChange(val) { From 2440434a0d5c8f59ea14bc24d68aad44d0fa674d Mon Sep 17 00:00:00 2001 From: shweta2101 Date: Fri, 8 May 2026 15:42:46 +0530 Subject: [PATCH 03/11] fix: update nameModel in watch(model) and fix stale comment --- components/ProjectGroupPicker.vue | 1 + pages/dashboard.vue | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/components/ProjectGroupPicker.vue b/components/ProjectGroupPicker.vue index 145dc4a..341f0a2 100644 --- a/components/ProjectGroupPicker.vue +++ b/components/ProjectGroupPicker.vue @@ -160,6 +160,7 @@ watch(model, (newId) => { if (pg && !isOpen.value) { searchText.value = pg.name selectedGroupName.value = pg.name + nameModel.value = pg.name } }) diff --git a/pages/dashboard.vue b/pages/dashboard.vue index 83b2d39..e276ad0 100644 --- a/pages/dashboard.vue +++ b/pages/dashboard.vue @@ -122,7 +122,7 @@ function autoSelectPreferredView() { } } - // currentProjectGroup is already initialized from localStorage synchronously + // currentProjectGroup is already initialized from sessionStorage synchronously } async function onCurrentWorkspacesChange(val) { From f97df64bcadfdb385a654d2c989b85ff740fce7e Mon Sep 17 00:00:00 2001 From: shweta2101 Date: Fri, 8 May 2026 20:19:59 +0530 Subject: [PATCH 04/11] Simplify close handlers using nullish coalescing Replace duplicated conditional logic in onKeydown and handleClickOutside with optional chaining and nullish coalescing. Compute name = pg?.name ?? selectedGroupName.value, assign it to searchText, and only update selectedGroupName when a matching project group exists. This makes the code more concise and preserves the previously selected group name instead of clearing searchText when no matching group is found. --- components/ProjectGroupPicker.vue | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/components/ProjectGroupPicker.vue b/components/ProjectGroupPicker.vue index 341f0a2..b4b50db 100644 --- a/components/ProjectGroupPicker.vue +++ b/components/ProjectGroupPicker.vue @@ -242,12 +242,9 @@ const onKeydown = (e: KeyboardEvent) => { e.preventDefault() isOpen.value = false const pg = projectGroups.value.find(p => p.id === model.value) - if (pg) { - searchText.value = pg.name - selectedGroupName.value = pg.name - } else { - searchText.value = '' - } + const name = pg?.name ?? selectedGroupName.value + searchText.value = name + if (pg) selectedGroupName.value = name } } @@ -256,12 +253,9 @@ const handleClickOutside = (event: MouseEvent) => { if (isOpen.value) { isOpen.value = false const pg = projectGroups.value.find(p => p.id === model.value) - if (pg) { - searchText.value = pg.name - selectedGroupName.value = pg.name - } else { - searchText.value = '' - } + const name = pg?.name ?? selectedGroupName.value + searchText.value = name + if (pg) selectedGroupName.value = name } } } From d41d407375336ef47de7d1643a759a5d2528b3b6 Mon Sep 17 00:00:00 2001 From: shweta2101 Date: Mon, 11 May 2026 13:46:13 +0530 Subject: [PATCH 05/11] Clear timeout; fix storage and header parsing Clear the pending timeout on ProjectGroupPicker unmount to avoid leaked timers. Switch saved project/group keys removal from localStorage to sessionStorage and also remove the project-group-name key to fully clear auth selection. Harden parsing of the X-Total-Count response header: treat invalid/NaN parses as null to avoid returning an incorrect numeric value. --- components/ProjectGroupPicker.vue | 1 + services/tdei.ts | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/components/ProjectGroupPicker.vue b/components/ProjectGroupPicker.vue index b4b50db..b25407b 100644 --- a/components/ProjectGroupPicker.vue +++ b/components/ProjectGroupPicker.vue @@ -285,6 +285,7 @@ onMounted(async () => { onUnmounted(() => { document.removeEventListener('mousedown', handleClickOutside) + clearTimeout(timeoutId) }) diff --git a/services/tdei.ts b/services/tdei.ts index a2629c6..c5f0ed5 100644 --- a/services/tdei.ts +++ b/services/tdei.ts @@ -123,8 +123,9 @@ export class TdeiAuthStore { this.refreshExpiresAt = new Date(0); localStorage.removeItem(this._storageKey); - localStorage.removeItem('tdei-selected-project-group'); - localStorage.removeItem('tdei-selected-workspace'); + sessionStorage.removeItem('tdei-selected-project-group'); + sessionStorage.removeItem('tdei-selected-project-group-name'); + sessionStorage.removeItem('tdei-selected-workspace'); } } @@ -469,7 +470,8 @@ export class TdeiUserClient extends BaseHttpClient implements ICancelableClient try { const totalHeader = response.headers.get('X-Total-Count'); - const total = totalHeader !== null ? parseInt(totalHeader, 10) : null; + const totalParsed = totalHeader !== null ? parseInt(totalHeader, 10) : NaN; + const total = Number.isNaN(totalParsed) ? null : totalParsed; const items = (await response.json() as TdeiProjectGroupApiResponse[]) ?? []; return { items: items.map(p => ({ id: p.tdei_project_group_id, name: p.project_group_name })), total }; } catch (e) { From 1d09094bad7121dcf8efe38c3539b164c451f7b4 Mon Sep 17 00:00:00 2001 From: shweta2101 Date: Mon, 11 May 2026 15:38:08 +0530 Subject: [PATCH 06/11] Show cached group name; safe sessionStorage access Add applyCachedName() to ProjectGroupPicker and use it on mount/selection to display a cached project group name immediately while the groups API call completes, removing duplicated assignments. In pages/dashboard, wrap sessionStorage access with typeof window checks and try/catch blocks so reads/writes are no-ops in SSR or when storage access throws (silently fail). This prevents runtime errors in server-side rendering or environments with disabled/blocked storage. --- components/ProjectGroupPicker.vue | 14 ++++++++++-- pages/dashboard.vue | 38 +++++++++++++++++++++++++------ 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/components/ProjectGroupPicker.vue b/components/ProjectGroupPicker.vue index b25407b..326e023 100644 --- a/components/ProjectGroupPicker.vue +++ b/components/ProjectGroupPicker.vue @@ -248,6 +248,11 @@ const onKeydown = (e: KeyboardEvent) => { } } +const applyCachedName = () => { + searchText.value = nameModel.value + selectedGroupName.value = nameModel.value +} + const handleClickOutside = (event: MouseEvent) => { if (pickerRef.value && !pickerRef.value.contains(event.target as Node)) { if (isOpen.value) { @@ -262,6 +267,12 @@ const handleClickOutside = (event: MouseEvent) => { onMounted(async () => { document.addEventListener('mousedown', handleClickOutside) + + // Show cached name immediately before the API call completes + if (model.value && nameModel.value) { + applyCachedName() + } + await loadGroups(true) if (projectGroups.value.length > 0) { @@ -271,8 +282,7 @@ onMounted(async () => { selectedGroupName.value = selected.name } else if (model.value && nameModel.value) { // Group is beyond page 1 — use the cached name for display - searchText.value = nameModel.value - selectedGroupName.value = nameModel.value + applyCachedName() } else if (!model.value) { const first = projectGroups.value[0]! model.value = first.id diff --git a/pages/dashboard.vue b/pages/dashboard.vue index e276ad0..f88adb7 100644 --- a/pages/dashboard.vue +++ b/pages/dashboard.vue @@ -49,23 +49,47 @@ const STORAGE_KEY_PROJECT_GROUP_NAME = 'tdei-selected-project-group-name'; const STORAGE_KEY_WORKSPACE = 'tdei-selected-workspace'; function getLastProjectGroupId(): string | null { - return sessionStorage.getItem(STORAGE_KEY_PROJECT_GROUP); + if (typeof window === 'undefined') return null; + try { + return sessionStorage.getItem(STORAGE_KEY_PROJECT_GROUP); + } catch { + return null; + } } function setLastProjectGroupId(id: string) { - sessionStorage.setItem(STORAGE_KEY_PROJECT_GROUP, id); + if (typeof window === 'undefined') return; + try { + sessionStorage.setItem(STORAGE_KEY_PROJECT_GROUP, id); + } catch { /* silently fail */ } } function getLastProjectGroupName(): string | null { - return sessionStorage.getItem(STORAGE_KEY_PROJECT_GROUP_NAME); + if (typeof window === 'undefined') return null; + try { + return sessionStorage.getItem(STORAGE_KEY_PROJECT_GROUP_NAME); + } catch { + return null; + } } function setLastProjectGroupName(name: string) { - sessionStorage.setItem(STORAGE_KEY_PROJECT_GROUP_NAME, name); + if (typeof window === 'undefined') return; + try { + sessionStorage.setItem(STORAGE_KEY_PROJECT_GROUP_NAME, name); + } catch { /* silently fail */ } } function getLastWorkspaceId(): number | null { - const v = sessionStorage.getItem(STORAGE_KEY_WORKSPACE); - return v ? Number(v) : null; + if (typeof window === 'undefined') return null; + try { + const v = sessionStorage.getItem(STORAGE_KEY_WORKSPACE); + return v ? Number(v) : null; + } catch { + return null; + } } function setLastWorkspaceId(id: number) { - sessionStorage.setItem(STORAGE_KEY_WORKSPACE, String(id)); + if (typeof window === 'undefined') return; + try { + sessionStorage.setItem(STORAGE_KEY_WORKSPACE, String(id)); + } catch { /* silently fail */ } } From e6efe84515fecf44fd2a5d8a8920dcab31964758 Mon Sep 17 00:00:00 2001 From: shweta2101 Date: Wed, 13 May 2026 19:57:44 +0530 Subject: [PATCH 07/11] Populate missing group name and null-safe IDs ProjectGroupPicker: when a model ID is present but the group name is not cached, paginate through groups until the matching group is found and populate searchText, selectedGroupName, and nameModel so the UI shows the correct name. tdei.vue: make assignment of projectGroupId and tdeiRecordId null-safe by using optional chaining and null coalescing to avoid errors when nested fields are missing. --- components/ProjectGroupPicker.vue | 12 ++++++++++++ pages/workspace/create/tdei.vue | 6 +++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/components/ProjectGroupPicker.vue b/components/ProjectGroupPicker.vue index 326e023..b14daf3 100644 --- a/components/ProjectGroupPicker.vue +++ b/components/ProjectGroupPicker.vue @@ -283,6 +283,18 @@ onMounted(async () => { } else if (model.value && nameModel.value) { // Group is beyond page 1 — use the cached name for display applyCachedName() + } else if (model.value) { + // model is set but name is unknown — paginate until the group is found + while (hasMore.value) { + await loadGroups() + const found = projectGroups.value.find(pg => pg.id === model.value) + if (found) { + searchText.value = found.name + selectedGroupName.value = found.name + nameModel.value = found.name + break + } + } } else if (!model.value) { const first = projectGroups.value[0]! model.value = first.id diff --git a/pages/workspace/create/tdei.vue b/pages/workspace/create/tdei.vue index acf0428..6557fbd 100644 --- a/pages/workspace/create/tdei.vue +++ b/pages/workspace/create/tdei.vue @@ -180,9 +180,9 @@ async function getDatasetInfo(id: string | null) { await nextTick(); - workspaceTitle.value = record.metadata?.dataset_detail?.name ?? ''; - projectGroupId.value = record.project_group.tdei_project_group_id; - tdeiRecordId.value = record.tdei_dataset_id; + workspaceTitle.value = record.metadata?.dataset_detail?.name ?? '' + projectGroupId.value = record.project_group?.tdei_project_group_id ?? null + tdeiRecordId.value = record.tdei_dataset_id ?? null initMap(); } From 3f113c4c195ec3fd6909c87c511c7676d9c18ba3 Mon Sep 17 00:00:00 2001 From: shweta2101 Date: Thu, 14 May 2026 12:01:24 +0530 Subject: [PATCH 08/11] Cache project group name; use undefined total Add sessionStorage caching for selected project group (stores {id,name}) and wire ProjectGroupPicker to read/write the cached name so the UI can show a name immediately even when the group isn't on page 1. Remove the separate nameModel/v-model:name usage in dashboard and consolidate storage to the single JSON key. Change getMyProjectGroups to use an optional total (undefined instead of null) and update callers accordingly. Other updates: persist cached name on selection, simplify input open logic, switch ProjectGroupPicker styles to SCSS and theme variables, clear the removed storage key from auth sign-out, and add dataset validation/error display in workspace/create/tdei.vue when dataset metadata is incomplete. --- components/ProjectGroupPicker.vue | 64 +++++++++++++++++++++---------- pages/dashboard.vue | 55 ++++++++------------------ pages/workspace/create/tdei.vue | 28 ++++++++++---- services/tdei.ts | 7 ++-- 4 files changed, 82 insertions(+), 72 deletions(-) diff --git a/components/ProjectGroupPicker.vue b/components/ProjectGroupPicker.vue index b14daf3..c49f8be 100644 --- a/components/ProjectGroupPicker.vue +++ b/components/ProjectGroupPicker.vue @@ -18,7 +18,7 @@ >
- + + -