Skip to content

Commit 079b2ce

Browse files
authored
Feature 3623 project group field issues (#50)
## PR: Project Group Field Issues Task/Bugs Covered: https://dev.azure.com/TDEI-UW/TDEI/_workitems/edit/3635 https://dev.azure.com/TDEI-UW/TDEI/_workitems/edit/3624/ https://dev.azure.com/TDEI-UW/TDEI/_workitems/edit/3623/ --- ## Changes Implemented ### `components/ProjectGroupPicker.vue` - Click-outside handler closes the dropdown without changing selection - Displays total count and "scroll to load more" hint in the dropdown header - Loading spinner shown in the header during API requests - Highlighted item tracks keyboard/mouse hover consistently - Cached project group name is shown immediately on mount before the first API response, eliminating blank-field flash on reload ### `pages/dashboard.vue` - Replaces module-level mutable variables with `sessionStorage`-backed helpers using named constants for all storage keys (`tdei-selected-project-group`, `tdei-selected-project-group-name`, `tdei-selected-workspace`) - All `sessionStorage` operations are wrapped in `try/catch` with SSR guards (`typeof window === 'undefined'`) to handle private browsing and restricted browser policies - Project group and workspace selection are restored synchronously on load; the `lastProjectGroupId` in-memory fallback is removed ### `services/tdei.ts` - `TdeiAuthStore.clear()` now removes all three `sessionStorage` picker keys on logout --- ## Testing Checklist | Scenario | Expected | |---|---| | Open dashboard — project group picker renders | Picker shows, no blank flash if a group was previously selected | | Click the picker input | Dropdown opens, first 10 groups load | | Type in the search box | Results filter after ~300ms debounce | | Scroll to bottom of list | Next 10 results load automatically | | `↑`/`↓` arrow keys | Highlighted item moves through the list | | `Enter` on highlighted item | Item selected, dropdown closes, name shown in input | | Click an item | Same as Enter | | `Escape` key | Dropdown closes, previously selected name restored | | Click outside the dropdown | Dropdown closes, selection unchanged | | Select a group — reload the page | Same group pre-selected, name shown instantly with no flicker | | Select a workspace — reload the page | Same workspace re-selected | | Open two tabs, select different groups | Each tab retains its own selection independently (sessionStorage is tab-scoped) | | Log out | `sessionStorage` picker keys cleared; fresh session on next login | ### Screenshots <img width="1470" height="783" alt="Screenshot 2026-05-11 at 3 50 54 PM" src="https://github.com/user-attachments/assets/f7b7c34d-7e68-4232-9d6c-1d04b3993915" /> <img width="1466" height="827" alt="Screenshot 2026-05-11 at 3 50 16 PM" src="https://github.com/user-attachments/assets/58ac355b-4173-48c8-aa84-bfc5011db6d8" /> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Changes Overview This PR addresses issues with the Project Group picker and session persistence across four files: ### components/ProjectGroupPicker.vue Refactored dropdown UI and state management: - Added named v-model binding for cached display name: `defineModel('name', { default: '' })` - Input now explicitly opens dropdown on click; debounces data reloads instead of using `watch(searchText)` - Restructured dropdown header to show "showing first N of total" messaging based on API response - Replaced inline loading row with header spinner during API requests - Infinite scroll handled via `@scroll` listener on dedicated list wrapper with revised threshold - Updated focus, scroll, Escape, and click-outside behaviors to restore visible search text using cached name - On mount, cached name is applied immediately before initial API load; pagination reconciles if selected group isn't on first page - Lines changed: +157/-51 ### pages/dashboard.vue Added sessionStorage persistence for selections: - Project group picker now uses `v-model:name` binding in addition to id binding - New storage helper functions safely read/write selected project group id, name, and workspace id with sessionStorage keys: `tdei-selected-project-group`, `tdei-selected-project-group-name`, `tdei-selected-workspace` - Storage operations wrapped in try/catch with SSR guards (`typeof window === 'undefined'`) - `currentProjectGroup` and `currentProjectGroupName` initialized synchronously at setup time from sessionStorage - Auto-selection logic reads `lastWorkspaceId` from sessionStorage instead of in-memory variables - Updated watchers to persist all three selection values - Adjusted `.project-group-picker` styling with minimum width - Lines changed: +56/-9 ### services/tdei.ts Updated API client for project group functionality: - `TdeiAuthStore.clear()` now removes all three sessionStorage picker keys on logout (in addition to existing localStorage auth key removal) - `TdeiUserClient.getMyProjectGroups()` signature updated to accept `sortBy` parameter with default value `'name'` - Return type changed from `Promise<TdeiProjectGroup[]>` to `Promise<{ items: TdeiProjectGroup[]; total: number | null }>` - Method now reads `X-Total-Count` response header and returns total count; returns `{ items: [], total: null }` on parsing errors - Lines changed: +10/-4 ### pages/workspace/create/tdei.vue Defensive property assignments: - Post-`nextTick()` assignments now use optional chaining and nullish coalescing - `workspaceTitle` falls back to `''` - `projectGroupId` falls back to `null` when `record.project_group` is missing - `tdeiRecordId` falls back to `null` when `record.tdei_dataset_id` is missing - Lines changed: +3/-3 <!-- review_stack_entry_start --> [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/TaskarCenterAtUW/workspaces-frontend/pull/50) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
2 parents 74857e4 + dca4efe commit 079b2ce

4 files changed

Lines changed: 259 additions & 95 deletions

File tree

components/ProjectGroupPicker.vue

Lines changed: 186 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,88 @@
11
<template>
2-
<div class="position-relative project-group-picker" ref="pickerRef">
2+
<div class="position-relative project-group-picker" ref="pickerRef" @focusout="onFocusOut">
33
<input
44
v-model="searchText"
55
type="text"
66
class="form-select"
77
:disabled="props.disabled"
88
placeholder="Search project groups..."
99
@focus="onFocus"
10+
@click="onInputClick"
11+
@input="onInput"
1012
@keydown="onKeydown"
1113
/>
12-
<ul
14+
<div
1315
v-if="isOpen"
14-
ref="listRef"
15-
class="list-group position-absolute w-100 mt-1 shadow bg-white"
16-
style="z-index: 1000; max-height: 250px; overflow-y: auto;"
17-
@scroll="onScroll"
16+
class="pg-dropdown position-absolute w-100 mt-1"
1817
@mousedown.prevent
1918
>
20-
<li
21-
v-if="projectGroups.length === 0 && !loading"
22-
class="list-group-item text-muted"
19+
<div class="pg-header">
20+
<span v-if="projectGroups.length > 0" class="pg-count">
21+
<template v-if="totalCount !== undefined">
22+
Showing first {{ projectGroups.length }} of {{ totalCount }} project groups
23+
<span v-if="hasMore && !loading" class="pg-scroll-hint">&#183; Scroll to continue loading</span>
24+
</template>
25+
<template v-else-if="!hasMore">Showing all {{ projectGroups.length }} project group{{ projectGroups.length !== 1 ? 's' : '' }}</template>
26+
<template v-else>
27+
Showing first {{ projectGroups.length }} results
28+
<span v-if="!loading" class="pg-scroll-hint">&#183; Scroll to continue loading</span>
29+
</template>
30+
</span>
31+
<span v-if="loading" class="spinner-border spinner-border-sm ms-auto" role="status" aria-hidden="true"></span>
32+
</div>
33+
<div
34+
class="pg-list-wrap"
35+
:class="{ 'pg-has-more': hasMore && !loading }"
36+
ref="listRef"
37+
@scroll="onScroll"
2338
>
24-
No project groups found.
25-
</li>
26-
<li
27-
v-for="(pg, index) in projectGroups"
28-
:key="pg.id"
29-
:id="'pg-item-' + index"
30-
class="list-group-item list-group-item-action cursor-pointer"
31-
:class="{ highlighted: activeIndex === index, 'fw-bold': model === pg.id }"
32-
@click="selectGroup(pg.id)"
33-
@mouseenter="activeIndex = index"
34-
>
35-
{{ pg.name }}
36-
</li>
37-
<li v-if="loading" class="list-group-item text-center">
38-
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
39-
</li>
40-
</ul>
39+
<ul class="list-group list-group-flush">
40+
<li
41+
v-if="projectGroups.length === 0 && !loading"
42+
class="list-group-item text-muted"
43+
>
44+
No project groups found.
45+
</li>
46+
<li
47+
v-for="(pg, index) in projectGroups"
48+
:key="pg.id"
49+
:id="'pg-item-' + index"
50+
class="list-group-item list-group-item-action cursor-pointer"
51+
:class="{ highlighted: activeIndex === index, 'fw-bold': model === pg.id }"
52+
@click="selectGroup(pg.id)"
53+
@mouseenter="activeIndex = index"
54+
>
55+
{{ pg.name }}
56+
</li>
57+
</ul>
58+
</div>
59+
</div>
4160
</div>
4261
</template>
4362

63+
<script lang="ts">
64+
const STORAGE_KEY_PROJECT_GROUP = 'tdei-selected-project-group'
65+
66+
function loadCachedName(id: string): string | undefined {
67+
if (typeof window === 'undefined') return undefined
68+
try {
69+
const raw = sessionStorage.getItem(STORAGE_KEY_PROJECT_GROUP)
70+
if (!raw) return undefined
71+
const stored = JSON.parse(raw) as { id: string; name: string }
72+
return stored.id === id ? stored.name : undefined
73+
} catch {
74+
return undefined
75+
}
76+
}
77+
78+
function persistCachedName(id: string, name: string) {
79+
if (typeof window === 'undefined') return
80+
try {
81+
sessionStorage.setItem(STORAGE_KEY_PROJECT_GROUP, JSON.stringify({ id, name }))
82+
} catch { /* silently fail */ }
83+
}
84+
</script>
85+
4486
<script setup lang="ts">
4587
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
4688
import { tdeiUserClient } from '~/services/index'
@@ -55,14 +97,16 @@ const isOpen = ref(false)
5597
const projectGroups = ref<{ id: string; name: string }[]>([])
5698
const selectedGroupName = ref('')
5799
const loading = ref(false)
100+
const totalCount = ref<number | undefined>(undefined)
58101
const pickerRef = ref<HTMLElement | null>(null)
59102
const listRef = ref<HTMLElement | null>(null)
60103
const activeIndex = ref(-1)
61104
62105
let pageNo = 1
63-
let hasMore = true
106+
const hasMore = ref(true)
64107
let pendingReset = false
65108
const pageSize = 10
109+
let hasUnfilteredResults = false
66110
67111
const loadGroups = async (reset = false) => {
68112
if (loading.value) {
@@ -72,11 +116,12 @@ const loadGroups = async (reset = false) => {
72116
73117
if (reset) {
74118
pageNo = 1
75-
hasMore = true
119+
hasMore.value = true
76120
projectGroups.value = []
77121
activeIndex.value = -1
122+
totalCount.value = undefined
78123
}
79-
if (!hasMore) return
124+
if (!hasMore.value) return
80125
81126
loading.value = true
82127
try {
@@ -85,12 +130,18 @@ const loadGroups = async (reset = false) => {
85130
if (query === selectedGroupName.value) {
86131
query = ''
87132
}
133+
if (reset) {
134+
hasUnfilteredResults = query === ''
135+
}
88136
89-
const newGroups = await tdeiUserClient.getMyProjectGroups(pageNo, query, pageSize)
137+
const { items: newGroups, total } = await tdeiUserClient.getMyProjectGroups(pageNo, query, pageSize)
138+
if (total !== undefined) totalCount.value = total
90139
projectGroups.value.push(...newGroups)
140+
const selected = newGroups.find(g => g.id === model.value)
141+
if (selected) persistCachedName(selected.id, selected.name)
91142
92143
if (newGroups.length < pageSize) {
93-
hasMore = false
144+
hasMore.value = false
94145
} else {
95146
pageNo++
96147
}
@@ -108,14 +159,23 @@ const loadGroups = async (reset = false) => {
108159
}
109160
110161
let timeoutId: ReturnType<typeof setTimeout>
111-
watch(searchText, (newVal, oldVal) => {
112-
if (!isOpen.value) return
113162
163+
const onInputClick = () => {
164+
if (!isOpen.value) {
165+
isOpen.value = true
166+
if (!hasUnfilteredResults || projectGroups.value.length === 0) {
167+
loadGroups(true)
168+
}
169+
}
170+
}
171+
172+
const onInput = () => {
173+
isOpen.value = true
114174
clearTimeout(timeoutId)
115175
timeoutId = setTimeout(() => {
116176
loadGroups(true)
117177
}, 300)
118-
})
178+
}
119179
120180
watch(model, (newId) => {
121181
const pg = projectGroups.value.find(p => p.id === newId)
@@ -127,7 +187,7 @@ watch(model, (newId) => {
127187
128188
const onScroll = (e: Event) => {
129189
const target = e.target as HTMLElement
130-
if (target.scrollTop + target.clientHeight >= target.scrollHeight - 10) {
190+
if (target.scrollTop + target.clientHeight >= target.scrollHeight * 0.8) {
131191
loadGroups()
132192
}
133193
}
@@ -139,12 +199,15 @@ const selectGroup = (id: string) => {
139199
if (pg) {
140200
searchText.value = pg.name
141201
selectedGroupName.value = pg.name
202+
persistCachedName(pg.id, pg.name)
142203
}
143204
}
144205
145206
const onFocus = (e: Event) => {
146207
isOpen.value = true
147-
loadGroups(true)
208+
if (!hasUnfilteredResults || projectGroups.value.length === 0) {
209+
loadGroups(true)
210+
}
148211
const target = e.target as HTMLInputElement
149212
if (target) {
150213
target.select()
@@ -198,59 +261,119 @@ const onKeydown = (e: KeyboardEvent) => {
198261
}
199262
} else if (e.key === 'Escape') {
200263
e.preventDefault()
201-
isOpen.value = false
202-
const pg = projectGroups.value.find(p => p.id === model.value)
203-
if (pg) {
204-
searchText.value = pg.name
205-
selectedGroupName.value = pg.name
206-
} else {
207-
searchText.value = ''
208-
}
264+
closeDropdown()
209265
}
210266
}
211267
212-
const handleClickOutside = (event: MouseEvent) => {
213-
if (pickerRef.value && !pickerRef.value.contains(event.target as Node)) {
214-
if (isOpen.value) {
215-
isOpen.value = false
216-
const pg = projectGroups.value.find(p => p.id === model.value)
217-
if (pg) {
218-
searchText.value = pg.name
219-
selectedGroupName.value = pg.name
220-
} else {
221-
searchText.value = ''
222-
}
223-
}
268+
const applyCachedName = () => {
269+
const cached = loadCachedName(model.value as string) ?? ''
270+
searchText.value = cached
271+
selectedGroupName.value = cached
272+
}
273+
274+
const closeDropdown = () => {
275+
isOpen.value = false
276+
const pg = projectGroups.value.find(p => p.id === model.value)
277+
const name = pg?.name ?? selectedGroupName.value
278+
searchText.value = name
279+
if (pg) selectedGroupName.value = name
280+
}
281+
282+
const onFocusOut = (e: FocusEvent) => {
283+
if (!pickerRef.value?.contains(e.relatedTarget as Node)) {
284+
if (isOpen.value) closeDropdown()
224285
}
225286
}
226287
227288
onMounted(async () => {
228-
document.addEventListener('mousedown', handleClickOutside)
289+
// Show cached name immediately before the API call completes
290+
if (model.value && loadCachedName(model.value as string)) {
291+
applyCachedName()
292+
}
293+
229294
await loadGroups(true)
230295
231296
if (projectGroups.value.length > 0) {
232-
if (!model.value) {
233-
model.value = projectGroups.value[0]?.id
234-
}
235297
const selected = projectGroups.value.find(pg => pg.id === model.value)
236298
if (selected) {
237299
searchText.value = selected.name
238300
selectedGroupName.value = selected.name
301+
} else if (model.value && loadCachedName(model.value as string)) {
302+
// Group is beyond page 1 — use the cached name for display
303+
applyCachedName()
304+
} else if (model.value) {
305+
// model is set but name is unknown — paginate until the group is found
306+
while (hasMore.value) {
307+
await loadGroups()
308+
const found = projectGroups.value.find(pg => pg.id === model.value)
309+
if (found) {
310+
searchText.value = found.name
311+
selectedGroupName.value = found.name
312+
break
313+
}
314+
}
315+
} else if (!model.value) {
316+
const first = projectGroups.value[0]!
317+
model.value = first.id
318+
searchText.value = first.name
319+
selectedGroupName.value = first.name
239320
}
240321
}
241322
})
242323
243324
onUnmounted(() => {
244-
document.removeEventListener('mousedown', handleClickOutside)
325+
clearTimeout(timeoutId)
245326
})
246327
</script>
247328

248-
<style scoped>
329+
<style lang="scss" scoped>
330+
@import "assets/scss/theme.scss";
331+
249332
.cursor-pointer {
250333
cursor: pointer;
251334
}
335+
.pg-dropdown {
336+
background: #fff;
337+
border: 1px solid rgba(0, 0, 0, 0.15);
338+
border-radius: 0.375rem;
339+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
340+
overflow: hidden;
341+
z-index: 1000;
342+
}
343+
.pg-header {
344+
display: flex;
345+
align-items: center;
346+
gap: 8px;
347+
padding: 5px 12px;
348+
border-bottom: 1px solid $gray-200;
349+
background: $gray-100;
350+
min-height: 30px;
351+
}
352+
.pg-count {
353+
font-size: 0.74rem;
354+
color: $gray-700;
355+
flex: 1;
356+
}
357+
.pg-scroll-hint {
358+
color: $primary;
359+
}
360+
.pg-list-wrap {
361+
position: relative;
362+
max-height: 220px;
363+
overflow-y: auto;
364+
}
365+
.pg-list-wrap.pg-has-more::after {
366+
content: '';
367+
display: block;
368+
position: sticky;
369+
bottom: 0;
370+
height: 44px;
371+
margin-top: -44px;
372+
background: linear-gradient(to bottom, transparent, rgba(255, 255, 255, 0.95));
373+
pointer-events: none;
374+
}
252375
.list-group-item.highlighted {
253-
background-color: rgba(13, 110, 253, 0.25);
376+
background-color: rgba(13, 110, 253, 0.15);
254377
color: inherit;
255378
}
256379
</style>

0 commit comments

Comments
 (0)