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
261 changes: 244 additions & 17 deletions components/ProjectGroupPicker.vue
Original file line number Diff line number Diff line change
@@ -1,29 +1,256 @@
<template>
<select
v-model="model"
class="project-group-picker form-select"
aria-label="Project Group Selection"
>
<option
v-for="pg in projectGroups"
:key="pg.id"
:value="pg.id"
<div class="position-relative project-group-picker" ref="pickerRef">
<input
v-model="searchText"
type="text"
class="form-select"
:disabled="props.disabled"
placeholder="Search project groups..."
@focus="onFocus"
@keydown="onKeydown"
/>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<ul
v-if="isOpen"
ref="listRef"
class="list-group position-absolute w-100 mt-1 shadow bg-white"
style="z-index: 1000; max-height: 250px; overflow-y: auto;"
@scroll="onScroll"
@mousedown.prevent
>
{{ pg.name }}
</option>
</select>
<li
v-if="projectGroups.length === 0 && !loading"
class="list-group-item text-muted"
>
No project groups found.
</li>
<li
v-for="(pg, index) in projectGroups"
:key="pg.id"
:id="'pg-item-' + index"
class="list-group-item list-group-item-action cursor-pointer"
:class="{ highlighted: activeIndex === index, 'fw-bold': model === pg.id }"
@click="selectGroup(pg.id)"
@mouseenter="activeIndex = index"
>
{{ pg.name }}
</li>
<li v-if="loading" class="list-group-item text-center">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
</li>
</ul>
</div>
</template>

<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { tdeiUserClient } from '~/services/index'

const props = withDefaults(defineProps<{ disabled?: boolean }>(), {
disabled: false,
})

const model = defineModel({ required: true })
const projectGroups = (await tdeiUserClient.getMyProjectGroups())
.sort((a, b) => a.name.localeCompare(b.name))
const searchText = ref('')
const isOpen = ref(false)
const projectGroups = ref<{ id: string; name: string }[]>([])
const selectedGroupName = ref('')
const loading = ref(false)
const pickerRef = ref<HTMLElement | null>(null)
const listRef = ref<HTMLElement | null>(null)
const activeIndex = ref(-1)

let pageNo = 1
let hasMore = true
let pendingReset = false
const pageSize = 10

const loadGroups = async (reset = false) => {
if (loading.value) {
pendingReset = pendingReset || reset
return
}

if (reset) {
pageNo = 1
hasMore = true
projectGroups.value = []
activeIndex.value = -1
}
if (!hasMore) return

Comment thread
shweta2101 marked this conversation as resolved.
loading.value = true
try {
let query = searchText.value
// If the text is exactly the selected group's name, fetch all options instead of filtering
if (query === selectedGroupName.value) {
query = ''
}

const newGroups = await tdeiUserClient.getMyProjectGroups(pageNo, query, pageSize)
projectGroups.value.push(...newGroups)

if (projectGroups.length > 0) {
if (!model.value || !projectGroups.some(pg => pg.id === model.value)) {
model.value = projectGroups[0].id
if (newGroups.length < pageSize) {
hasMore = false
} else {
pageNo++
}
} catch (e) {
console.error(e)
} finally {
loading.value = false

if (pendingReset) {
const resetNext = pendingReset
pendingReset = false
await loadGroups(resetNext)
}
}
}

let timeoutId: ReturnType<typeof setTimeout>
watch(searchText, (newVal, oldVal) => {
if (!isOpen.value) return

clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
loadGroups(true)
}, 300)
})

watch(model, (newId) => {
const pg = projectGroups.value.find(p => p.id === newId)
if (pg && !isOpen.value) {
searchText.value = pg.name
selectedGroupName.value = pg.name
}
})

const onScroll = (e: Event) => {
const target = e.target as HTMLElement
if (target.scrollTop + target.clientHeight >= target.scrollHeight - 10) {
loadGroups()
}
}

const selectGroup = (id: string) => {
model.value = id
isOpen.value = false
const pg = projectGroups.value.find(p => p.id === id)
if (pg) {
searchText.value = pg.name
selectedGroupName.value = pg.name
}
}

const onFocus = (e: Event) => {
isOpen.value = true
loadGroups(true)
const target = e.target as HTMLInputElement
if (target) {
target.select()
}
}

const scrollToActive = () => {
nextTick(() => {
if (!listRef.value || activeIndex.value < 0) return
const activeEl = listRef.value.querySelector(`#pg-item-${activeIndex.value}`) as HTMLElement
if (activeEl) {
const list = listRef.value
const top = activeEl.offsetTop
const bottom = top + activeEl.offsetHeight

if (top < list.scrollTop) {
list.scrollTop = top
} else if (bottom > list.scrollTop + list.clientHeight) {
list.scrollTop = bottom - list.clientHeight
}
}
})
}

const onKeydown = (e: KeyboardEvent) => {
if (!isOpen.value) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
onFocus(e)
e.preventDefault()
}
return
}

