iachat/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewGroupForm.vue
Gabriel Jablonski d120c25917
fix(groups): restrict enable CTA to superadmin users (#248)
Non-superadmin users now see a "contact your administrator" message
instead of the "how to enable" action button on group disabled banners.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 13:01:02 -03:00

310 lines
9.4 KiB
Vue

<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { debounce } from '@chatwoot/utils';
import ContactsAPI from 'dashboard/api/contacts';
import wootConstants from 'dashboard/constants/globals';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
const props = defineProps({
inboxes: { type: Array, default: () => [] },
isCreating: { type: Boolean, default: false },
isGroupsDisabled: { type: Boolean, default: false },
isSuperAdmin: { type: Boolean, default: false },
});
const emit = defineEmits(['createGroup', 'discard']);
const { t } = useI18n();
const groupName = ref('');
const selectedInbox = ref(null);
const showInboxDropdown = ref(false);
const participants = ref([]);
const contactResults = ref([]);
const showContactsDropdown = ref(false);
const isSearching = ref(false);
const nameTouched = ref(false);
const participantsTouched = ref(false);
const participantsFocused = ref(false);
const inboxMenuItems = computed(() =>
props.inboxes.map(inbox => ({
label: inbox.name,
value: inbox.id,
action: 'select',
}))
);
const contactMenuItems = computed(() =>
contactResults.value.map(contact => ({
id: contact.id,
label: contact.phone_number
? `${contact.name} (${contact.phone_number})`
: contact.name,
value: contact.id,
action: 'contact',
thumbnail: { name: contact.name, src: contact.thumbnail },
phoneNumber: contact.phone_number,
name: contact.name,
}))
);
const participantTags = computed(() =>
participants.value.map(p => p.name || p.phone_number)
);
const showNameError = computed(
() => nameTouched.value && !groupName.value.trim()
);
const showParticipantsError = computed(
() => participantsTouched.value && participants.value.length === 0
);
const isFormValid = computed(
() =>
selectedInbox.value &&
groupName.value.trim() &&
participants.value.length > 0
);
const searchContacts = debounce(
async query => {
if (!query || query.length < 2) {
contactResults.value = [];
showContactsDropdown.value = false;
return;
}
isSearching.value = true;
try {
const { data } = await ContactsAPI.search(query);
const selectedIds = participants.value.map(p => p.id);
contactResults.value = (data.payload || []).filter(
contact => contact.phone_number && !selectedIds.includes(contact.id)
);
showContactsDropdown.value = contactResults.value.length > 0;
} catch {
contactResults.value = [];
} finally {
isSearching.value = false;
}
},
300,
false
);
const handleInboxAction = item => {
const inbox = props.inboxes.find(i => i.id === item.value);
selectedInbox.value = inbox;
showInboxDropdown.value = false;
};
const clearInbox = () => {
selectedInbox.value = null;
};
const handleAddParticipant = item => {
const contact = contactResults.value.find(c => c.id === item.value);
if (contact) {
participants.value = [...participants.value, contact];
participantsTouched.value = true;
contactResults.value = [];
showContactsDropdown.value = false;
}
};
const handleRemoveParticipant = index => {
participants.value = participants.value.filter((_, i) => i !== index);
participantsTouched.value = true;
};
const handleNameBlur = () => {
nameTouched.value = true;
};
const handleParticipantsFocus = () => {
participantsFocused.value = true;
};
const handleParticipantsBlur = () => {
showContactsDropdown.value = false;
if (participantsFocused.value && participants.value.length === 0) {
participantsTouched.value = true;
}
};
const resetForm = () => {
groupName.value = '';
selectedInbox.value = null;
participants.value = [];
contactResults.value = [];
showContactsDropdown.value = false;
showInboxDropdown.value = false;
nameTouched.value = false;
participantsTouched.value = false;
participantsFocused.value = false;
};
const handleSubmit = () => {
if (!isFormValid.value) return;
emit('createGroup', {
inboxId: selectedInbox.value.id,
subject: groupName.value.trim(),
participants: participants.value.map(p => p.phone_number),
});
};
defineExpose({ resetForm });
</script>
<template>
<div
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0 max-h-[calc(100vh-8rem)]"
>
<div class="flex-1 divide-y divide-n-strong overflow-visible">
<div
v-if="isGroupsDisabled"
class="flex items-center gap-2 mx-4 mt-3 px-3 py-2 rounded-lg text-sm text-n-amber-11 bg-n-amber-2"
>
<span class="i-lucide-triangle-alert text-base flex-shrink-0" />
<span v-if="isSuperAdmin">
{{ t('GROUP.CREATE.GROUPS_DISABLED') }}
<a
:href="wootConstants.FAZER_AI_GUIDES_URL"
target="_blank"
rel="noopener noreferrer"
class="underline font-medium"
>
{{ t('GROUP.CREATE.GROUPS_DISABLED_CTA') }}
</a>
</span>
<span v-else>
{{ t('GROUP.CREATE.GROUPS_DISABLED_NON_ADMIN') }}
</span>
</div>
<div
class="flex items-center flex-1 w-full gap-3 px-4 py-3 overflow-y-visible"
>
<label
class="mb-0.5 text-sm font-medium text-n-slate-11 whitespace-nowrap"
>
{{ t('GROUP.CREATE.INBOX_LABEL') }}
</label>
<div class="relative flex-1 min-w-0">
<div
v-if="selectedInbox"
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 truncate ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 h-7 min-w-0"
>
<span class="text-sm truncate text-n-slate-12">
{{ selectedInbox.name }}
</span>
<Button
variant="ghost"
icon="i-lucide-x"
color="slate"
size="xs"
class="flex-shrink-0"
@click="clearInbox"
/>
</div>
<div v-else class="relative">
<Button
:label="t('GROUP.CREATE.INBOX_PLACEHOLDER')"
variant="link"
size="sm"
color="slate"
class="hover:!no-underline"
@click="showInboxDropdown = !showInboxDropdown"
/>
<DropdownMenu
v-if="showInboxDropdown"
:menu-items="inboxMenuItems"
class="z-[100] top-9 w-full max-h-48 overflow-y-auto dark:!outline-n-slate-5"
@action="handleInboxAction"
/>
</div>
</div>
</div>
<div
class="flex items-start flex-1 w-full gap-3 px-4 py-3 overflow-y-visible"
>
<label
class="mb-0.5 text-sm font-medium whitespace-nowrap mt-1"
:class="showNameError ? 'text-n-ruby-9' : 'text-n-slate-11'"
>
{{ t('GROUP.CREATE.NAME_LABEL') }}
</label>
<div class="flex flex-col flex-1 min-w-0">
<input
v-model="groupName"
type="text"
class="w-full px-2 py-1 text-sm rounded-md bg-transparent text-n-slate-12 placeholder:text-n-slate-10 focus:outline-none border"
:class="showNameError ? 'border-n-ruby-9' : 'border-transparent'"
:placeholder="t('GROUP.CREATE.NAME_PLACEHOLDER')"
@blur="handleNameBlur"
/>
<span v-if="showNameError" class="text-xs text-n-ruby-9 mt-0.5 px-2">
{{ t('GROUP.CREATE.NAME_REQUIRED') }}
</span>
</div>
</div>
<div class="relative flex flex-col gap-1 px-4 py-3">
<label
class="mb-0.5 text-sm font-medium whitespace-nowrap"
:class="showParticipantsError ? 'text-n-ruby-9' : 'text-n-slate-11'"
>
{{ t('GROUP.CREATE.PARTICIPANTS_LABEL') }}
</label>
<TagInput
:model-value="participantTags"
:placeholder="t('GROUP.CREATE.PARTICIPANTS_PLACEHOLDER')"
mode="multiple"
:menu-items="contactMenuItems"
:show-dropdown="showContactsDropdown"
:is-loading="isSearching"
skip-label-dedup
:auto-open-dropdown="false"
:class="showParticipantsError ? '!border-n-ruby-9' : ''"
@input="searchContacts"
@focus="handleParticipantsFocus"
@on-click-outside="handleParticipantsBlur"
@add="handleAddParticipant"
@remove="handleRemoveParticipant"
/>
<span v-if="showParticipantsError" class="text-xs text-n-ruby-9">
{{ t('GROUP.CREATE.PARTICIPANTS_REQUIRED') }}
</span>
</div>
</div>
<div class="flex items-center justify-between gap-2 px-4 py-3">
<div />
<div class="flex items-center gap-2">
<Button
:label="t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.DISCARD')"
variant="faded"
color="slate"
size="sm"
@click="
resetForm();
emit('discard');
"
/>
<Button
:label="t('GROUP.CREATE.SUBMIT_BUTTON')"
color="blue"
size="sm"
:disabled="!isFormValid || isCreating || isGroupsDisabled"
:is-loading="isCreating"
@click="handleSubmit"
/>
</div>
</div>
</div>
</template>