Skip to content

Commit 243872a

Browse files
authored
Use infinite scroll select (#11991)
* addresses the domain selection (listed after the page size) with keyword search
1 parent 1300fc5 commit 243872a

File tree

9 files changed

+558
-728
lines changed

9 files changed

+558
-728
lines changed

ui/src/components/view/DedicateDomain.vue

Lines changed: 55 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -18,52 +18,44 @@
1818
<template>
1919
<div class="form">
2020
<div class="form__item" :class="{'error': domainError}">
21-
<a-spin :spinning="domainsLoading">
22-
<p class="form__label">{{ $t('label.domain') }}<span class="required">*</span></p>
23-
<p class="required required-label">{{ $t('label.required') }}</p>
24-
<a-select
25-
style="width: 100%"
26-
showSearch
27-
optionFilterProp="label"
28-
:filterOption="(input, option) => {
29-
return option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0
30-
}"
31-
@change="handleChangeDomain"
32-
v-focus="true"
33-
v-model:value="domainId">
34-
<a-select-option
35-
v-for="(domain, index) in domainsList"
36-
:value="domain.id"
37-
:key="index"
38-
:label="domain.path || domain.name || domain.description">
39-
{{ domain.path || domain.name || domain.description }}
40-
</a-select-option>
41-
</a-select>
42-
</a-spin>
21+
<p class="form__label">{{ $t('label.domain') }}<span class="required">*</span></p>
22+
<p class="required required-label">{{ $t('label.required') }}</p>
23+
<infinite-scroll-select
24+
style="width: 100%"
25+
v-model:value="domainId"
26+
api="listDomains"
27+
:apiParams="domainsApiParams"
28+
resourceType="domain"
29+
optionValueKey="id"
30+
optionLabelKey="path"
31+
defaultIcon="block-outlined"
32+
v-focus="true"
33+
@change-option-value="handleChangeDomain" />
4334
</div>
44-
<div class="form__item" v-if="accountsList">
35+
<div class="form__item">
4536
<p class="form__label">{{ $t('label.account') }}</p>
46-
<a-select
37+
<infinite-scroll-select
4738
style="width: 100%"
48-
@change="handleChangeAccount"
49-
showSearch
50-
optionFilterProp="value"
51-
:filterOption="(input, option) => {
52-
return option.value.toLowerCase().indexOf(input.toLowerCase()) >= 0
53-
}" >
54-
<a-select-option v-for="(account, index) in accountsList" :value="account.name" :key="index">
55-
{{ account.name }}
56-
</a-select-option>
57-
</a-select>
39+
v-model:value="selectedAccount"
40+
api="listAccounts"
41+
:apiParams="accountsApiParams"
42+
resourceType="account"
43+
optionValueKey="name"
44+
optionLabelKey="name"
45+
defaultIcon="team-outlined"
46+
@change-option-value="handleChangeAccount" />
5847
</div>
5948
</div>
6049
</template>
6150