if (e.key === 'ArrowDown') {
e.preventDefault()
if (activeIndex.value < projectGroups.value.length - 1) {
activeIndex.value++
scrollToActive()
}
} else if (e.key === 'ArrowUp') {
e.preventDefault()
if (activeIndex.value > 0) {
activeIndex.value--
scrollToActive()
}
} else if (e.key === 'Enter') {
e.preventDefault()
if (activeIndex.value >= 0 && activeIndex.value < projectGroups.value.length) {
const pg = projectGroups.value[activeIndex.value]
if (pg) selectGroup(pg.id)
}
} else if (e.key === 'Escape') {
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 handleClickOutside = (event: MouseEvent) => {
if (pickerRef.value && !pickerRef.value.contains(event.target as Node)) {
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 = ''
}
}
}
}

onMounted(async () => {
document.addEventListener('mousedown', handleClickOutside)
await loadGroups(true)

if (projectGroups.value.length > 0) {
if (!model.value) {
model.value = projectGroups.value[0]?.id
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const selected = projectGroups.value.find(pg => pg.id === model.value)
if (selected) {
searchText.value = selected.name
selectedGroupName.value = selected.name
}
}
})

onUnmounted(() => {
document.removeEventListener('mousedown', handleClickOutside)
})
</script>

<style scoped>
.cursor-pointer {
cursor: pointer;
}
.list-group-item.highlighted {
background-color: rgba(13, 110, 253, 0.25);
color: inherit;
}
</style>
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"maplibre-gl": "^5.10.0",
"nuxt": "^4.0.0",
"papaparse": "^5.5.1",
"qrcode": "^1.5.4",
Comment thread
jeffmaki marked this conversation as resolved.
"vue": "^3.4.19",
"vue-qrcode": "^2.2.2",
"vue-router": "^4.2.5",
Expand Down
41 changes: 28 additions & 13 deletions pages/workspace/create/tdei.vue
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@
</template>

<script setup lang="ts">
declare const L: any;
import { LoadingContext } from '~/services/loading'
import { TdeiImporter, TdeiImporterContext } from '~/services/import/tdei'
import { osmClient, tdeiClient, workspacesClient } from '~/services/index'
Expand All @@ -156,11 +157,11 @@ const importer = new TdeiImporter(workspacesClient, tdeiClient, osmClient, conte

const loading = reactive(new LoadingContext())
const route = useRoute()
const tdeiRecordId = ref(null)
const record = reactive({})
const map = ref({})
const tdeiRecordId = ref<string | null>(null)
const record = reactive<Record<string, any>>({})
const map = ref<any>({})
const workspaceTitle = ref('')
const projectGroupId = ref(null)
const projectGroupId = ref<string | null>(null)

watch(tdeiRecordId, val => getDatasetInfo(val))

Expand All @@ -170,7 +171,7 @@ const complete = computed(() =>
&& tdeiRecordId.value !== null,
)

async function getDatasetInfo(id: string) {
async function getDatasetInfo(id: string | null) {
if (id === null) {
for (const prop in record) {
record[prop] = ''
Expand All @@ -183,9 +184,15 @@ async function getDatasetInfo(id: string) {
await loading.wrap(tdeiClient, async (client) => {
const info = await client.getDatasetInfo(id)

for (const prop in info) {
record[prop] = info[prop]
if (!info) return

// Clear stale keys from any previously loaded dataset before merging,
// so switching datasets never leaves orphaned fields in record.
for (const key of Object.keys(record)) {
delete record[key]
}

Object.assign(record, info)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})

await nextTick()
Expand All @@ -202,6 +209,10 @@ onMounted(async () => {
})

function initMap() {
if (map.value && map.value.remove) {
map.value.remove()
}

// TODO: use Mapbox
map.value = L.map('dataset_map')

Expand All @@ -210,19 +221,23 @@ function initMap() {
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(map.value)

const area = L.geoJSON(record.metadata.dataset_detail.dataset_area).addTo(map.value)
const bounds = area.getBounds()
if (record.metadata?.dataset_detail?.dataset_area) {
const area = L.geoJSON(record.metadata.dataset_detail.dataset_area).addTo(map.value)
const bounds = area.getBounds()

map.value.fitBounds(bounds)
if (bounds.isValid()) {
map.value.fitBounds(bounds)
}
}
}

async function create() {
const workspaceId = await importer.import({
title: workspaceTitle.value,
type: record.data_type,
tdeiRecordId: tdeiRecordId.value,
tdeiProjectGroupId: projectGroupId.value,
tdeiServiceId: record.service.tdei_service_id,
tdeiRecordId: tdeiRecordId.value as string,
tdeiProjectGroupId: projectGroupId.value as string,
tdeiServiceId: record.service?.tdei_service_id || '',
tdeiMetadata: JSON.stringify(record),
})

Expand Down
Loading
Loading