crm/frontend/src/components/Controls/MultiSelectEmailInput.vue
Shariq Ansari 512970cad1 fix: filter out existing emails
(cherry picked from commit 08bab927a2ad5fa06f3d1a000262e11a35f943fd)
2025-06-23 07:54:21 +00:00

305 lines
8.0 KiB
Vue

<template>
<div>
<div class="flex flex-wrap gap-1">
<Button
ref="emails"
v-for="value in values"
:key="value"
:label="value"
theme="gray"
variant="subtle"
:class="{
'rounded bg-surface-white hover:!bg-surface-gray-1 focus-visible:ring-outline-gray-4':
variant === 'subtle',
}"
@keydown.delete.capture.stop="removeLastValue"
>
<template #suffix>
<FeatherIcon
class="h-3.5"
name="x"
@click.stop="removeValue(value)"
/>
</template>
</Button>
<div class="flex-1">
<Combobox v-model="selectedValue" nullable>
<Popover class="w-full" v-model:show="showOptions">
<template #target="{ togglePopover }">
<ComboboxInput
ref="search"
class="search-input form-input w-full border-none focus:border-none focus:!shadow-none focus-visible:!ring-0"
:class="[
variant == 'ghost'
? 'bg-surface-white hover:bg-surface-white'
: 'bg-surface-gray-2 hover:bg-surface-gray-3',
inputClass,
]"
:placeholder="placeholder"
type="text"
:value="query"
@change="
(e) => {
query = e.target.value
showOptions = true
}
"
autocomplete="off"
@focus="() => togglePopover()"
@keydown.delete.capture.stop="removeLastValue"
/>
</template>
<template #body="{ isOpen }">
<div v-show="isOpen">
<div
class="mt-1 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<ComboboxOptions
class="p-1.5 max-h-[12rem] overflow-y-auto"
static
>
<div
v-if="!options.length"
class="flex gap-2 rounded px-2 py-1 text-base text-ink-gray-5"
>
<FeatherIcon
v-if="fetchContacts"
name="search"
class="h-4"
/>
{{
fetchContacts
? __('No results found')
: __('Type an email address to invite')
}}
</div>
<ComboboxOption
v-for="option in options"
:key="option.value"
:value="option"
v-slot="{ active }"
>
<li
:class="[
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
{ 'bg-surface-gray-3': active },
]"
>
<UserAvatar
class="mr-2"
:user="option.value"
size="lg"
/>
<div class="flex flex-col gap-1 p-1 text-ink-gray-8">
<div class="text-base font-medium">
{{ option.label }}
</div>
<div class="text-sm text-ink-gray-5">
{{ option.value }}
</div>
</div>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
</div>
</template>
</Popover>
</Combobox>
</div>
</div>
<ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" />
<div
v-if="info"
class="whitespace-pre-line text-sm text-ink-blue-3 mt-2 pl-2"
>
{{ info }}
</div>
</div>
</template>
<script setup>
import {
Combobox,
ComboboxInput,
ComboboxOptions,
ComboboxOption,
} from '@headlessui/vue'
import UserAvatar from '@/components/UserAvatar.vue'
import Popover from '@/components/frappe-ui/Popover.vue'
import { createResource } from 'frappe-ui'
import { ref, computed, nextTick } from 'vue'
import { watchDebounced } from '@vueuse/core'
const props = defineProps({
validate: {
type: Function,
default: null,
},
variant: {
type: String,
default: 'subtle',
},
placeholder: {
type: String,
default: '',
},
inputClass: {
type: String,
default: '',
},
errorMessage: {
type: Function,
default: (value) => `${value} is an Invalid value`,
},
fetchContacts: {
type: Boolean,
default: true,
},
existingEmails: {
type: Array,
default: () => [],
},
})
const values = defineModel()
const emails = ref([])
const search = ref(null)
const error = ref(null)
const info = ref(null)
const query = ref('')
const text = ref('')
const showOptions = ref(false)
const selectedValue = computed({
get: () => query.value || '',
set: (val) => {
query.value = ''
if (val) {
showOptions.value = false
}
val?.value && addValue(val.value)
},
})
watchDebounced(
query,
(val) => {
val = val || ''
if (text.value === val && options.value?.length) return
text.value = val
reload(val)
},
{ debounce: 300, immediate: true },
)
const filterOptions = createResource({
url: 'crm.api.contact.search_emails',
method: 'POST',
cache: [text.value, 'Contact'],
params: { txt: text.value },
transform: (data) => {
let allData = data.map((option) => {
let fullName = option[0]
let email = option[1]
let name = option[2]
return {
label: fullName || name || email,
value: email,
}
})
// Filter out existing emails
if (props.existingEmails?.length) {
allData = allData.filter((option) => {
return !props.existingEmails.includes(option.value)
})
}
return allData
},
})
const options = computed(() => {
let searchedContacts = props.fetchContacts ? filterOptions.data : []
if (!searchedContacts?.length && query.value) {
searchedContacts.push({
label: query.value,
value: query.value,
})
}
return searchedContacts || []
})
function reload(val) {
if (!props.fetchContacts) return
filterOptions.update({
params: { txt: val },
})
filterOptions.reload()
}
const addValue = (value) => {
error.value = null
info.value = null
if (value) {
const splitValues = value.split(',')
splitValues.forEach((value) => {
value = value.trim()
if (value) {
// check if value is not already in the values array
if (!values.value?.includes(value)) {
// check if value is valid
if (value && props.validate && !props.validate(value)) {
error.value = props.errorMessage(value)
query.value = value
return
}
// add value to values array
if (!values.value) {
values.value = [value]
} else {
values.value.push(value)
}
value = value.replace(value, '')
} else {
info.value = __('email already exists')
}
}
})
!error.value && (value = '')
}
}
const removeValue = (value) => {
values.value = values.value.filter((v) => v !== value)
}
const removeLastValue = () => {
if (query.value) return
let emailRef = emails.value[emails.value.length - 1]?.$el
if (document.activeElement === emailRef) {
values.value.pop()
nextTick(() => {
if (values.value.length) {
emailRef = emails.value[emails.value.length - 1].$el
emailRef?.focus()
} else {
setFocus()
}
})
} else {
emailRef?.focus()
}
}
function setFocus() {
search.value.$el.focus()
}
defineExpose({ setFocus })
</script>