6251
<script>
63-
import { api } from '@/api'
52+
import InfiniteScrollSelect from '@/components/widgets/InfiniteScrollSelect.vue'
6453
6554
export default {
6655
name: 'DedicateDomain',
56+
components: {
57+
InfiniteScrollSelect
58+
},
6759
props: {
6860
error: {
6961
type: Boolean,
@@ -72,59 +64,48 @@ export default {
7264
},
7365
data () {
7466
return {
75-
domainsLoading: false,
7667
domainId: null,
77-
accountsList: null,
78-
domainsList: null,
68+
selectedAccount: null,
7969
domainError: false
8070
}
8171
},
72+
computed: {
73+
domainsApiParams () {
74+
return {
75+
listall: true,
76+
details: 'min'
77+
}
78+
},
79+
accountsApiParams () {
80+
if (!this.domainId) {
81+
return {
82+
listall: true,
83+
showicon: true
84+
}
85+
}
86+
return {
87+
showicon: true,
88+
domainid: this.domainId
89+
}
90+
}
91+
},
8292
watch: {
8393
error () {
8494
this.domainError = this.error
8595
}
8696
},
8797
created () {
88-
this.fetchData()
8998
},
9099
methods: {
91-
fetchData () {
92-
this.domainsLoading = true
93-
api('listDomains', {
94-
listAll: true,
95-
details: 'min'
96-
}).then(response => {
97-
this.domainsList = response.listdomainsresponse.domain
98-
99-
if (this.domainsList[0]) {
100-
this.domainId = this.domainsList[0].id
101-
this.handleChangeDomain(this.domainId)
102-
}
103-
}).catch(error => {
104-
this.$notifyError(error)
105-
}).finally(() => {
106-
this.domainsLoading = false
107-
})
108-
},
109-
fetchAccounts () {
110-
api('listAccounts', {
111-
domainid: this.domainId
112-
}).then(response => {
113-
this.accountsList = response.listaccountsresponse.account || []
114-
if (this.accountsList && this.accountsList.length === 0) {
115-
this.handleChangeAccount(null)
116-
}
117-
}).catch(error => {
118-
this.$notifyError(error)
119-
})
120-
},
121-
handleChangeDomain (e) {
122-
this.$emit('domainChange', e)
100+
handleChangeDomain (domainId) {
101+
this.domainId = domainId
102+
this.selectedAccount = null
103+
this.$emit('domainChange', domainId)
123104
this.domainError = false
124-
this.fetchAccounts()
125105
},
126-
handleChangeAccount (e) {
127-
this.$emit('accountChange', e)
106+
handleChangeAccount (accountName) {
107+
this.selectedAccount = accountName
108+
this.$emit('accountChange', accountName)
128109
}
129110
}
130111
}

ui/src/components/widgets/InfiniteScrollSelect.vue

Lines changed: 82 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,10 @@
4141
- optionValueKey (String, optional): Property to use as the value for options (e.g., 'name'). Default is 'id'
4242
- optionLabelKey (String, optional): Property to use as the label for options (e.g., 'name'). Default is 'name'
4343
- defaultOption (Object, optional): Preselected object to include initially
44+
- allowClear (Boolean, optional): Whether to allow clearing the selection. Default is false
4445
- showIcon (Boolean, optional): Whether to show icon for the options. Default is true
4546
- defaultIcon (String, optional): Icon to be shown when there is no resource icon for the option. Default is 'cloud-outlined'
47+
- selectFirstOption (Boolean, optional): Whether to automatically select the first option when options are loaded. Default is false
4648
4749
Events:
4850
- @change-option-value (Function): Emits the selected option value(s) when value(s) changes. Do not use @change as it will give warnings and may not work
@@ -58,6 +60,7 @@
5860
:filter-option="false"
5961
:loading="loading"
6062
show-search
63+
:allowClear="allowClear"
6164
placeholder="Select"
6265
@search="onSearchTimed"
6366
@popupScroll="onScroll"
@@ -75,9 +78,9 @@
7578
</div>
7679
</div>
7780
</template>
78-
<a-select-option v-for="option in options" :key="option.id" :value="option[optionValueKey]">
81+
<a-select-option v-for="option in selectableOptions" :key="option.id" :value="option[optionValueKey]">
7982
<span>
80-
<span v-if="showIcon">
83+
<span v-if="showIcon && option.id !== null && option.id !== undefined">
8184
<resource-icon v-if="option.icon && option.icon.base64image" :image="option.icon.base64image" size="1x" style="margin-right: 5px"/>
8285
<render-icon v-else :icon="defaultIcon" style="margin-right: 5px" />
8386
</span>
@@ -124,6 +127,10 @@ export default {
124127
type: Object,
125128
default: null
126129
},
130+
allowClear: {
131+
type: Boolean,
132+
default: false
133+
},
127134
showIcon: {
128135
type: Boolean,
129136
default: true
@@ -135,6 +142,10 @@ export default {
135142
pageSize: {
136143
type: Number,
137144
default: null
145+
},
146+
selectFirstOption: {
147+
type: Boolean,
148+
default: false
138149
}
139150
},
140151
data () {
@@ -147,7 +158,8 @@ export default {
147158
searchTimer: null,
148159
scrollHandlerAttached: false,
149160
preselectedOptionValue: null,
150-
successiveFetches: 0
161+
successiveFetches: 0,
162+
hasAutoSelectedFirst: false
151163
}
152164
},
153165
created () {
@@ -166,6 +178,36 @@ export default {
166178
},
167179
formattedSearchFooterMessage () {
168180
return `${this.$t('label.showing.results.for').replace('%x', this.searchQuery)}`
181+
},
182+
selectableOptions () {
183+
const currentValue = this.$attrs.value
184+
// Only filter out null/empty options when the current value is also null/undefined/empty
185+
// This prevents such options from being selected and allows the placeholder to show instead
186+
if (currentValue === null || currentValue === undefined || currentValue === '') {
187+
return this.options.filter(option => {
188+
const optionValue = option[this.optionValueKey]
189+
return optionValue !== null && optionValue !== undefined && optionValue !== ''
190+
})
191+
}
192+
// When a valid value is selected, show all options
193+
return this.options
194+
},
195+
apiOptionsCount () {
196+
if (this.defaultOption) {
197+
const defaultOptionValue = this.defaultOption[this.optionValueKey]
198+
return this.options.filter(option => option[this.optionValueKey] !== defaultOptionValue).length
199+
}
200+
return this.options.length
201+
},
202+
preselectedMatchValue () {
203+
// Extract the first value from preselectedOptionValue if it's an array, otherwise return the value itself
204+
if (!this.preselectedOptionValue) return null
205+
return Array.isArray(this.preselectedOptionValue) ? this.preselectedOptionValue[0] : this.preselectedOptionValue
206+
},
207+
preselectedMatch () {
208+
// Find the matching option for the preselected value
209+
if (!this.preselectedMatchValue) return null
210+
return this.options.find(entry => entry[this.optionValueKey] === this.preselectedMatchValue) || null
169211
}
170212
},
171213
watch: {
@@ -210,6 +252,7 @@ export default {
210252
}).finally(() => {
211253
if (this.successiveFetches === 0) {
212254
this.loading = false
255+
this.autoSelectFirstOptionIfNeeded()
213256
}
214257
})
215258
},
@@ -220,19 +263,18 @@ export default {
220263
this.resetPreselectedOptionValue()
221264
return
222265
}
223-
const matchValue = Array.isArray(this.preselectedOptionValue) ? this.preselectedOptionValue[0] : this.preselectedOptionValue
224-
const match = this.options.find(entry => entry[this.optionValueKey] === matchValue)
225-
if (!match) {
266+
if (!this.preselectedMatch) {
226267
this.successiveFetches++
227-
if (this.options.length < this.totalCount) {
268+
// Exclude defaultOption from count when comparing with totalCount
269+
if (this.apiOptionsCount < this.totalCount) {
228270
this.fetchItems()
229271
} else {
230272
this.resetPreselectedOptionValue()
231273
}
232274
return
233275
}
234276
if (Array.isArray(this.preselectedOptionValue) && this.preselectedOptionValue.length > 1) {
235-
this.preselectedOptionValue = this.preselectedOptionValue.filter(o => o !== match)
277+
this.preselectedOptionValue = this.preselectedOptionValue.filter(o => o !== this.preselectedMatchValue)
236278
} else {
237279
this.resetPreselectedOptionValue()
238280
}
@@ -246,6 +288,36 @@ export default {
246288
this.preselectedOptionValue = null
247289
this.successiveFetches = 0
248290
},
291+
autoSelectFirstOptionIfNeeded () {
292+
if (!this.selectFirstOption || this.hasAutoSelectedFirst) {
293+
return
294+
}
295+
// Don't auto-select if there's a preselected value being fetched
296+
if (this.preselectedOptionValue) {
297+
return
298+
}
299+
const currentValue = this.$attrs.value
300+
if (currentValue !== undefined && currentValue !== null && currentValue !== '') {
301+
return
302+
}
303+
if (this.options.length === 0) {
304+
return
305+
}
306+
if (this.searchQuery && this.searchQuery.length > 0) {
307+
return
308+
}
309+
// Only auto-select after initial load is complete (no more successive fetches)
310+
if (this.successiveFetches > 0) {
311+
return
312+
}
313+
const firstOption = this.options[0]
314+
if (firstOption) {
315+
const firstValue = firstOption[this.optionValueKey]
316+
this.hasAutoSelectedFirst = true
317+
this.$emit('change-option-value', firstValue)
318+
this.$emit('change-option', firstOption)
319+
}
320+
},
249321
onSearchTimed (value) {
250322
clearTimeout(this.searchTimer)
251323
this.searchTimer = setTimeout(() => {
@@ -264,7 +336,8 @@ export default {
264336
},
265337
onScroll (e) {
266338
const nearBottom = e.target.scrollTop + e.target.clientHeight >= e.target.scrollHeight - 10
267-
const hasMore = this.options.length < this.totalCount
339+
// Exclude defaultOption from count when comparing with totalCount
340+
const hasMore = this.apiOptionsCount < this.totalCount
268341
if (nearBottom && hasMore && !this.loading) {
269342
this.fetchItems()
270343
}

0 commit comments

Comments
 (0)