refactor: replace MultiSelectEmailInput and MultiSelectUserInput with EmailMultiSelect component
- Removed MultiSelectEmailInput.vue and MultiSelectUserInput.vue components. - Introduced EmailMultiSelect.vue component for handling email selection. - Updated EmailEditor.vue, AddExistingUserModal.vue, InviteUserPage.vue, and Users.vue to use EmailMultiSelect. - Adjusted props and validation logic in the new EmailMultiSelect component. - Removed unused Dropdown.vue component.
This commit is contained in:
parent
610a5cd40b
commit
20318d0d13
12
frontend/components.d.ts
vendored
12
frontend/components.d.ts
vendored
@ -48,7 +48,6 @@ declare module 'vue' {
|
|||||||
BulkDeleteLinkedDocModal: typeof import('./src/components/BulkDeleteLinkedDocModal.vue')['default']
|
BulkDeleteLinkedDocModal: typeof import('./src/components/BulkDeleteLinkedDocModal.vue')['default']
|
||||||
CalendarEventPanel: typeof import('./src/components/Calendar/CalendarEventPanel.vue')['default']
|
CalendarEventPanel: typeof import('./src/components/Calendar/CalendarEventPanel.vue')['default']
|
||||||
CalendarIcon: typeof import('./src/components/Icons/CalendarIcon.vue')['default']
|
CalendarIcon: typeof import('./src/components/Icons/CalendarIcon.vue')['default']
|
||||||
CalendarModal: typeof import('./src/components/Modals/CalendarModal.vue')['default']
|
|
||||||
CallArea: typeof import('./src/components/Activities/CallArea.vue')['default']
|
CallArea: typeof import('./src/components/Activities/CallArea.vue')['default']
|
||||||
CallLogDetailModal: typeof import('./src/components/Modals/CallLogDetailModal.vue')['default']
|
CallLogDetailModal: typeof import('./src/components/Modals/CallLogDetailModal.vue')['default']
|
||||||
CallLogModal: typeof import('./src/components/Modals/CallLogModal.vue')['default']
|
CallLogModal: typeof import('./src/components/Modals/CallLogModal.vue')['default']
|
||||||
@ -85,7 +84,6 @@ declare module 'vue' {
|
|||||||
DashboardItem: typeof import('./src/components/Dashboard/DashboardItem.vue')['default']
|
DashboardItem: typeof import('./src/components/Dashboard/DashboardItem.vue')['default']
|
||||||
DataFields: typeof import('./src/components/Activities/DataFields.vue')['default']
|
DataFields: typeof import('./src/components/Activities/DataFields.vue')['default']
|
||||||
DataFieldsModal: typeof import('./src/components/Modals/DataFieldsModal.vue')['default']
|
DataFieldsModal: typeof import('./src/components/Modals/DataFieldsModal.vue')['default']
|
||||||
DateMonthYearPicker: typeof import('./src/components/Calendar/DateMonthYearPicker.vue')['default']
|
|
||||||
DealModal: typeof import('./src/components/Modals/DealModal.vue')['default']
|
DealModal: typeof import('./src/components/Modals/DealModal.vue')['default']
|
||||||
DealsIcon: typeof import('./src/components/Icons/DealsIcon.vue')['default']
|
DealsIcon: typeof import('./src/components/Icons/DealsIcon.vue')['default']
|
||||||
DealsListView: typeof import('./src/components/ListViews/DealsListView.vue')['default']
|
DealsListView: typeof import('./src/components/ListViews/DealsListView.vue')['default']
|
||||||
@ -101,7 +99,6 @@ declare module 'vue' {
|
|||||||
DoubleCheckIcon: typeof import('./src/components/Icons/DoubleCheckIcon.vue')['default']
|
DoubleCheckIcon: typeof import('./src/components/Icons/DoubleCheckIcon.vue')['default']
|
||||||
DragIcon: typeof import('./src/components/Icons/DragIcon.vue')['default']
|
DragIcon: typeof import('./src/components/Icons/DragIcon.vue')['default']
|
||||||
DragVerticalIcon: typeof import('./src/components/Icons/DragVerticalIcon.vue')['default']
|
DragVerticalIcon: typeof import('./src/components/Icons/DragVerticalIcon.vue')['default']
|
||||||
Dropdown: typeof import('./src/components/frappe-ui/Dropdown.vue')['default']
|
|
||||||
DropdownItem: typeof import('./src/components/DropdownItem.vue')['default']
|
DropdownItem: typeof import('./src/components/DropdownItem.vue')['default']
|
||||||
DuplicateIcon: typeof import('./src/components/Icons/DuplicateIcon.vue')['default']
|
DuplicateIcon: typeof import('./src/components/Icons/DuplicateIcon.vue')['default']
|
||||||
DurationIcon: typeof import('./src/components/Icons/DurationIcon.vue')['default']
|
DurationIcon: typeof import('./src/components/Icons/DurationIcon.vue')['default']
|
||||||
@ -119,13 +116,12 @@ declare module 'vue' {
|
|||||||
EmailEdit: typeof import('./src/components/Settings/EmailEdit.vue')['default']
|
EmailEdit: typeof import('./src/components/Settings/EmailEdit.vue')['default']
|
||||||
EmailEditor: typeof import('./src/components/EmailEditor.vue')['default']
|
EmailEditor: typeof import('./src/components/EmailEditor.vue')['default']
|
||||||
EmailIcon: typeof import('./src/components/Icons/EmailIcon.vue')['default']
|
EmailIcon: typeof import('./src/components/Icons/EmailIcon.vue')['default']
|
||||||
|
EmailMultiSelect: typeof import('./src/components/Controls/EmailMultiSelect.vue')['default']
|
||||||
EmailProviderIcon: typeof import('./src/components/Settings/EmailProviderIcon.vue')['default']
|
EmailProviderIcon: typeof import('./src/components/Settings/EmailProviderIcon.vue')['default']
|
||||||
EmailTemplateIcon: typeof import('./src/components/Icons/EmailTemplateIcon.vue')['default']
|
EmailTemplateIcon: typeof import('./src/components/Icons/EmailTemplateIcon.vue')['default']
|
||||||
EmailTemplateModal: typeof import('./src/components/Modals/EmailTemplateModal.vue')['default']
|
|
||||||
EmailTemplatePage: typeof import('./src/components/Settings/EmailTemplate/EmailTemplatePage.vue')['default']
|
EmailTemplatePage: typeof import('./src/components/Settings/EmailTemplate/EmailTemplatePage.vue')['default']
|
||||||
EmailTemplates: typeof import('./src/components/Settings/EmailTemplate/EmailTemplates.vue')['default']
|
EmailTemplates: typeof import('./src/components/Settings/EmailTemplate/EmailTemplates.vue')['default']
|
||||||
EmailTemplateSelectorModal: typeof import('./src/components/Modals/EmailTemplateSelectorModal.vue')['default']
|
EmailTemplateSelectorModal: typeof import('./src/components/Modals/EmailTemplateSelectorModal.vue')['default']
|
||||||
EmailTemplatesListView: typeof import('./src/components/ListViews/EmailTemplatesListView.vue')['default']
|
|
||||||
ERPNextIcon: typeof import('./src/components/Icons/ERPNextIcon.vue')['default']
|
ERPNextIcon: typeof import('./src/components/Icons/ERPNextIcon.vue')['default']
|
||||||
ERPNextSettings: typeof import('./src/components/Settings/ERPNextSettings.vue')['default']
|
ERPNextSettings: typeof import('./src/components/Settings/ERPNextSettings.vue')['default']
|
||||||
ErrorPage: typeof import('./src/components/ErrorPage.vue')['default']
|
ErrorPage: typeof import('./src/components/ErrorPage.vue')['default']
|
||||||
@ -175,7 +171,6 @@ declare module 'vue' {
|
|||||||
InboxIcon: typeof import('./src/components/Icons/InboxIcon.vue')['default']
|
InboxIcon: typeof import('./src/components/Icons/InboxIcon.vue')['default']
|
||||||
IndicatorIcon: typeof import('./src/components/Icons/IndicatorIcon.vue')['default']
|
IndicatorIcon: typeof import('./src/components/Icons/IndicatorIcon.vue')['default']
|
||||||
InviteIcon: typeof import('./src/components/Icons/InviteIcon.vue')['default']
|
InviteIcon: typeof import('./src/components/Icons/InviteIcon.vue')['default']
|
||||||
InviteMemberPage: typeof import('./src/components/Settings/InviteMemberPage.vue')['default']
|
|
||||||
InviteUserPage: typeof import('./src/components/Settings/InviteUserPage.vue')['default']
|
InviteUserPage: typeof import('./src/components/Settings/InviteUserPage.vue')['default']
|
||||||
KanbanIcon: typeof import('./src/components/Icons/KanbanIcon.vue')['default']
|
KanbanIcon: typeof import('./src/components/Icons/KanbanIcon.vue')['default']
|
||||||
KanbanSettings: typeof import('./src/components/Kanban/KanbanSettings.vue')['default']
|
KanbanSettings: typeof import('./src/components/Kanban/KanbanSettings.vue')['default']
|
||||||
@ -210,10 +205,7 @@ declare module 'vue' {
|
|||||||
MoneyIcon: typeof import('./src/components/Icons/MoneyIcon.vue')['default']
|
MoneyIcon: typeof import('./src/components/Icons/MoneyIcon.vue')['default']
|
||||||
MultiActionButton: typeof import('./src/components/MultiActionButton.vue')['default']
|
MultiActionButton: typeof import('./src/components/MultiActionButton.vue')['default']
|
||||||
MultipleAvatar: typeof import('./src/components/MultipleAvatar.vue')['default']
|
MultipleAvatar: typeof import('./src/components/MultipleAvatar.vue')['default']
|
||||||
MultiSelectEmailInput: typeof import('./src/components/Controls/MultiSelectEmailInput.vue')['default']
|
|
||||||
MultiSelectUserInput: typeof import('./src/components/Controls/MultiSelectUserInput.vue')['default']
|
|
||||||
MuteIcon: typeof import('./src/components/Icons/MuteIcon.vue')['default']
|
MuteIcon: typeof import('./src/components/Icons/MuteIcon.vue')['default']
|
||||||
NestedPopover: typeof import('./src/components/NestedPopover.vue')['default']
|
|
||||||
NewEmailTemplate: typeof import('./src/components/Settings/EmailTemplate/NewEmailTemplate.vue')['default']
|
NewEmailTemplate: typeof import('./src/components/Settings/EmailTemplate/NewEmailTemplate.vue')['default']
|
||||||
NoteArea: typeof import('./src/components/Activities/NoteArea.vue')['default']
|
NoteArea: typeof import('./src/components/Activities/NoteArea.vue')['default']
|
||||||
NoteIcon: typeof import('./src/components/Icons/NoteIcon.vue')['default']
|
NoteIcon: typeof import('./src/components/Icons/NoteIcon.vue')['default']
|
||||||
@ -233,7 +225,6 @@ declare module 'vue' {
|
|||||||
PlaybackSpeedOption: typeof import('./src/components/Activities/PlaybackSpeedOption.vue')['default']
|
PlaybackSpeedOption: typeof import('./src/components/Activities/PlaybackSpeedOption.vue')['default']
|
||||||
PlayIcon: typeof import('./src/components/Icons/PlayIcon.vue')['default']
|
PlayIcon: typeof import('./src/components/Icons/PlayIcon.vue')['default']
|
||||||
Popover: typeof import('./src/components/frappe-ui/Popover.vue')['default']
|
Popover: typeof import('./src/components/frappe-ui/Popover.vue')['default']
|
||||||
ProfileImageEditor: typeof import('./src/components/Settings/ProfileImageEditor.vue')['default']
|
|
||||||
ProfileSettings: typeof import('./src/components/Settings/ProfileSettings.vue')['default']
|
ProfileSettings: typeof import('./src/components/Settings/ProfileSettings.vue')['default']
|
||||||
QuickEntryModal: typeof import('./src/components/Modals/QuickEntryModal.vue')['default']
|
QuickEntryModal: typeof import('./src/components/Modals/QuickEntryModal.vue')['default']
|
||||||
QuickFilterField: typeof import('./src/components/QuickFilterField.vue')['default']
|
QuickFilterField: typeof import('./src/components/QuickFilterField.vue')['default']
|
||||||
@ -276,7 +267,6 @@ declare module 'vue' {
|
|||||||
TelegramIcon: typeof import('./src/components/Icons/TelegramIcon.vue')['default']
|
TelegramIcon: typeof import('./src/components/Icons/TelegramIcon.vue')['default']
|
||||||
TelephonySettings: typeof import('./src/components/Settings/TelephonySettings.vue')['default']
|
TelephonySettings: typeof import('./src/components/Settings/TelephonySettings.vue')['default']
|
||||||
TerritoryIcon: typeof import('./src/components/Icons/TerritoryIcon.vue')['default']
|
TerritoryIcon: typeof import('./src/components/Icons/TerritoryIcon.vue')['default']
|
||||||
TimePicker: typeof import('./src/components/Calendar/TimePicker.vue')['default']
|
|
||||||
TwilioCallUI: typeof import('./src/components/Telephony/TwilioCallUI.vue')['default']
|
TwilioCallUI: typeof import('./src/components/Telephony/TwilioCallUI.vue')['default']
|
||||||
UnpinIcon: typeof import('./src/components/Icons/UnpinIcon.vue')['default']
|
UnpinIcon: typeof import('./src/components/Icons/UnpinIcon.vue')['default']
|
||||||
UserAvatar: typeof import('./src/components/UserAvatar.vue')['default']
|
UserAvatar: typeof import('./src/components/UserAvatar.vue')['default']
|
||||||
|
|||||||
316
frontend/src/components/Controls/EmailMultiSelect.vue
Normal file
316
frontend/src/components/Controls/EmailMultiSelect.vue
Normal file
@ -0,0 +1,316 @@
|
|||||||
|
<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">
|
||||||
|
<ComboboxRoot
|
||||||
|
:model-value="tempSelection"
|
||||||
|
:open="showOptions"
|
||||||
|
@update:open="(o) => (showOptions = o)"
|
||||||
|
@update:modelValue="onSelect"
|
||||||
|
:ignore-filter="true"
|
||||||
|
>
|
||||||
|
<ComboboxAnchor
|
||||||
|
class="flex h-7 items-center gap-2 rounded px-2 py-1 border border-transparent"
|
||||||
|
:class="[
|
||||||
|
variant == 'ghost'
|
||||||
|
? 'bg-surface-white hover:bg-surface-white'
|
||||||
|
: 'bg-surface-gray-2 hover:bg-surface-gray-3',
|
||||||
|
inputClass,
|
||||||
|
]"
|
||||||
|
style="max-width: 100%; width: auto"
|
||||||
|
>
|
||||||
|
<ComboboxInput
|
||||||
|
ref="search"
|
||||||
|
:value="query"
|
||||||
|
autocomplete="off"
|
||||||
|
class="bg-transparent p-0 outline-none border-0 text-base text-ink-gray-8 h-full placeholder:text-ink-gray-4 w-full focus:outline-none focus:ring-0 focus:border-0"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
@focus="showOptions = true"
|
||||||
|
@input="onInput"
|
||||||
|
@keydown.delete.capture.stop="removeLastValue"
|
||||||
|
@keydown.enter.prevent="handleEnter"
|
||||||
|
/>
|
||||||
|
</ComboboxAnchor>
|
||||||
|
<ComboboxPortal>
|
||||||
|
<ComboboxContent
|
||||||
|
class="z-10 mt-1 bg-surface-modal overflow-hidden rounded-lg shadow-2xl ring-1 ring-black ring-opacity-5"
|
||||||
|
position="popper"
|
||||||
|
:align="'start'"
|
||||||
|
:style="{ minWidth: '12rem', width: 'auto', maxWidth: '24rem' }"
|
||||||
|
@openAutoFocus.prevent
|
||||||
|
@closeAutoFocus.prevent
|
||||||
|
>
|
||||||
|
<ComboboxViewport class="max-h-60 overflow-auto p-1.5">
|
||||||
|
<ComboboxEmpty
|
||||||
|
class="flex gap-2 rounded px-2 py-1 text-base text-ink-gray-5"
|
||||||
|
>
|
||||||
|
<FeatherIcon
|
||||||
|
v-if="showSearchIcon"
|
||||||
|
name="search"
|
||||||
|
class="h-4"
|
||||||
|
/>
|
||||||
|
{{ emptyStateText }}
|
||||||
|
</ComboboxEmpty>
|
||||||
|
<ComboboxItem
|
||||||
|
v-for="option in options"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
class="text-base leading-none text-ink-gray-7 rounded flex items-center px-2 py-1 relative select-none data-[highlighted]:outline-none data-[highlighted]:bg-surface-gray-3 cursor-pointer"
|
||||||
|
@mousedown.prevent="onSelect(option.value)"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</ComboboxItem>
|
||||||
|
</ComboboxViewport>
|
||||||
|
</ComboboxContent>
|
||||||
|
</ComboboxPortal>
|
||||||
|
</ComboboxRoot>
|
||||||
|
</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>
|
||||||
|
// Generic multi-source (users / contacts / free) multi-select email-like input
|
||||||
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
|
import { usersStore } from '@/stores/users'
|
||||||
|
import { createResource } from 'frappe-ui'
|
||||||
|
import {
|
||||||
|
ComboboxRoot,
|
||||||
|
ComboboxAnchor,
|
||||||
|
ComboboxInput,
|
||||||
|
ComboboxPortal,
|
||||||
|
ComboboxContent,
|
||||||
|
ComboboxViewport,
|
||||||
|
ComboboxItem,
|
||||||
|
ComboboxEmpty,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { ref, computed, nextTick } from 'vue'
|
||||||
|
import { watchDebounced } from '@vueuse/core'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
// Behaviour
|
||||||
|
mode: { type: String, default: null }, // 'users' | 'contacts' | 'free' (fallback to legacy flags)
|
||||||
|
fetchUsers: { type: Boolean, default: false },
|
||||||
|
fetchContacts: { type: Boolean, default: false },
|
||||||
|
existingEmails: { type: Array, default: () => [] },
|
||||||
|
validate: { type: Function, default: null },
|
||||||
|
errorMessage: {
|
||||||
|
type: Function,
|
||||||
|
default: (value) => `${value} is an Invalid value`,
|
||||||
|
},
|
||||||
|
emptyPlaceholder: { type: String, default: __('No results found') },
|
||||||
|
// UI
|
||||||
|
variant: { type: String, default: 'subtle' },
|
||||||
|
placeholder: { type: String, default: '' },
|
||||||
|
inputClass: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
// v-model values
|
||||||
|
const values = defineModel()
|
||||||
|
|
||||||
|
// Determine effective mode (backwards compatibility with old components)
|
||||||
|
const effectiveMode = computed(() => {
|
||||||
|
if (props.mode) return props.mode
|
||||||
|
if (props.fetchUsers) return 'users'
|
||||||
|
if (props.fetchContacts) return 'contacts'
|
||||||
|
return 'free'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Common state
|
||||||
|
const emails = ref([])
|
||||||
|
const search = ref(null)
|
||||||
|
const error = ref(null)
|
||||||
|
const info = ref(null)
|
||||||
|
const query = ref('')
|
||||||
|
const showOptions = ref(false)
|
||||||
|
const tempSelection = ref(null)
|
||||||
|
|
||||||
|
// Users data
|
||||||
|
const { users } = usersStore()
|
||||||
|
|
||||||
|
// Contacts resource (only if needed)
|
||||||
|
const filterOptions = ref(null)
|
||||||
|
const lastLoadedQuery = ref('')
|
||||||
|
|
||||||
|
if (effectiveMode.value === 'contacts') {
|
||||||
|
filterOptions.value = createResource({
|
||||||
|
url: 'crm.api.contact.search_emails',
|
||||||
|
method: 'POST',
|
||||||
|
cache: ['ContactEmails'],
|
||||||
|
params: { txt: '' },
|
||||||
|
transform: (data) => {
|
||||||
|
let allData = (data || []).map((option) => {
|
||||||
|
const fullName = option[0]
|
||||||
|
const email = option[1]
|
||||||
|
const name = option[2]
|
||||||
|
return { label: fullName || name || email, value: email }
|
||||||
|
})
|
||||||
|
if (props.existingEmails?.length) {
|
||||||
|
allData = allData.filter((o) => !props.existingEmails.includes(o.value))
|
||||||
|
}
|
||||||
|
return allData
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
watchDebounced(
|
||||||
|
query,
|
||||||
|
(val) => {
|
||||||
|
val = val || ''
|
||||||
|
if (lastLoadedQuery.value === val && options.value?.length) return
|
||||||
|
lastLoadedQuery.value = val
|
||||||
|
reload(val)
|
||||||
|
},
|
||||||
|
{ debounce: 300, immediate: true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function reload(val) {
|
||||||
|
if (effectiveMode.value !== 'contacts' || !filterOptions.value) return
|
||||||
|
filterOptions.value.update({ params: { txt: val } })
|
||||||
|
filterOptions.value.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options computed
|
||||||
|
const options = computed(() => {
|
||||||
|
const mode = effectiveMode.value
|
||||||
|
if (mode === 'users') {
|
||||||
|
let list = users?.data?.allUsers || []
|
||||||
|
list = list.map((u) => ({
|
||||||
|
label: u.full_name || u.name || u.email,
|
||||||
|
value: u.email,
|
||||||
|
}))
|
||||||
|
if (props.existingEmails?.length) {
|
||||||
|
list = list.filter((o) => !props.existingEmails.includes(o.value))
|
||||||
|
}
|
||||||
|
if (query.value) {
|
||||||
|
const q = query.value.toLowerCase()
|
||||||
|
list = list.filter(
|
||||||
|
(o) =>
|
||||||
|
o.label?.toLowerCase().includes(q) ||
|
||||||
|
o.value?.toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
if (mode === 'contacts') {
|
||||||
|
const list = filterOptions.value?.data ? [...filterOptions.value.data] : []
|
||||||
|
if (!list.length && query.value) {
|
||||||
|
list.push({ label: query.value, value: query.value })
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
// Free / manual mode
|
||||||
|
return query.value ? [{ label: query.value, value: query.value }] : []
|
||||||
|
})
|
||||||
|
|
||||||
|
const showSearchIcon = computed(() => effectiveMode.value !== 'free')
|
||||||
|
const emptyStateText = computed(() => {
|
||||||
|
if (effectiveMode.value === 'free') return __(props.emptyPlaceholder)
|
||||||
|
return options.value.length ? '' : __(props.emptyPlaceholder)
|
||||||
|
})
|
||||||
|
|
||||||
|
function addValue(input) {
|
||||||
|
if (!input) return
|
||||||
|
error.value = null
|
||||||
|
info.value = null
|
||||||
|
const parts = input
|
||||||
|
.split(',')
|
||||||
|
.map((p) => p.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
for (const email of parts) {
|
||||||
|
if (values.value?.includes(email)) {
|
||||||
|
info.value = __('email already exists')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (props.validate && !props.validate(email)) {
|
||||||
|
error.value = props.errorMessage(email)
|
||||||
|
query.value = email
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (!values.value) values.value = [email]
|
||||||
|
else values.value.push(email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeValue(value) {
|
||||||
|
values.value = values.value.filter((v) => v !== value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLastValue() {
|
||||||
|
if (query.value) return
|
||||||
|
let emailRef = emails.value[emails.value.length - 1]?.rootRef
|
||||||
|
if (document.activeElement === emailRef) {
|
||||||
|
values.value.pop()
|
||||||
|
nextTick(() => {
|
||||||
|
if (values.value.length) {
|
||||||
|
emailRef = emails.value[emails.value.length - 1].rootRef
|
||||||
|
emailRef?.focus()
|
||||||
|
} else {
|
||||||
|
setFocus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
emailRef?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFocus() {
|
||||||
|
search.value?.focus?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ setFocus })
|
||||||
|
|
||||||
|
function onInput(e) {
|
||||||
|
query.value = e.target.value
|
||||||
|
showOptions.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelect(val) {
|
||||||
|
if (!val) return
|
||||||
|
addValue(val)
|
||||||
|
if (!error.value) {
|
||||||
|
query.value = ''
|
||||||
|
tempSelection.value = null
|
||||||
|
showOptions.value = false
|
||||||
|
nextTick(() => setFocus())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEnter() {
|
||||||
|
if (query.value) onSelect(query.value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -1,304 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,278 +0,0 @@
|
|||||||
<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="fetchUsers"
|
|
||||||
name="search"
|
|
||||||
class="h-4"
|
|
||||||
/>
|
|
||||||
{{
|
|
||||||
fetchUsers
|
|
||||||
? __('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 { usersStore } from '@/stores/users'
|
|
||||||
import { ref, computed, nextTick } from 'vue'
|
|
||||||
|
|
||||||
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`,
|
|
||||||
},
|
|
||||||
fetchUsers: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
existingEmails: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const values = defineModel()
|
|
||||||
|
|
||||||
const { users } = usersStore()
|
|
||||||
|
|
||||||
const emails = ref([])
|
|
||||||
const search = ref(null)
|
|
||||||
const error = ref(null)
|
|
||||||
const info = ref(null)
|
|
||||||
const query = 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)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const options = computed(() => {
|
|
||||||
let userEmails = props.fetchUsers ? users?.data?.allUsers : []
|
|
||||||
|
|
||||||
if (props.fetchUsers) {
|
|
||||||
userEmails = userEmails.map((user) => ({
|
|
||||||
label: user.full_name || user.name || user.email,
|
|
||||||
value: user.email,
|
|
||||||
}))
|
|
||||||
|
|
||||||
if (props.existingEmails?.length) {
|
|
||||||
userEmails = userEmails.filter((option) => {
|
|
||||||
return !props.existingEmails.includes(option.value)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.value) {
|
|
||||||
userEmails = userEmails.filter(
|
|
||||||
(option) =>
|
|
||||||
option.label.toLowerCase().includes(query.value.toLowerCase()) ||
|
|
||||||
option.value.toLowerCase().includes(query.value.toLowerCase()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (!userEmails?.length && query.value) {
|
|
||||||
userEmails.push({
|
|
||||||
label: query.value,
|
|
||||||
value: query.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return userEmails || []
|
|
||||||
})
|
|
||||||
|
|
||||||
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>
|
|
||||||
@ -20,11 +20,12 @@
|
|||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<div class="sm:mx-10 mx-4 flex items-center gap-2 border-t pt-2.5">
|
<div class="sm:mx-10 mx-4 flex items-center gap-2 border-t pt-2.5">
|
||||||
<span class="text-xs text-ink-gray-4">{{ __('TO') }}:</span>
|
<span class="text-xs text-ink-gray-4">{{ __('TO') }}:</span>
|
||||||
<MultiSelectEmailInput
|
<EmailMultiSelect
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
v-model="toEmails"
|
v-model="toEmails"
|
||||||
:validate="validateEmail"
|
:validate="validateEmail"
|
||||||
|
:fetchContacts="true"
|
||||||
:error-message="
|
:error-message="
|
||||||
(value) => __('{0} is an invalid email address', [value])
|
(value) => __('{0} is an invalid email address', [value])
|
||||||
"
|
"
|
||||||
@ -54,11 +55,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="cc" class="sm:mx-10 mx-4 flex items-center gap-2">
|
<div v-if="cc" class="sm:mx-10 mx-4 flex items-center gap-2">
|
||||||
<span class="text-xs text-ink-gray-4">{{ __('CC') }}:</span>
|
<span class="text-xs text-ink-gray-4">{{ __('CC') }}:</span>
|
||||||
<MultiSelectEmailInput
|
<EmailMultiSelect
|
||||||
ref="ccInput"
|
ref="ccInput"
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
v-model="ccEmails"
|
v-model="ccEmails"
|
||||||
|
:fetchContacts="true"
|
||||||
:validate="validateEmail"
|
:validate="validateEmail"
|
||||||
:error-message="
|
:error-message="
|
||||||
(value) => __('{0} is an invalid email address', [value])
|
(value) => __('{0} is an invalid email address', [value])
|
||||||
@ -67,11 +69,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="bcc" class="sm:mx-10 mx-4 flex items-center gap-2">
|
<div v-if="bcc" class="sm:mx-10 mx-4 flex items-center gap-2">
|
||||||
<span class="text-xs text-ink-gray-4">{{ __('BCC') }}:</span>
|
<span class="text-xs text-ink-gray-4">{{ __('BCC') }}:</span>
|
||||||
<MultiSelectEmailInput
|
<EmailMultiSelect
|
||||||
ref="bccInput"
|
ref="bccInput"
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
v-model="bccEmails"
|
v-model="bccEmails"
|
||||||
|
:fetchContacts="true"
|
||||||
:validate="validateEmail"
|
:validate="validateEmail"
|
||||||
:error-message="
|
:error-message="
|
||||||
(value) => __('{0} is an invalid email address', [value])
|
(value) => __('{0} is an invalid email address', [value])
|
||||||
@ -179,7 +182,7 @@ import SmileIcon from '@/components/Icons/SmileIcon.vue'
|
|||||||
import EmailTemplateIcon from '@/components/Icons/EmailTemplateIcon.vue'
|
import EmailTemplateIcon from '@/components/Icons/EmailTemplateIcon.vue'
|
||||||
import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
|
import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
|
||||||
import AttachmentItem from '@/components/AttachmentItem.vue'
|
import AttachmentItem from '@/components/AttachmentItem.vue'
|
||||||
import MultiSelectEmailInput from '@/components/Controls/MultiSelectEmailInput.vue'
|
import EmailMultiSelect from '@/components/Controls/EmailMultiSelect.vue'
|
||||||
import EmailTemplateSelectorModal from '@/components/Modals/EmailTemplateSelectorModal.vue'
|
import EmailTemplateSelectorModal from '@/components/Modals/EmailTemplateSelectorModal.vue'
|
||||||
import { TextEditorBubbleMenu, TextEditor, FileUploader, call } from 'frappe-ui'
|
import { TextEditorBubbleMenu, TextEditor, FileUploader, call } from 'frappe-ui'
|
||||||
import { capture } from '@/telemetry'
|
import { capture } from '@/telemetry'
|
||||||
|
|||||||
@ -21,13 +21,14 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="p-2 group bg-surface-gray-2 hover:bg-surface-gray-3 rounded">
|
<div class="p-2 group bg-surface-gray-2 hover:bg-surface-gray-3 rounded">
|
||||||
<MultiSelectUserInput
|
<EmailMultiSelect
|
||||||
v-if="users?.data?.crmUsers?.length"
|
v-if="users?.data?.crmUsers?.length"
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
inputClass="!bg-surface-gray-2 hover:!bg-surface-gray-3 group-hover:!bg-surface-gray-3"
|
inputClass="!bg-surface-gray-2 hover:!bg-surface-gray-3 group-hover:!bg-surface-gray-3"
|
||||||
:placeholder="__('john@doe.com')"
|
:placeholder="__('john@doe.com')"
|
||||||
v-model="newUsers"
|
v-model="newUsers"
|
||||||
:validate="validateEmail"
|
:validate="validateEmail"
|
||||||
|
:fetchUsers="true"
|
||||||
:existingEmails="[
|
:existingEmails="[
|
||||||
...users.data.crmUsers.map((user) => user.name),
|
...users.data.crmUsers.map((user) => user.name),
|
||||||
'admin@example.com',
|
'admin@example.com',
|
||||||
@ -35,6 +36,7 @@
|
|||||||
:error-message="
|
:error-message="
|
||||||
(value) => __('{0} is an invalid email address', [value])
|
(value) => __('{0} is an invalid email address', [value])
|
||||||
"
|
"
|
||||||
|
:emptyPlaceholder="__('No users found')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<FormControl
|
<FormControl
|
||||||
@ -61,7 +63,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import MultiSelectUserInput from '@/components/Controls/MultiSelectUserInput.vue'
|
import EmailMultiSelect from '@/components/Controls/EmailMultiSelect.vue'
|
||||||
import { validateEmail } from '@/utils'
|
import { validateEmail } from '@/utils'
|
||||||
import { usersStore } from '@/stores/users'
|
import { usersStore } from '@/stores/users'
|
||||||
import { createResource, toast } from 'frappe-ui'
|
import { createResource, toast } from 'frappe-ui'
|
||||||
|
|||||||
@ -31,7 +31,7 @@
|
|||||||
<div
|
<div
|
||||||
class="p-2 group bg-surface-gray-2 hover:bg-surface-gray-3 rounded"
|
class="p-2 group bg-surface-gray-2 hover:bg-surface-gray-3 rounded"
|
||||||
>
|
>
|
||||||
<MultiSelectUserInput
|
<EmailMultiSelect
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
inputClass="!bg-surface-gray-2 hover:!bg-surface-gray-3 group-hover:!bg-surface-gray-3"
|
inputClass="!bg-surface-gray-2 hover:!bg-surface-gray-3 group-hover:!bg-surface-gray-3"
|
||||||
:placeholder="__('john@doe.com')"
|
:placeholder="__('john@doe.com')"
|
||||||
@ -40,7 +40,7 @@
|
|||||||
:error-message="
|
:error-message="
|
||||||
(value) => __('{0} is an invalid email address', [value])
|
(value) => __('{0} is an invalid email address', [value])
|
||||||
"
|
"
|
||||||
:fetchUsers="false"
|
:emptyPlaceholder="__('Type an email address to invite')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@ -100,7 +100,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import MultiSelectUserInput from '@/components/Controls/MultiSelectUserInput.vue'
|
import EmailMultiSelect from '@/components/Controls/EmailMultiSelect.vue'
|
||||||
import { validateEmail, convertArrayToString } from '@/utils'
|
import { validateEmail, convertArrayToString } from '@/utils'
|
||||||
import { usersStore } from '@/stores/users'
|
import { usersStore } from '@/stores/users'
|
||||||
import {
|
import {
|
||||||
|
|||||||
@ -170,7 +170,15 @@ import AddExistingUserModal from '@/components/Modals/AddExistingUserModal.vue'
|
|||||||
import { activeSettingsPage } from '@/composables/settings'
|
import { activeSettingsPage } from '@/composables/settings'
|
||||||
import { usersStore } from '@/stores/users'
|
import { usersStore } from '@/stores/users'
|
||||||
import { TemplateOption, DropdownOption } from '@/utils'
|
import { TemplateOption, DropdownOption } from '@/utils'
|
||||||
import { Avatar, TextInput, toast, call, FeatherIcon, Tooltip } from 'frappe-ui'
|
import {
|
||||||
|
Dropdown,
|
||||||
|
Avatar,
|
||||||
|
TextInput,
|
||||||
|
toast,
|
||||||
|
call,
|
||||||
|
FeatherIcon,
|
||||||
|
Tooltip,
|
||||||
|
} from 'frappe-ui'
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
|
||||||
const { users, isAdmin, isManager } = usersStore()
|
const { users, isAdmin, isManager } = usersStore()
|
||||||
|
|||||||
@ -1,168 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Menu as="div" class="relative inline-block text-left" v-slot="{ open }">
|
|
||||||
<Popover
|
|
||||||
:transition="dropdownTransition"
|
|
||||||
:show="open"
|
|
||||||
:placement="popoverPlacement"
|
|
||||||
>
|
|
||||||
<template #target="{ togglePopover }">
|
|
||||||
<MenuButton as="template">
|
|
||||||
<slot v-if="$slots.default" v-bind="{ open, togglePopover }" />
|
|
||||||
<Button v-else :active="open" v-bind="button">
|
|
||||||
{{ button ? button?.label || null : 'Options' }}
|
|
||||||
</Button>
|
|
||||||
</MenuButton>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #body>
|
|
||||||
<slot name="body" v-bind="{ open, placement }">
|
|
||||||
<div
|
|
||||||
class="mt-2 min-w-40 divide-y divide-outline-gray-modals rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
|
|
||||||
:class="{
|
|
||||||
'mt-2': ['bottom', 'left', 'right'].includes(placement),
|
|
||||||
'ml-2': placement == 'right-start',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<MenuItems
|
|
||||||
class="min-w-40 divide-y divide-outline-gray-modals"
|
|
||||||
:class="{
|
|
||||||
'left-0 origin-top-left': placement == 'left',
|
|
||||||
'right-0 origin-top-right': placement == 'right',
|
|
||||||
'inset-x-0 origin-top': placement == 'center',
|
|
||||||
'mt-0 origin-top-right': placement == 'right-start',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div v-for="group in groups" :key="group.key" class="p-1.5">
|
|
||||||
<div
|
|
||||||
v-if="group.group && !group.hideLabel"
|
|
||||||
class="flex h-7 items-center px-2 text-sm font-medium text-ink-gray-4"
|
|
||||||
>
|
|
||||||
{{ group.group }}
|
|
||||||
</div>
|
|
||||||
<MenuItem
|
|
||||||
v-for="item in group.items"
|
|
||||||
:key="item.label"
|
|
||||||
v-slot="{ active }"
|
|
||||||
>
|
|
||||||
<slot name="item" v-bind="{ item, active }">
|
|
||||||
<component
|
|
||||||
v-if="item.component"
|
|
||||||
:is="item.component"
|
|
||||||
:active="active"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
v-else
|
|
||||||
:class="[
|
|
||||||
active ? 'bg-surface-gray-3' : 'text-ink-gray-6',
|
|
||||||
'group flex h-7 w-full items-center rounded px-2 text-base',
|
|
||||||
]"
|
|
||||||
@click="item.onClick"
|
|
||||||
>
|
|
||||||
<FeatherIcon
|
|
||||||
v-if="item.icon && typeof item.icon === 'string'"
|
|
||||||
:name="item.icon"
|
|
||||||
class="mr-2 h-4 w-4 flex-shrink-0 text-ink-gray-7"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<component
|
|
||||||
class="mr-2 h-4 w-4 flex-shrink-0 text-ink-gray-7"
|
|
||||||
v-else-if="item.icon"
|
|
||||||
:is="item.icon"
|
|
||||||
/>
|
|
||||||
<span class="whitespace-nowrap text-ink-gray-7">
|
|
||||||
{{ item.label }}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</slot>
|
|
||||||
</MenuItem>
|
|
||||||
</div>
|
|
||||||
</MenuItems>
|
|
||||||
<div v-if="slots.footer" class="border-t p-1.5">
|
|
||||||
<slot name="footer"></slot>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</slot>
|
|
||||||
</template>
|
|
||||||
</Popover>
|
|
||||||
</Menu>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
|
|
||||||
import { Popover, Button, FeatherIcon } from 'frappe-ui'
|
|
||||||
import { computed, useSlots } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
button: {
|
|
||||||
type: Object,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
placement: {
|
|
||||||
type: String,
|
|
||||||
default: 'left',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const slots = useSlots()
|
|
||||||
|
|
||||||
const dropdownTransition = {
|
|
||||||
enterActiveClass: 'transition duration-100 ease-out',
|
|
||||||
enterFromClass: 'transform scale-95 opacity-0',
|
|
||||||
enterToClass: 'transform scale-100 opacity-100',
|
|
||||||
leaveActiveClass: 'transition duration-75 ease-in',
|
|
||||||
leaveFromClass: 'transform scale-100 opacity-100',
|
|
||||||
leaveToClass: 'transform scale-95 opacity-0',
|
|
||||||
}
|
|
||||||
|
|
||||||
const groups = computed(() => {
|
|
||||||
let groups = props.options[0]?.group
|
|
||||||
? props.options
|
|
||||||
: [{ group: '', items: props.options }]
|
|
||||||
|
|
||||||
return groups.map((group, i) => {
|
|
||||||
return {
|
|
||||||
key: i,
|
|
||||||
group: group.group,
|
|
||||||
hideLabel: group.hideLabel || false,
|
|
||||||
items: filterOptions(group.items),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const popoverPlacement = computed(() => {
|
|
||||||
if (props.placement === 'left') return 'bottom-start'
|
|
||||||
if (props.placement === 'right') return 'bottom-end'
|
|
||||||
if (props.placement === 'center') return 'bottom-center'
|
|
||||||
if (props.placement === 'right-start') return 'right-start'
|
|
||||||
return 'bottom'
|
|
||||||
})
|
|
||||||
|
|
||||||
function normalizeDropdownItem(option) {
|
|
||||||
let onClick = option.onClick || null
|
|
||||||
if (!onClick && option.route && router) {
|
|
||||||
onClick = () => router.push(option.route)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: option.name,
|
|
||||||
label: option.label,
|
|
||||||
icon: option.icon,
|
|
||||||
group: option.group,
|
|
||||||
component: option.component,
|
|
||||||
onClick,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterOptions(options) {
|
|
||||||
return (options || [])
|
|
||||||
.filter(Boolean)
|
|
||||||
.filter((option) => (option.condition ? option.condition() : true))
|
|
||||||
.map((option) => normalizeDropdownItem(option))
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
Loading…
x
Reference in New Issue
Block a user