mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-23 19:06:35 +00:00
Merge pull request #100 from abhinavxd/feat/allow-macro-in-new-conversations
Feat: Allow setting macro in new conversations along with attachments
This commit is contained in:
+56
-37
@@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
|
||||
@@ -11,6 +10,7 @@ import (
|
||||
"github.com/abhinavxd/libredesk/internal/automation/models"
|
||||
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
|
||||
"github.com/abhinavxd/libredesk/internal/envelope"
|
||||
medModels "github.com/abhinavxd/libredesk/internal/media/models"
|
||||
"github.com/abhinavxd/libredesk/internal/stringutil"
|
||||
umodels "github.com/abhinavxd/libredesk/internal/user/models"
|
||||
"github.com/valyala/fasthttp"
|
||||
@@ -18,6 +18,18 @@ import (
|
||||
"github.com/zerodha/fastglue"
|
||||
)
|
||||
|
||||
type createConversationRequest struct {
|
||||
InboxID int `json:"inbox_id" form:"inbox_id"`
|
||||
AssignedAgentID int `json:"agent_id" form:"agent_id"`
|
||||
AssignedTeamID int `json:"team_id" form:"team_id"`
|
||||
Email string `json:"contact_email" form:"contact_email"`
|
||||
FirstName string `json:"first_name" form:"first_name"`
|
||||
LastName string `json:"last_name" form:"last_name"`
|
||||
Subject string `json:"subject" form:"subject"`
|
||||
Content string `json:"content" form:"content"`
|
||||
Attachments []int `json:"attachments" form:"attachments"`
|
||||
}
|
||||
|
||||
// handleGetAllConversations retrieves all conversations.
|
||||
func handleGetAllConversations(r *fastglue.Request) error {
|
||||
var (
|
||||
@@ -632,36 +644,32 @@ func filterCurrentConv(convs []cmodels.Conversation, uuid string) []cmodels.Conv
|
||||
// handleCreateConversation creates a new conversation and sends a message to it.
|
||||
func handleCreateConversation(r *fastglue.Request) error {
|
||||
var (
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
inboxID = r.RequestCtx.PostArgs().GetUintOrZero("inbox_id")
|
||||
assignedAgentID = r.RequestCtx.PostArgs().GetUintOrZero("agent_id")
|
||||
assignedTeamID = r.RequestCtx.PostArgs().GetUintOrZero("team_id")
|
||||
email = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("contact_email")))
|
||||
firstName = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("first_name")))
|
||||
lastName = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("last_name")))
|
||||
subject = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("subject")))
|
||||
content = strings.TrimSpace(string(r.RequestCtx.PostArgs().Peek("content")))
|
||||
to = []string{email}
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
req = createConversationRequest{}
|
||||
)
|
||||
|
||||
if err := r.Decode(&req, "json"); err != nil {
|
||||
app.lo.Error("error decoding create conversation request", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.request}"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
to := []string{req.Email}
|
||||
|
||||
// Validate required fields
|
||||
if inboxID <= 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`inbox_id`"), nil, envelope.InputError)
|
||||
if req.InboxID <= 0 {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`inbox_id`"), nil, envelope.InputError)
|
||||
}
|
||||
if subject == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`subject`"), nil, envelope.InputError)
|
||||
if req.Content == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`content`"), nil, envelope.InputError)
|
||||
}
|
||||
if content == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`content`"), nil, envelope.InputError)
|
||||
if req.Email == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`contact_email`"), nil, envelope.InputError)
|
||||
}
|
||||
if email == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`contact_email`"), nil, envelope.InputError)
|
||||
if req.FirstName == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.required", "name", "`first_name`"), nil, envelope.InputError)
|
||||
}
|
||||
if firstName == "" {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.fieldRequired", "name", "`first_name`"), nil, envelope.InputError)
|
||||
}
|
||||
if !stringutil.ValidEmail(email) {
|
||||
if !stringutil.ValidEmail(req.Email) {
|
||||
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.invalid", "name", "`contact_email`"), nil, envelope.InputError)
|
||||
}
|
||||
|
||||
@@ -671,7 +679,7 @@ func handleCreateConversation(r *fastglue.Request) error {
|
||||
}
|
||||
|
||||
// Check if inbox exists and is enabled.
|
||||
inbox, err := app.inbox.GetDBRecord(inboxID)
|
||||
inbox, err := app.inbox.GetDBRecord(req.InboxID)
|
||||
if err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
@@ -681,11 +689,11 @@ func handleCreateConversation(r *fastglue.Request) error {
|
||||
|
||||
// Find or create contact.
|
||||
contact := umodels.User{
|
||||
Email: null.StringFrom(email),
|
||||
SourceChannelID: null.StringFrom(email),
|
||||
FirstName: firstName,
|
||||
LastName: lastName,
|
||||
InboxID: inboxID,
|
||||
Email: null.StringFrom(req.Email),
|
||||
SourceChannelID: null.StringFrom(req.Email),
|
||||
FirstName: req.FirstName,
|
||||
LastName: req.LastName,
|
||||
InboxID: req.InboxID,
|
||||
}
|
||||
if err := app.user.CreateContact(&contact); err != nil {
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.contact}"), nil))
|
||||
@@ -695,10 +703,10 @@ func handleCreateConversation(r *fastglue.Request) error {
|
||||
conversationID, conversationUUID, err := app.conversation.CreateConversation(
|
||||
contact.ID,
|
||||
contact.ContactChannelID,
|
||||
inboxID,
|
||||
req.InboxID,
|
||||
"", /** last_message **/
|
||||
time.Now(), /** last_message_at **/
|
||||
subject,
|
||||
req.Subject,
|
||||
true, /** append reference number to subject **/
|
||||
)
|
||||
if err != nil {
|
||||
@@ -706,8 +714,19 @@ func handleCreateConversation(r *fastglue.Request) error {
|
||||
return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.conversation}"), nil))
|
||||
}
|
||||
|
||||
// Prepare attachments.
|
||||
var media = make([]medModels.Media, 0, len(req.Attachments))
|
||||
for _, id := range req.Attachments {
|
||||
m, err := app.media.Get(id, "")
|
||||
if err != nil {
|
||||
app.lo.Error("error fetching media", "error", err)
|
||||
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.media}"), nil, envelope.GeneralError)
|
||||
}
|
||||
media = append(media, m)
|
||||
}
|
||||
|
||||
// Send reply to the created conversation.
|
||||
if err := app.conversation.SendReply(nil /**media**/, inboxID, auser.ID /**sender_id**/, conversationUUID, content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
|
||||
if err := app.conversation.SendReply(media, req.InboxID, auser.ID /**sender_id**/, conversationUUID, req.Content, to, nil /**cc**/, nil /**bcc**/, map[string]any{} /**meta**/); err != nil {
|
||||
// Delete the conversation if reply fails.
|
||||
if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
|
||||
app.lo.Error("error deleting conversation", "error", err)
|
||||
@@ -716,11 +735,11 @@ func handleCreateConversation(r *fastglue.Request) error {
|
||||
}
|
||||
|
||||
// Assign the conversation to the agent or team.
|
||||
if assignedAgentID > 0 {
|
||||
app.conversation.UpdateConversationUserAssignee(conversationUUID, assignedAgentID, user)
|
||||
if req.AssignedAgentID > 0 {
|
||||
app.conversation.UpdateConversationUserAssignee(conversationUUID, req.AssignedAgentID, user)
|
||||
}
|
||||
if assignedTeamID > 0 {
|
||||
app.conversation.UpdateConversationTeamAssignee(conversationUUID, assignedTeamID, user)
|
||||
if req.AssignedTeamID > 0 {
|
||||
app.conversation.UpdateConversationTeamAssignee(conversationUUID, req.AssignedTeamID, user)
|
||||
}
|
||||
|
||||
// Send the created conversation back to the client.
|
||||
|
||||
+7
-4
@@ -81,8 +81,7 @@ func handleCreateMacro(r *fastglue.Request) error {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
err := app.macro.Create(macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.Actions)
|
||||
if err != nil {
|
||||
if err := app.macro.Create(macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.VisibleWhen, macro.Actions); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
@@ -110,7 +109,7 @@ func handleUpdateMacro(r *fastglue.Request) error {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
if err = app.macro.Update(id, macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.Actions); err != nil {
|
||||
if err = app.macro.Update(id, macro.Name, macro.MessageContent, macro.UserID, macro.TeamID, macro.Visibility, macro.VisibleWhen, macro.Actions); err != nil {
|
||||
return sendErrorEnvelope(r, err)
|
||||
}
|
||||
|
||||
@@ -275,13 +274,17 @@ func validateMacro(app *App, macro models.Macro) error {
|
||||
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil)
|
||||
}
|
||||
|
||||
if len(macro.VisibleWhen) == 0 {
|
||||
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`visible_when`"), nil)
|
||||
}
|
||||
|
||||
var act []autoModels.RuleAction
|
||||
if err := json.Unmarshal(macro.Actions, &act); err != nil {
|
||||
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.errorParsing", "name", "{globals.terms.macroAction}"), nil)
|
||||
}
|
||||
for _, a := range act {
|
||||
if len(a.Value) == 0 {
|
||||
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.emptyActionValue", "name", a.Type), nil)
|
||||
return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", a.Type), nil)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
+1
-1
@@ -132,7 +132,6 @@ func handleSendMessage(r *fastglue.Request) error {
|
||||
app = r.Context.(*App)
|
||||
auser = r.RequestCtx.UserValue("user").(amodels.User)
|
||||
cuuid = r.RequestCtx.UserValue("cuuid").(string)
|
||||
media = []medModels.Media{}
|
||||
req = messageReq{}
|
||||
)
|
||||
|
||||
@@ -153,6 +152,7 @@ func handleSendMessage(r *fastglue.Request) error {
|
||||
}
|
||||
|
||||
// Prepare attachments.
|
||||
var media = make([]medModels.Media, 0, len(req.Attachments))
|
||||
for _, id := range req.Attachments {
|
||||
m, err := app.media.Get(id, "")
|
||||
if err != nil {
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
<Command />
|
||||
|
||||
<!-- Create conversation dialog -->
|
||||
<CreateConversation v-model="openCreateConversationDialog" />
|
||||
<CreateConversation v-model="openCreateConversationDialog" v-if="openCreateConversationDialog" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
@@ -231,7 +231,11 @@ const updateConversationCustomAttribute = (uuid, data) => http.put(`/api/v1/conv
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const createConversation = (data) => http.post('/api/v1/conversations', data)
|
||||
const createConversation = (data) => http.post('/api/v1/conversations', data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
const updateConversationStatus = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/status`, data)
|
||||
const updateConversationPriority = (uuid, data) => http.put(`/api/v1/conversations/${uuid}/priority`, data)
|
||||
const updateAssigneeLastSeen = (uuid) => http.put(`/api/v1/conversations/${uuid}/last-seen`)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click.prevent="onClose"
|
||||
size="xs"
|
||||
class="text-gray-400 dark:text-gray-500 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 w-6 h-6 p-0"
|
||||
>
|
||||
<slot>
|
||||
<X size="16" />
|
||||
</slot>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { X } from 'lucide-vue-next'
|
||||
|
||||
defineProps({
|
||||
onClose: {
|
||||
type: Function,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<ComboBox
|
||||
:model-value="normalizedValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
:items="items"
|
||||
:placeholder="placeholder"
|
||||
>
|
||||
<!-- Items -->
|
||||
<template #item="{ item }">
|
||||
<div class="flex items-center gap-2">
|
||||
<!--USER -->
|
||||
<Avatar v-if="type === 'user'" class="w-7 h-7">
|
||||
<AvatarImage :src="item.avatar_url || ''" :alt="item.label.slice(0, 2)" />
|
||||
<AvatarFallback>{{ item.label.slice(0, 2).toUpperCase() }}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<!-- Others -->
|
||||
<span v-else-if="item.emoji">{{ item.emoji }}</span>
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Selected -->
|
||||
<template #selected="{ selected }">
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="selected" class="flex items-center gap-2">
|
||||
<!--USER -->
|
||||
<Avatar v-if="type === 'user'" class="w-7 h-7">
|
||||
<AvatarImage :src="selected.avatar_url || ''" :alt="selected.label.slice(0, 2)" />
|
||||
<AvatarFallback>{{ selected.label.slice(0, 2).toUpperCase() }}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<!-- Others -->
|
||||
<span v-else-if="selected.emoji">{{ selected.emoji }}</span>
|
||||
<span>{{ selected.label }}</span>
|
||||
</div>
|
||||
<span v-else>{{ placeholder }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</ComboBox>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: [String, Number, Object],
|
||||
placeholder: String,
|
||||
items: Array,
|
||||
type: {
|
||||
type: String
|
||||
}
|
||||
})
|
||||
|
||||
// Convert to str.
|
||||
const normalizedValue = computed(() => String(props.modelValue || ''))
|
||||
|
||||
defineEmits(['update:modelValue'])
|
||||
</script>
|
||||
+10
-6
@@ -91,7 +91,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, watchEffect, onUnmounted } from 'vue'
|
||||
import { ref, watch, watchEffect, onUnmounted, computed } from 'vue'
|
||||
import { useEditor, EditorContent, BubbleMenu } from '@tiptap/vue-3'
|
||||
import {
|
||||
ChevronDown,
|
||||
@@ -136,6 +136,10 @@ const props = defineProps({
|
||||
setInlineImage: Object,
|
||||
insertContent: String,
|
||||
clearContent: Boolean,
|
||||
autoFocus: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
aiPrompts: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
@@ -188,7 +192,7 @@ const CustomTableHeader = TableHeader.extend({
|
||||
}
|
||||
})
|
||||
|
||||
const editorConfig = {
|
||||
const editorConfig = computed(() => ({
|
||||
extensions: [
|
||||
StarterKit.configure(),
|
||||
Image.configure({ HTMLAttributes: { class: 'inline-image' } }),
|
||||
@@ -201,7 +205,7 @@ const editorConfig = {
|
||||
CustomTableCell,
|
||||
CustomTableHeader
|
||||
],
|
||||
autofocus: true,
|
||||
autofocus: props.autoFocus,
|
||||
editorProps: {
|
||||
attributes: { class: 'outline-none' },
|
||||
handleKeyDown: (view, event) => {
|
||||
@@ -210,17 +214,17 @@ const editorConfig = {
|
||||
return true
|
||||
}
|
||||
if (event.ctrlKey && event.key.toLowerCase() === 'b') {
|
||||
// Prevent outer listeners
|
||||
// Prevent outer listeners
|
||||
event.stopPropagation()
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const editor = ref(
|
||||
useEditor({
|
||||
...editorConfig,
|
||||
...editorConfig.value,
|
||||
content: htmlContent.value,
|
||||
onSelectionUpdate: ({ editor }) => {
|
||||
const { from, to } = editor.state.selection
|
||||
@@ -44,79 +44,47 @@
|
||||
<div class="flex-1">
|
||||
<div v-if="modelFilter.field && modelFilter.operator">
|
||||
<template v-if="modelFilter.operator !== 'set' && modelFilter.operator !== 'not set'">
|
||||
<ComboBox
|
||||
v-if="getFieldOptions(modelFilter).length > 0"
|
||||
<SelectComboBox
|
||||
v-if="
|
||||
getFieldOptions(modelFilter).length > 0 &&
|
||||
modelFilter.field === 'assigned_user_id'
|
||||
"
|
||||
v-model="modelFilter.value"
|
||||
:items="getFieldOptions(modelFilter)"
|
||||
:placeholder="t('form.field.select')"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div v-if="modelFilter.field === 'assigned_user_id'">
|
||||
<div class="flex items-center gap-1">
|
||||
<Avatar class="w-6 h-6">
|
||||
<AvatarImage :src="item.avatar_url || ''" :alt="item.label.slice(0, 2)" />
|
||||
<AvatarFallback>{{ item.label.slice(0, 2).toUpperCase() }} </AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="modelFilter.field === 'assigned_team_id'">
|
||||
<div class="flex items-center gap-2 ml-2">
|
||||
<span>{{ item.emoji }}</span>
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ item.label }}
|
||||
</div>
|
||||
</template>
|
||||
type="user"
|
||||
/>
|
||||
|
||||
<SelectComboBox
|
||||
v-else-if="
|
||||
getFieldOptions(modelFilter).length > 0 &&
|
||||
modelFilter.field === 'assigned_team_id'
|
||||
"
|
||||
v-model="modelFilter.value"
|
||||
:items="getFieldOptions(modelFilter)"
|
||||
:placeholder="t('form.field.select')"
|
||||
type="team"
|
||||
/>
|
||||
|
||||
<SelectComboBox
|
||||
v-else-if="getFieldOptions(modelFilter).length > 0"
|
||||
v-model="modelFilter.value"
|
||||
:items="getFieldOptions(modelFilter)"
|
||||
:placeholder="t('form.field.select')"
|
||||
/>
|
||||
|
||||
<template #selected="{ selected }">
|
||||
<div v-if="!selected">{{ $t('form.field.selectValue') }}</div>
|
||||
<div v-if="modelFilter.field === 'assigned_user_id'">
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="selected" class="flex items-center gap-1">
|
||||
<Avatar class="w-6 h-6">
|
||||
<AvatarImage
|
||||
:src="selected.avatar_url || ''"
|
||||
:alt="selected.label.slice(0, 2)"
|
||||
/>
|
||||
<AvatarFallback>{{
|
||||
selected.label.slice(0, 2).toUpperCase()
|
||||
}}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{{ selected.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="modelFilter.field === 'assigned_team_id'">
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="selected">
|
||||
{{ selected.emoji }}
|
||||
<span>{{ selected.label }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="selected">
|
||||
{{ selected.label }}
|
||||
</div>
|
||||
</template>
|
||||
</ComboBox>
|
||||
<Input
|
||||
v-else
|
||||
v-model="modelFilter.value"
|
||||
class="bg-transparent hover:bg-slate-100"
|
||||
:placeholder="t('form.field.value')"
|
||||
:placeholder="t('globals.terms.value')"
|
||||
type="text"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="removeFilter(index)" class="p-1 hover:bg-slate-100 rounded">
|
||||
<X class="w-4 h-4 text-slate-500" />
|
||||
</button>
|
||||
<CloseButton :onClose="() => removeFilter(index)" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-3">
|
||||
@@ -146,12 +114,12 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { Plus, X } from 'lucide-vue-next'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
|
||||
import CloseButton from '@/components/button/CloseButton.vue'
|
||||
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
|
||||
|
||||
const props = defineProps({
|
||||
fields: {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { ref, readonly } from 'vue'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import api from '@/api'
|
||||
|
||||
/**
|
||||
* Composable for handling file uploads
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {Function} options.onFileUploadSuccess - Callback when file upload succeeds (uploadedFile)
|
||||
* @param {Function} options.onUploadError - Optional callback when file upload fails (file, error)
|
||||
* @param {string} options.linkedModel - The linked model for the upload
|
||||
* @param {Array} options.mediaFiles - Optional external array to manage files (if not provided, internal array is used)
|
||||
*/
|
||||
export function useFileUpload (options = {}) {
|
||||
const {
|
||||
onFileUploadSuccess,
|
||||
onUploadError,
|
||||
linkedModel,
|
||||
mediaFiles: externalMediaFiles
|
||||
} = options
|
||||
|
||||
const emitter = useEmitter()
|
||||
const uploadingFiles = ref([])
|
||||
const isUploading = ref(false)
|
||||
const internalMediaFiles = ref([])
|
||||
|
||||
// Use external mediaFiles if provided, otherwise use internal
|
||||
const mediaFiles = externalMediaFiles || internalMediaFiles
|
||||
|
||||
/**
|
||||
* Handles the file upload process when files are selected.
|
||||
* Uploads each file to the server and adds them to the mediaFiles array.
|
||||
* @param {Event} event - The file input change event containing selected files
|
||||
*/
|
||||
const handleFileUpload = (event) => {
|
||||
const files = Array.from(event.target.files)
|
||||
uploadingFiles.value = files
|
||||
isUploading.value = true
|
||||
|
||||
for (const file of files) {
|
||||
api
|
||||
.uploadMedia({
|
||||
files: file,
|
||||
inline: false,
|
||||
linked_model: linkedModel
|
||||
})
|
||||
.then((resp) => {
|
||||
const uploadedFile = resp.data.data
|
||||
|
||||
// Add to media files array
|
||||
if (Array.isArray(mediaFiles.value)) {
|
||||
mediaFiles.value.push(uploadedFile)
|
||||
} else {
|
||||
mediaFiles.push(uploadedFile)
|
||||
}
|
||||
|
||||
// Remove from uploading list
|
||||
uploadingFiles.value = uploadingFiles.value.filter((f) => f.name !== file.name)
|
||||
|
||||
// Call success callback
|
||||
if (onFileUploadSuccess) {
|
||||
onFileUploadSuccess(uploadedFile)
|
||||
}
|
||||
|
||||
// Update uploading state
|
||||
if (uploadingFiles.value.length === 0) {
|
||||
isUploading.value = false
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
uploadingFiles.value = uploadingFiles.value.filter((f) => f.name !== file.name)
|
||||
|
||||
// Call error callback or show default toast
|
||||
if (onUploadError) {
|
||||
onUploadError(file, error)
|
||||
} else {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
}
|
||||
|
||||
// Update uploading state
|
||||
if (uploadingFiles.value.length === 0) {
|
||||
isUploading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the file delete event.
|
||||
* Removes the file from the mediaFiles array.
|
||||
* @param {String} uuid - The UUID of the file to delete
|
||||
*/
|
||||
const handleFileDelete = (uuid) => {
|
||||
if (Array.isArray(mediaFiles.value)) {
|
||||
mediaFiles.value = [
|
||||
...mediaFiles.value.filter((item) => item.uuid !== uuid)
|
||||
]
|
||||
} else {
|
||||
const index = mediaFiles.findIndex((item) => item.uuid === uuid)
|
||||
if (index > -1) {
|
||||
mediaFiles.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload files programmatically (without event)
|
||||
* @param {File[]} files - Array of files to upload
|
||||
*/
|
||||
const uploadFiles = (files) => {
|
||||
const mockEvent = { target: { files } }
|
||||
handleFileUpload(mockEvent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all media files
|
||||
*/
|
||||
const clearMediaFiles = () => {
|
||||
if (Array.isArray(mediaFiles.value)) {
|
||||
mediaFiles.value = []
|
||||
} else {
|
||||
mediaFiles.length = 0
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
uploadingFiles: readonly(uploadingFiles),
|
||||
isUploading: readonly(isUploading),
|
||||
mediaFiles: externalMediaFiles ? readonly(mediaFiles) : readonly(internalMediaFiles),
|
||||
|
||||
// Methods
|
||||
handleFileUpload,
|
||||
handleFileDelete,
|
||||
uploadFiles,
|
||||
clearMediaFiles
|
||||
}
|
||||
}
|
||||
@@ -48,63 +48,17 @@
|
||||
class="w-48"
|
||||
v-if="action.type && conversationActions[action.type]?.type === 'select'"
|
||||
>
|
||||
<ComboBox
|
||||
<SelectComboBox
|
||||
v-model="action.value[0]"
|
||||
:items="conversationActions[action.type]?.options"
|
||||
:placeholder="t('form.field.select')"
|
||||
@select="handleValueChange($event, index)"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div class="flex items-center gap-2 ml-2">
|
||||
<Avatar v-if="action.type === 'assign_user'" class="w-7 h-7">
|
||||
<AvatarImage :src="item.avatar_url || ''" :alt="item.label.slice(0, 2)" />
|
||||
<AvatarFallback>
|
||||
{{ item.label.slice(0, 2).toUpperCase() }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span v-if="action.type === 'assign_team'">
|
||||
{{ item.emoji }}
|
||||
</span>
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #selected="{ selected }">
|
||||
<div v-if="action.type === 'assign_team'">
|
||||
<div v-if="selected" class="flex items-center gap-2">
|
||||
{{ selected.emoji }}
|
||||
<span>{{ selected.label }}</span>
|
||||
</div>
|
||||
<span v-else>{{ $t('form.field.selectTeam') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="action.type === 'assign_user'" class="flex items-center gap-2">
|
||||
<div v-if="selected" class="flex items-center gap-2">
|
||||
<Avatar class="w-7 h-7">
|
||||
<AvatarImage
|
||||
:src="selected.avatar_url ?? ''"
|
||||
:alt="selected.label.slice(0, 2)"
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{{ selected.label.slice(0, 2).toUpperCase() }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{{ selected.label }}</span>
|
||||
</div>
|
||||
<span v-else>{{ $t('form.field.selectUser') }}</span>
|
||||
</div>
|
||||
<span v-else>
|
||||
<span v-if="!selected"> {{ $t('form.field.select') }}</span>
|
||||
<span v-else>{{ selected.label }} </span>
|
||||
</span>
|
||||
</template>
|
||||
</ComboBox>
|
||||
:type="action.type === 'assign_team' ? 'team' : 'user'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cursor-pointer" @click.prevent="removeAction(index)">
|
||||
<X size="16" />
|
||||
</div>
|
||||
<CloseButton :onClose="() => removeAction(index)" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -114,7 +68,7 @@
|
||||
<Editor
|
||||
v-model:htmlContent="action.value[0]"
|
||||
@update:htmlContent="(value) => handleEditorChange(value, index)"
|
||||
:placeholder="t('editor.placeholder')"
|
||||
:placeholder="t('editor.newLine') + t('editor.send') + t('editor.cmdK')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -133,7 +87,7 @@
|
||||
<script setup>
|
||||
import { toRefs } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import CloseButton from '@/components/button/CloseButton.vue'
|
||||
import { useTagStore } from '@/stores/tag'
|
||||
import {
|
||||
Select,
|
||||
@@ -143,13 +97,12 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { SelectTag } from '@/components/ui/select'
|
||||
import { useConversationFilters } from '@/composables/useConversationFilters'
|
||||
import { getTextFromHTML } from '@/utils/strings.js'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Editor from '@/features/conversation/ConversationTextEditor.vue'
|
||||
import Editor from '@/components/editor/TextEditor.vue'
|
||||
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
|
||||
|
||||
const props = defineProps({
|
||||
actions: {
|
||||
|
||||
@@ -102,59 +102,12 @@
|
||||
|
||||
<!-- Select input -->
|
||||
<div v-if="inputType(index) === 'select'">
|
||||
<ComboBox
|
||||
<SelectComboBox
|
||||
v-model="rule.value"
|
||||
:items="getFieldOptions(rule.field, rule.field_type)"
|
||||
@select="handleValueChange($event, index)"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div class="flex items-center gap-2 ml-2">
|
||||
<Avatar v-if="rule.field === 'assigned_user'" class="w-7 h-7">
|
||||
<AvatarImage :src="item.avatar_url || ''" :alt="item.label.slice(0, 2)" />
|
||||
<AvatarFallback>
|
||||
{{ item.label.slice(0, 2).toUpperCase() }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span v-if="rule.field === 'assigned_team'">
|
||||
{{ item.emoji }}
|
||||
</span>
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #selected="{ selected }">
|
||||
<div v-if="rule?.field === 'assigned_team'">
|
||||
<div v-if="selected" class="flex items-center gap-2">
|
||||
{{ selected.emoji }}
|
||||
<span>{{ selected.label }}</span>
|
||||
</div>
|
||||
<span v-else>{{ $t('form.field.selectTeam') }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="rule?.field === 'assigned_user'"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<div v-if="selected" class="flex items-center gap-2">
|
||||
<Avatar class="w-7 h-7">
|
||||
<AvatarImage
|
||||
:src="selected.avatar_url || ''"
|
||||
:alt="selected.label.slice(0, 2)"
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{{ selected.label.slice(0, 2).toUpperCase() }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{{ selected.label }}</span>
|
||||
</div>
|
||||
<span v-else>{{ $t('form.field.selectUser') }}</span>
|
||||
</div>
|
||||
<span v-else>
|
||||
<span v-if="!selected"> {{ $t('form.field.select') }}</span>
|
||||
<span v-else>{{ selected.label }} </span>
|
||||
</span>
|
||||
</template>
|
||||
</ComboBox>
|
||||
:type="rule.field === 'assigned_user' ? 'user' : 'team'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tag input -->
|
||||
@@ -209,9 +162,7 @@
|
||||
<div v-else class="flex-1"></div>
|
||||
|
||||
<!-- Remove condition -->
|
||||
<div class="cursor-pointer mt-2" @click.prevent="removeCondition(index)">
|
||||
<X size="16" />
|
||||
</div>
|
||||
<CloseButton :onClose="() => removeCondition(index)" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
@@ -242,6 +193,7 @@ import { toRefs, computed, watch } from 'vue'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import CloseButton from '@/components/button/CloseButton.vue'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -258,13 +210,11 @@ import {
|
||||
TagsInputItemDelete,
|
||||
TagsInputItemText
|
||||
} from '@/components/ui/tags-input'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useConversationFilters } from '@/composables/useConversationFilters'
|
||||
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
|
||||
|
||||
const props = defineProps({
|
||||
ruleGroup: {
|
||||
|
||||
@@ -23,6 +23,21 @@
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isLoading && rules.length === 0"
|
||||
class="flex flex-col items-center justify-center py-12 px-4"
|
||||
>
|
||||
<div class="text-center space-y-2">
|
||||
<p class="text-muted-foreground">
|
||||
{{
|
||||
$t('globals.messages.noResults', {
|
||||
name: $t('globals.terms.rule', 2).toLowerCase()
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div v-if="type === 'new_conversation'">
|
||||
<draggable v-model="rules" class="space-y-5" item-key="id" @end="onDragEnd">
|
||||
|
||||
@@ -1,125 +1,136 @@
|
||||
<template>
|
||||
<div class="space-y-5 rounded">
|
||||
<div class="space-y-5">
|
||||
<div v-for="(action, index) in model" :key="index" class="space-y-5">
|
||||
<hr v-if="index" class="border-t-2 border-dotted border-gray-300" />
|
||||
<div class="space-y-6">
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-if="!model.length"
|
||||
class="text-center py-12 px-6 border-2 border-dashed border-muted rounded-lg"
|
||||
>
|
||||
<div class="mx-auto w-12 h-12 bg-muted rounded-full flex items-center justify-center mb-3">
|
||||
<Plus class="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 class="text-sm font-medium text-foreground mb-2">
|
||||
{{ $t('globals.messages.no', { name: $t('globals.terms.action', 2).toLowerCase() }) }}
|
||||
</h3>
|
||||
<Button
|
||||
@click.prevent="add"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="inline-flex items-center gap-2"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
{{ config.addButtonText }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex gap-5">
|
||||
<div class="w-48">
|
||||
<Select
|
||||
v-model="action.type"
|
||||
@update:modelValue="(value) => updateField(value, index)"
|
||||
<!-- Actions List -->
|
||||
<div v-else class="space-y-6">
|
||||
<div v-for="(action, index) in model" :key="index" class="relative">
|
||||
<!-- Action Card -->
|
||||
<div class="border rounded p-6 shadow-sm hover:shadow-md transition-shadow">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 space-y-4">
|
||||
<!-- Action Type Selection -->
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<div class="flex-1 max-w-xs">
|
||||
<label class="block text-sm font-medium mb-2">{{
|
||||
$t('globals.messages.type', {
|
||||
name: $t('globals.terms.action')
|
||||
})
|
||||
}}</label>
|
||||
<Select
|
||||
v-model="action.type"
|
||||
@update:modelValue="(value) => updateField(value, index)"
|
||||
>
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue :placeholder="config.typePlaceholder" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem
|
||||
v-for="(actionConfig, key) in config.actions"
|
||||
:key="key"
|
||||
:value="key"
|
||||
>
|
||||
{{ actionConfig.label }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Value Selection -->
|
||||
<div
|
||||
v-if="action.type && config.actions[action.type]?.type === 'select'"
|
||||
class="flex-1 max-w-xs"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue :placeholder="config.typePlaceholder" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem
|
||||
v-for="(actionConfig, key) in config.actions"
|
||||
:key="key"
|
||||
:value="key"
|
||||
>
|
||||
{{ actionConfig.label }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<label class="block text-sm font-medium mb-2">Value</label>
|
||||
|
||||
<SelectComboBox
|
||||
v-if="action.type === 'assign_user'"
|
||||
v-model="action.value[0]"
|
||||
:items="config.actions[action.type].options"
|
||||
:placeholder="config.valuePlaceholder"
|
||||
@update:modelValue="(value) => updateValue(value, index)"
|
||||
type="user"
|
||||
/>
|
||||
|
||||
<SelectComboBox
|
||||
v-else-if="action.type === 'assign_team'"
|
||||
v-model="action.value[0]"
|
||||
:items="config.actions[action.type].options"
|
||||
:placeholder="config.valuePlaceholder"
|
||||
@update:modelValue="(value) => updateValue(value, index)"
|
||||
type="team"
|
||||
/>
|
||||
<SelectComboBox
|
||||
v-else
|
||||
v-model="action.value[0]"
|
||||
:items="config.actions[action.type].options"
|
||||
:placeholder="config.valuePlaceholder"
|
||||
@update:modelValue="(value) => updateValue(value, index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tag Selection -->
|
||||
<div
|
||||
v-if="action.type && config.actions[action.type]?.type === 'select'"
|
||||
class="w-48"
|
||||
v-if="action.type && config.actions[action.type]?.type === 'tag'"
|
||||
class="max-w-md"
|
||||
>
|
||||
<ComboBox
|
||||
v-model="action.value[0]"
|
||||
:items="config.actions[action.type].options"
|
||||
:placeholder="config.valuePlaceholder"
|
||||
@update:modelValue="(value) => updateValue(value, index)"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div v-if="action.type === 'assign_user'">
|
||||
<div class="flex items-center flex-1 gap-2 ml-2">
|
||||
<Avatar class="w-7 h-7">
|
||||
<AvatarImage :src="item.avatar_url || ''" :alt="item.label.slice(0, 2)" />
|
||||
<AvatarFallback
|
||||
>{{ item.label.slice(0, 2).toUpperCase() }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="action.type === 'assign_team'">
|
||||
<div class="flex items-center gap-2 ml-2">
|
||||
<span>{{ item.emoji }}</span>
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ item.label }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #selected="{ selected }">
|
||||
<div v-if="action.type === 'assign_user'">
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="selected" class="flex items-center gap-2">
|
||||
<Avatar class="w-7 h-7">
|
||||
<AvatarImage
|
||||
:src="selected.avatar_url || ''"
|
||||
:alt="selected.label.slice(0, 2)"
|
||||
/>
|
||||
<AvatarFallback>{{
|
||||
selected.label.slice(0, 2).toUpperCase()
|
||||
}}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{{ selected.label }}</span>
|
||||
</div>
|
||||
<span v-else>{{ $t('form.field.selectUser') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="action.type === 'assign_team'">
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="selected">
|
||||
{{ selected.emoji }}
|
||||
<span>{{ selected.label }}</span>
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t('form.field.selectTeam') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="selected">
|
||||
{{ selected.label }}
|
||||
</div>
|
||||
<div v-else>{{ $t('form.field.select') }}</div>
|
||||
</template>
|
||||
</ComboBox>
|
||||
<label class="block text-sm font-medium mb-2">{{ $t('globals.terms.tag') }}</label>
|
||||
<SelectTag
|
||||
v-model="action.value"
|
||||
:items="tagsStore.tagNames.map((tag) => ({ label: tag, value: tag }))"
|
||||
placeholder="Select tags"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<X class="cursor-pointer w-4" @click="remove(index)" />
|
||||
</div>
|
||||
|
||||
<div v-if="action.type && config.actions[action.type]?.type === 'tag'">
|
||||
<SelectTag
|
||||
v-model="action.value"
|
||||
:items="tagsStore.tagNames.map((tag) => ({ label: tag, value: tag }))"
|
||||
placeholder="Select tag"
|
||||
/>
|
||||
<!-- Remove Button -->
|
||||
<CloseButton :onClose="() => remove(index)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Action Button -->
|
||||
<div class="flex justify-center pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@click="add"
|
||||
class="inline-flex items-center gap-2 border-dashed hover:border-solid"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
{{ config.addButtonText }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" variant="outline" @click.prevent="add">{{ config.addButtonText }}</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -128,10 +139,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||
import CloseButton from '@/components/button/CloseButton.vue'
|
||||
import { SelectTag } from '@/components/ui/select'
|
||||
import { useTagStore } from '@/stores/tag'
|
||||
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
|
||||
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
|
||||
|
||||
const model = defineModel('actions', {
|
||||
type: Array,
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<Editor
|
||||
v-model:htmlContent="componentField.modelValue"
|
||||
@update:htmlContent="(value) => componentField.onChange(value)"
|
||||
:placeholder="t('editor.placeholder')"
|
||||
:placeholder="t('editor.newLine') + t('editor.send') + t('editor.cmdK')"
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
@@ -27,9 +27,16 @@
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="actions">
|
||||
<FormField
|
||||
v-slot="{ componentField }"
|
||||
name="actions"
|
||||
:validate-on-blur="false"
|
||||
:validate-on-change="false"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel> {{ t('admin.macro.actions') }}</FormLabel>
|
||||
<FormLabel>
|
||||
{{ t('globals.terms.action', 2) }} ({{ t('globals.terms.optional', 1).toLowerCase() }})
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<ActionBuilder
|
||||
v-model:actions="componentField.modelValue"
|
||||
@@ -41,17 +48,57 @@
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="visibility">
|
||||
<FormField v-slot="{ componentField, handleChange }" name="visible_when">
|
||||
<FormItem>
|
||||
<FormLabel>{{ t('admin.macro.visibility') }}</FormLabel>
|
||||
<FormLabel>{{ t('globals.messages.visibleWhen') }}</FormLabel>
|
||||
<FormControl>
|
||||
<SelectTag
|
||||
:items="[
|
||||
{ label: t('globals.messages.replying'), value: 'replying' },
|
||||
{
|
||||
label: t('globals.messages.starting', {
|
||||
name: t('globals.terms.conversation').toLowerCase()
|
||||
}),
|
||||
value: 'starting_conversation'
|
||||
},
|
||||
{
|
||||
label: t('globals.messages.adding', {
|
||||
name: t('globals.terms.privateNote', 2).toLowerCase()
|
||||
}),
|
||||
value: 'adding_private_note'
|
||||
}
|
||||
]"
|
||||
v-model="componentField.modelValue"
|
||||
@update:modelValue="handleChange"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
v-slot="{ componentField }"
|
||||
name="visibility"
|
||||
:validate-on-blur="false"
|
||||
:validate-on-change="false"
|
||||
:validate-on-input="false"
|
||||
:validate-on-mount="false"
|
||||
:validate-on-model-update="false"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel>{{ t('globals.terms.visibility') }}</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="componentField">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select visibility" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="all">{{ t('admin.macro.visibility.all') }}</SelectItem>
|
||||
<SelectItem value="all">{{
|
||||
t('globals.messages.all', {
|
||||
name: t('globals.terms.user', 2).toLowerCase()
|
||||
})
|
||||
}}</SelectItem>
|
||||
<SelectItem value="team">{{ t('globals.terms.team') }}</SelectItem>
|
||||
<SelectItem value="user">{{ t('globals.terms.user') }}</SelectItem>
|
||||
</SelectGroup>
|
||||
@@ -66,27 +113,12 @@
|
||||
<FormItem>
|
||||
<FormLabel>{{ t('globals.terms.team') }}</FormLabel>
|
||||
<FormControl>
|
||||
<ComboBox
|
||||
<SelectComboBox
|
||||
v-bind="componentField"
|
||||
:items="tStore.options"
|
||||
:placeholder="t('form.field.selectTeam')"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div class="flex items-center gap-2 ml-2">
|
||||
<span>{{ item.emoji }}</span>
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #selected="{ selected }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="selected">
|
||||
{{ selected.emoji }}
|
||||
<span>{{ selected.label }}</span>
|
||||
</span>
|
||||
<span v-else>{{ t('form.field.selectTeam') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</ComboBox>
|
||||
type="team"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -94,35 +126,14 @@
|
||||
|
||||
<FormField v-if="form.values.visibility === 'user'" v-slot="{ componentField }" name="user_id">
|
||||
<FormItem>
|
||||
<FormLabel>{{ t('globals.terms.user') }}</FormLabel>
|
||||
<FormLabel>{{ t('globals.terms.agent') }}</FormLabel>
|
||||
<FormControl>
|
||||
<ComboBox
|
||||
<SelectComboBox
|
||||
v-bind="componentField"
|
||||
:items="uStore.options"
|
||||
:placeholder="t('form.field.selectUser')"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div class="flex items-center gap-2 ml-2">
|
||||
<Avatar class="w-7 h-7">
|
||||
<AvatarImage :src="item.avatar_url || ''" :alt="item.label.slice(0, 2)" />
|
||||
<AvatarFallback>{{ item.label.slice(0, 2).toUpperCase() }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #selected="{ selected }">
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="selected" class="flex items-center gap-2">
|
||||
<Avatar class="w-7 h-7">
|
||||
<AvatarImage :src="selected.avatar_url || ''" :alt="selected.label.slice(0, 2)" />
|
||||
<AvatarFallback>{{ selected.label.slice(0, 2).toUpperCase() }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{{ selected.label }}</span>
|
||||
</div>
|
||||
<span v-else>{{ t('form.field.selectUser') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</ComboBox>
|
||||
:placeholder="t('form.field.selectAgent')"
|
||||
type="user"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -139,24 +150,24 @@ import { Button } from '@/components/ui/button'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||
import ActionBuilder from '@/features/admin/macros/ActionBuilder.vue'
|
||||
import { useConversationFilters } from '@/composables/useConversationFilters'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
import { useTeamStore } from '@/stores/team'
|
||||
import { getTextFromHTML } from '@/utils/strings.js'
|
||||
import { createFormSchema } from './formSchema.js'
|
||||
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
SelectValue,
|
||||
SelectTag
|
||||
} from '@/components/ui/select'
|
||||
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Editor from '@/features/conversation/ConversationTextEditor.vue'
|
||||
import Editor from '@/components/editor/TextEditor.vue'
|
||||
|
||||
const { macroActions } = useConversationFilters()
|
||||
const { t } = useI18n()
|
||||
@@ -189,7 +200,15 @@ const submitLabel = computed(() => {
|
||||
)
|
||||
})
|
||||
const form = useForm({
|
||||
validationSchema: toTypedSchema(createFormSchema(t))
|
||||
validationSchema: toTypedSchema(createFormSchema(t)),
|
||||
initialValues: {
|
||||
visible_when: props.initialValues.visible_when || [
|
||||
'replying',
|
||||
'starting_conversation',
|
||||
'adding_private_note'
|
||||
],
|
||||
visibility: props.initialValues.visibility || 'all'
|
||||
}
|
||||
})
|
||||
|
||||
const actionConfig = ref({
|
||||
|
||||
@@ -15,7 +15,7 @@ export const createColumns = (t) => [
|
||||
{
|
||||
accessorKey: 'visibility',
|
||||
header: function () {
|
||||
return h('div', { class: 'text-center' }, t('admin.macro.visibility'))
|
||||
return h('div', { class: 'text-center' }, t('globals.terms.visibility'))
|
||||
},
|
||||
cell: function ({ row }) {
|
||||
return h('div', { class: 'text-center' }, row.getValue('visibility'))
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{{ $t('globals.messages.areYouAbsolutelySure') }}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{{ $t('admin.macro.deleteConfirmation') }}
|
||||
{{ $t('globals.messages.deletionConfirmation', { name: $t('globals.terms.macro') }) }}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import * as z from 'zod'
|
||||
import { getTextFromHTML } from '@/utils/strings.js'
|
||||
|
||||
const actionSchema = (t) => z.array(
|
||||
const actionSchema = () => z.array(
|
||||
z.object({
|
||||
type: z.string().min(1, t('admin.macro.actionTypeRequired')),
|
||||
value: z.array(z.string().min(1, t('admin.macro.actionValueRequired'))),
|
||||
type: z.string().optional(),
|
||||
value: z.array(z.string()).optional(),
|
||||
})
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ export const createFormSchema = (t) => z.object({
|
||||
message_content: z.string().optional(),
|
||||
actions: actionSchema(t).optional().default([]),
|
||||
visibility: z.enum(['all', 'team', 'user']),
|
||||
visible_when: z.array(z.enum(['replying', 'starting_conversation', 'adding_private_note'])),
|
||||
team_id: z.string().nullable().optional(),
|
||||
user_id: z.string().nullable().optional(),
|
||||
})
|
||||
@@ -34,19 +35,39 @@ export const createFormSchema = (t) => z.object({
|
||||
.refine(
|
||||
(data) => {
|
||||
// If visibility is 'team', team_id is required
|
||||
if (data.visibility === 'team' && !data.team_id) {
|
||||
return false
|
||||
if (data.visibility === 'team') {
|
||||
return !!data.team_id
|
||||
}
|
||||
// If visibility is 'user', user_id is required
|
||||
if (data.visibility === 'user' && !data.user_id) {
|
||||
return false
|
||||
}
|
||||
// Otherwise, validation passes
|
||||
return true
|
||||
},
|
||||
{
|
||||
message: t('admin.macro.teamOrUserRequired'),
|
||||
message: t('globals.messages.required'),
|
||||
path: ['team_id'],
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
// If visibility is 'user', user_id is required
|
||||
if (data.visibility === 'user') {
|
||||
return !!data.user_id
|
||||
}
|
||||
return true
|
||||
},
|
||||
{
|
||||
message: t('globals.messages.required'),
|
||||
path: ['user_id'],
|
||||
}
|
||||
).refine(
|
||||
(data) => {
|
||||
// if actions are present, all actions should have type and value defined.
|
||||
if (data.actions && data.actions.length > 0) {
|
||||
return data.actions.every(action => action.type?.length > 0 && action.value?.length > 0)
|
||||
}
|
||||
return true
|
||||
},
|
||||
{
|
||||
message: t('admin.macro.actionInvalid'),
|
||||
// Field path to highlight
|
||||
path: ['visibility'],
|
||||
path: ['actions'],
|
||||
}
|
||||
)
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<FormField v-slot="{ componentField }" name="subject" v-if="!isOutgoingTemplate">
|
||||
<FormItem>
|
||||
<FormLabel>{{ $t('form.field.subject') }}</FormLabel>
|
||||
<FormLabel>{{ $t('globals.terms.subject') }}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="" v-bind="componentField" />
|
||||
</FormControl>
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
<template>
|
||||
<CommandDialog
|
||||
:open="open"
|
||||
@update:open="handleOpenChange"
|
||||
class="transform-gpu z-[51] !min-w-[50vw]"
|
||||
>
|
||||
<CommandDialog :open="open" @update:open="toggleOpen" class="transform-gpu z-[51] !min-w-[50vw]">
|
||||
<CommandInput :placeholder="t('command.typeCmdOrSearch')" @keydown="onInputKeydown" />
|
||||
<CommandList
|
||||
class="!min-h-[60vh] h-[60vh] !min-w-[50vw]"
|
||||
@@ -13,23 +9,7 @@
|
||||
<p class="text-muted-foreground">{{ $t('command.noCommandAvailable') }}</p>
|
||||
</CommandEmpty>
|
||||
|
||||
<!-- Commands requiring a conversation to be open -->
|
||||
<CommandGroup
|
||||
:heading="t('globals.terms.conversation', 2)"
|
||||
value="conversations"
|
||||
v-if="nestedCommand === null && conversationStore.hasConversationOpen"
|
||||
>
|
||||
<CommandItem value="conv-snooze" @select="setNestedCommand('snooze')">
|
||||
{{ $t('globals.messages.snooze') }}
|
||||
</CommandItem>
|
||||
<CommandItem value="conv-resolve" @select="resolveConversation">
|
||||
{{ $t('globals.messages.resolve') }}
|
||||
</CommandItem>
|
||||
<CommandItem value="apply-macro" @select="setNestedCommand('apply-macro')">
|
||||
{{ $t('globals.messages.applyMacro') }}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<!-- Snooze Options -->
|
||||
<CommandGroup v-if="nestedCommand === 'snooze'" heading="Snooze for">
|
||||
<CommandItem value="1 hour" @select="handleSnooze(60)">
|
||||
1 {{ $t('globals.terms.hour') }}
|
||||
@@ -55,7 +35,12 @@
|
||||
</CommandGroup>
|
||||
|
||||
<!-- Macros -->
|
||||
<div v-if="nestedCommand === 'apply-macro'">
|
||||
<div
|
||||
v-if="
|
||||
nestedCommand === 'apply-macro-to-existing-conversation' ||
|
||||
nestedCommand === 'apply-macro-to-new-conversation'
|
||||
"
|
||||
>
|
||||
<CommandGroup heading="Apply macro">
|
||||
<div class="min-h-[400px]">
|
||||
<div class="h-[60vh] grid grid-cols-12">
|
||||
@@ -158,6 +143,26 @@
|
||||
</div>
|
||||
</CommandGroup>
|
||||
</div>
|
||||
|
||||
<!-- Commands requiring a conversation to be open -->
|
||||
<CommandGroup
|
||||
:heading="t('globals.terms.conversation', 2)"
|
||||
value="conversations"
|
||||
v-else-if="conversationStore.hasConversationOpen && !nestedCommand"
|
||||
>
|
||||
<CommandItem value="conv-snooze" @select="setNestedCommand('snooze')">
|
||||
{{ $t('globals.messages.snooze') }}
|
||||
</CommandItem>
|
||||
<CommandItem value="conv-resolve" @select="resolveConversation">
|
||||
{{ $t('globals.messages.resolve') }}
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
value="apply-macro"
|
||||
@select="setNestedCommand('apply-macro-to-existing-conversation')"
|
||||
>
|
||||
{{ $t('globals.messages.applyMacro') }}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
|
||||
<!-- Navigation -->
|
||||
@@ -251,7 +256,7 @@ const { Meta_K, Ctrl_K } = useMagicKeys({
|
||||
})
|
||||
|
||||
watch([Meta_K, Ctrl_K], ([mac, win]) => {
|
||||
if (mac || win) handleOpenChange()
|
||||
if (mac || win) toggleOpen()
|
||||
})
|
||||
|
||||
const highlightedMacro = ref(null)
|
||||
@@ -259,8 +264,12 @@ const highlightedMacro = ref(null)
|
||||
function handleApplyMacro(macro) {
|
||||
// Create a deep copy.
|
||||
const plainMacro = JSON.parse(JSON.stringify(macro))
|
||||
conversationStore.setMacro(plainMacro)
|
||||
handleOpenChange()
|
||||
if (nestedCommand.value === 'apply-macro-to-new-conversation') {
|
||||
conversationStore.setMacro(plainMacro, 'new-conversation')
|
||||
} else {
|
||||
conversationStore.setMacro(plainMacro, 'reply')
|
||||
}
|
||||
toggleOpen()
|
||||
}
|
||||
|
||||
const getActionLabel = computed(() => (action) => {
|
||||
@@ -285,8 +294,10 @@ const otherActions = computed(
|
||||
) || []
|
||||
)
|
||||
|
||||
function handleOpenChange() {
|
||||
if (!open.value) nestedCommand.value = null
|
||||
function toggleOpen() {
|
||||
if (nestedCommand.value != 'apply-macro-to-new-conversation' && !open.value) {
|
||||
nestedCommand.value = null
|
||||
}
|
||||
open.value = !open.value
|
||||
}
|
||||
|
||||
@@ -300,16 +311,16 @@ function formatDuration(minutes) {
|
||||
|
||||
async function handleSnooze(minutes) {
|
||||
await conversationStore.snoozeConversation(formatDuration(minutes))
|
||||
handleOpenChange()
|
||||
toggleOpen()
|
||||
}
|
||||
|
||||
async function resolveConversation() {
|
||||
await conversationStore.updateStatus(CONVERSATION_DEFAULT_STATUSES.RESOLVED)
|
||||
handleOpenChange()
|
||||
toggleOpen()
|
||||
}
|
||||
|
||||
function showCustomDialog() {
|
||||
handleOpenChange()
|
||||
toggleOpen()
|
||||
showDatePicker.value = true
|
||||
}
|
||||
|
||||
@@ -329,7 +340,7 @@ function handleCustomSnooze() {
|
||||
}
|
||||
handleSnooze(diffMinutes)
|
||||
closeDatePicker()
|
||||
handleOpenChange()
|
||||
toggleOpen()
|
||||
}
|
||||
|
||||
function onInputKeydown(e) {
|
||||
@@ -343,9 +354,9 @@ function onInputKeydown(e) {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
emitter.on(EMITTER_EVENTS.SET_NESTED_COMMAND, (command) => {
|
||||
setNestedCommand(command)
|
||||
open.value = true
|
||||
emitter.on(EMITTER_EVENTS.SET_NESTED_COMMAND, (data) => {
|
||||
setNestedCommand(data.command)
|
||||
open.value = data.open
|
||||
})
|
||||
watchHighlightedMacro()
|
||||
})
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<Editor
|
||||
v-model:htmlContent="newNote"
|
||||
@update:htmlContent="(value) => (newNote = value)"
|
||||
:placeholder="t('editor.placeholder')"
|
||||
:placeholder="t('editor.newLine') + t('editor.send') + t('editor.cmdK')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3 pt-2">
|
||||
@@ -148,7 +148,7 @@ import {
|
||||
ClockIcon,
|
||||
MessageSquareIcon
|
||||
} from 'lucide-vue-next'
|
||||
import Editor from '@/features/conversation/ConversationTextEditor.vue'
|
||||
import Editor from '@/components/editor/TextEditor.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
|
||||
@@ -63,7 +63,10 @@ const emitter = useEmitter()
|
||||
|
||||
const handleUpdateStatus = (status) => {
|
||||
if (status === CONVERSATION_DEFAULT_STATUSES.SNOOZED) {
|
||||
emitter.emit(EMITTER_EVENTS.SET_NESTED_COMMAND, 'snooze')
|
||||
emitter.emit(EMITTER_EVENTS.SET_NESTED_COMMAND, {
|
||||
command: 'snooze',
|
||||
open: true
|
||||
})
|
||||
return
|
||||
}
|
||||
conversationStore.updateStatus(status)
|
||||
|
||||
@@ -1,230 +1,213 @@
|
||||
<template>
|
||||
<Dialog :open="dialogOpen" @update:open="dialogOpen = false">
|
||||
<DialogContent class="max-w-5xl w-full h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{{
|
||||
$t('globals.messages.new', {
|
||||
name: $t('globals.terms.conversation').toLowerCase()
|
||||
})
|
||||
}}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form @submit="createConversation" class="flex flex-col flex-1 overflow-hidden">
|
||||
<div class="flex-1 space-y-4 pr-1 overflow-y-auto pb-2">
|
||||
<FormField name="contact_email">
|
||||
<FormItem class="relative">
|
||||
<FormLabel>{{ $t('form.field.email') }}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
:placeholder="t('conversation.searchContact')"
|
||||
v-model="emailQuery"
|
||||
@input="handleSearchContacts"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<div>
|
||||
<Dialog v-model:open="dialogOpen">
|
||||
<DialogContent class="max-w-5xl w-full h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{{
|
||||
$t('globals.messages.new', {
|
||||
name: $t('globals.terms.conversation').toLowerCase()
|
||||
})
|
||||
}}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form @submit="createConversation" class="flex flex-col flex-1 overflow-hidden">
|
||||
<!-- Form Fields Section -->
|
||||
<div class="space-y-4 pb-2 flex-shrink-0">
|
||||
<div class="space-y-2">
|
||||
<FormField name="contact_email">
|
||||
<FormItem class="relative">
|
||||
<FormLabel>{{ $t('form.field.email') }}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
:placeholder="t('conversation.searchContact')"
|
||||
v-model="emailQuery"
|
||||
@input="handleSearchContacts"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
|
||||
<ul
|
||||
v-if="searchResults.length"
|
||||
class="border rounded p-2 max-h-60 overflow-y-auto absolute w-full z-50 shadow-lg bg-background"
|
||||
>
|
||||
<li
|
||||
v-for="contact in searchResults"
|
||||
:key="contact.email"
|
||||
@click="selectContact(contact)"
|
||||
class="cursor-pointer p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
{{ contact.first_name }} {{ contact.last_name }} ({{ contact.email }})
|
||||
</li>
|
||||
</ul>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<ul
|
||||
v-if="searchResults.length"
|
||||
class="border rounded p-2 max-h-60 overflow-y-auto absolute w-full z-50 shadow bg-background"
|
||||
>
|
||||
<li
|
||||
v-for="contact in searchResults"
|
||||
:key="contact.email"
|
||||
@click="selectContact(contact)"
|
||||
class="cursor-pointer p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
{{ contact.first_name }} {{ contact.last_name }} ({{ contact.email }})
|
||||
</li>
|
||||
</ul>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="first_name">
|
||||
<FormItem>
|
||||
<FormLabel>{{ $t('form.field.firstName') }}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="" v-bind="componentField" required />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<!-- Name Group -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<FormField v-slot="{ componentField }" name="first_name">
|
||||
<FormItem>
|
||||
<FormLabel>{{ $t('form.field.firstName') }}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="" v-bind="componentField" required />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="last_name">
|
||||
<FormItem>
|
||||
<FormLabel>{{ $t('form.field.lastName') }}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="" v-bind="componentField" required />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormField v-slot="{ componentField }" name="last_name">
|
||||
<FormItem>
|
||||
<FormLabel>{{ $t('form.field.lastName') }}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="subject">
|
||||
<FormItem>
|
||||
<FormLabel>{{ $t('form.field.subject') }}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="" v-bind="componentField" required />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<!-- Subject and Inbox Group -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<FormField v-slot="{ componentField }" name="subject">
|
||||
<FormItem>
|
||||
<FormLabel>{{ $t('globals.terms.subject') }}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="inbox_id">
|
||||
<FormItem>
|
||||
<FormLabel>{{ $t('form.field.inbox') }}</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="componentField">
|
||||
<SelectTrigger>
|
||||
<SelectValue :placeholder="t('form.field.selectInbox')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem
|
||||
v-for="option in inboxStore.options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormField v-slot="{ componentField }" name="inbox_id">
|
||||
<FormItem>
|
||||
<FormLabel>{{ $t('form.field.inbox') }}</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="componentField">
|
||||
<SelectTrigger>
|
||||
<SelectValue :placeholder="t('form.field.selectInbox')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem
|
||||
v-for="option in inboxStore.options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<!-- Set assigned team -->
|
||||
<FormField v-slot="{ componentField }" name="team_id">
|
||||
<FormItem>
|
||||
<FormLabel>{{ $t('form.field.assignTeamOptional') }}</FormLabel>
|
||||
<FormControl>
|
||||
<ComboBox
|
||||
v-bind="componentField"
|
||||
:items="[{ value: 'none', label: 'None' }, ...teamStore.options]"
|
||||
:placeholder="t('form.field.selectTeam')"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div class="flex items-center gap-3 py-2">
|
||||
<div class="w-7 h-7 flex items-center justify-center">
|
||||
<span v-if="item.emoji">{{ item.emoji }}</span>
|
||||
<div
|
||||
v-else
|
||||
class="text-primary bg-muted rounded-full w-7 h-7 flex items-center justify-center"
|
||||
>
|
||||
<Users size="14" />
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm">{{ item.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Assignment Group -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Set assigned team -->
|
||||
<FormField v-slot="{ componentField }" name="team_id">
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
>{{ $t('form.field.assignTeam') }} ({{
|
||||
$t('globals.terms.optional').toLowerCase()
|
||||
}})</FormLabel
|
||||
>
|
||||
<FormControl>
|
||||
<SelectComboBox
|
||||
v-bind="componentField"
|
||||
:items="[{ value: 'none', label: 'None' }, ...teamStore.options]"
|
||||
:placeholder="t('form.field.selectTeam')"
|
||||
type="team"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<template #selected="{ selected }">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-7 h-7 flex items-center justify-center" v-if="selected">
|
||||
<span v-if="selected?.emoji">{{ selected?.emoji }}</span>
|
||||
<div
|
||||
v-else
|
||||
class="text-primary bg-muted rounded-full w-7 h-7 flex items-center justify-center"
|
||||
>
|
||||
<Users size="14" />
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm">
|
||||
{{ selected?.label || t('form.field.selectTeam') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</ComboBox>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<!-- Set assigned agent -->
|
||||
<FormField v-slot="{ componentField }" name="agent_id">
|
||||
<FormItem>
|
||||
<FormLabel
|
||||
>{{ $t('form.field.assignAgent') }} ({{
|
||||
$t('globals.terms.optional').toLowerCase()
|
||||
}})</FormLabel
|
||||
>
|
||||
<FormControl>
|
||||
<SelectComboBox
|
||||
v-bind="componentField"
|
||||
:items="[{ value: 'none', label: 'None' }, ...uStore.options]"
|
||||
:placeholder="t('form.field.selectAgent')"
|
||||
type="user"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Set assigned agent -->
|
||||
<FormField v-slot="{ componentField }" name="agent_id">
|
||||
<FormItem>
|
||||
<FormLabel>{{ $t('form.field.assignAgentOptional') }}</FormLabel>
|
||||
<FormControl>
|
||||
<ComboBox
|
||||
v-bind="componentField"
|
||||
:items="[{ value: 'none', label: 'None' }, ...uStore.options]"
|
||||
:placeholder="t('form.field.selectAgent')"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div class="flex items-center gap-3 py-2">
|
||||
<Avatar class="w-8 h-8">
|
||||
<AvatarImage
|
||||
:src="item.value === 'none' ? '' : item.avatar_url || ''"
|
||||
:alt="item.value === 'none' ? 'N' : item.label.slice(0, 2)"
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{{ item.value === 'none' ? 'N' : item.label.slice(0, 2).toUpperCase() }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span class="text-sm">{{ item.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Message Editor Section -->
|
||||
<div class="flex-1 flex flex-col min-h-0 mt-4">
|
||||
<FormField v-slot="{ componentField }" name="content">
|
||||
<FormItem class="flex flex-col h-full">
|
||||
<FormLabel>{{ $t('form.field.message') }}</FormLabel>
|
||||
<FormControl class="flex-1 flex flex-col min-h-0">
|
||||
<div class="flex flex-col h-full">
|
||||
<Editor
|
||||
v-model:htmlContent="componentField.modelValue"
|
||||
@update:htmlContent="(value) => componentField.onChange(value)"
|
||||
:contentToSet="contentToSet"
|
||||
:placeholder="t('editor.newLine') + t('editor.send') + t('editor.cmdK')"
|
||||
:clearContent="clearEditorContent"
|
||||
:insertContent="insertContent"
|
||||
:autoFocus="false"
|
||||
class="w-full flex-1 overflow-y-auto p-2 box min-h-0"
|
||||
@send="createConversation"
|
||||
/>
|
||||
|
||||
<template #selected="{ selected }">
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar class="w-7 h-7" v-if="selected">
|
||||
<AvatarImage
|
||||
:src="selected?.value === 'none' ? '' : selected?.avatar_url || ''"
|
||||
:alt="selected?.value === 'none' ? 'N' : selected?.label?.slice(0, 2)"
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{{
|
||||
selected?.value === 'none'
|
||||
? 'N'
|
||||
: selected?.label?.slice(0, 2)?.toUpperCase()
|
||||
}}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span class="text-sm">{{
|
||||
selected?.label || t('form.field.selectAgent')
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
</ComboBox>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<!-- Macro preview -->
|
||||
<MacroActionsPreview
|
||||
v-if="conversationStore.getMacro('new-conversation').actions?.length > 0"
|
||||
:actions="conversationStore.getMacro('new-conversation')?.actions || []"
|
||||
:onRemove="
|
||||
(action) => conversationStore.removeMacroAction(action, 'new-conversation')
|
||||
"
|
||||
class="mt-2 flex-shrink-0"
|
||||
/>
|
||||
|
||||
<FormField
|
||||
v-slot="{ componentField }"
|
||||
name="content"
|
||||
class="flex-1 min-h-0 flex flex-col"
|
||||
>
|
||||
<FormItem class="flex flex-col flex-1">
|
||||
<FormLabel>{{ $t('form.field.message') }}</FormLabel>
|
||||
<FormControl class="flex-1 min-h-0 flex flex-col">
|
||||
<div class="flex-1 min-h-0 flex flex-col">
|
||||
<Editor
|
||||
v-model:htmlContent="componentField.modelValue"
|
||||
@update:htmlContent="(value) => componentField.onChange(value)"
|
||||
:placeholder="t('editor.placeholder')"
|
||||
class="w-full flex-1 overflow-y-auto p-2 min-h-[200px] box"
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
<!-- Attachments preview -->
|
||||
<AttachmentsPreview
|
||||
:attachments="mediaFiles"
|
||||
:uploadingFiles="uploadingFiles"
|
||||
:onDelete="handleFileDelete"
|
||||
v-if="mediaFiles.length > 0 || uploadingFiles.length > 0"
|
||||
class="mt-2 flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="mt-4 pt-2 border-t shrink-0">
|
||||
<Button type="submit" :disabled="loading" :isLoading="loading">
|
||||
{{ $t('globals.buttons.submit') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DialogFooter class="mt-4 pt-2 flex items-center !justify-between w-full flex-shrink-0">
|
||||
<ReplyBoxMenuBar
|
||||
:handleFileUpload="handleFileUpload"
|
||||
@emojiSelect="handleEmojiSelect"
|
||||
/>
|
||||
<Button type="submit" :disabled="isDisabled" :isLoading="loading">
|
||||
{{ $t('globals.buttons.submit') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -241,12 +224,13 @@ import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
|
||||
import { z } from 'zod'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref, watch, onUnmounted, nextTick, onMounted, computed } from 'vue'
|
||||
import AttachmentsPreview from '@/features/conversation/message/attachment/AttachmentsPreview.vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import MacroActionsPreview from '@/features/conversation/MacroActionsPreview.vue'
|
||||
import ReplyBoxMenuBar from '@/features/conversation/ReplyBoxMenuBar.vue'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
|
||||
import { Users } from 'lucide-vue-next'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import { useInboxStore } from '@/stores/inbox'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
@@ -260,7 +244,10 @@ import {
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Editor from '@/features/conversation/ConversationTextEditor.vue'
|
||||
import { useFileUpload } from '@/composables/useFileUpload'
|
||||
import Editor from '@/components/editor/TextEditor.vue'
|
||||
import { useMacroStore } from '@/stores/macro'
|
||||
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
|
||||
import api from '@/api'
|
||||
|
||||
const dialogOpen = defineModel({
|
||||
@@ -276,13 +263,37 @@ const emitter = useEmitter()
|
||||
const loading = ref(false)
|
||||
const searchResults = ref([])
|
||||
const emailQuery = ref('')
|
||||
const conversationStore = useConversationStore()
|
||||
const macroStore = useMacroStore()
|
||||
let timeoutId = null
|
||||
|
||||
const contentToSet = ref('')
|
||||
const clearEditorContent = ref(false)
|
||||
const insertContent = ref('')
|
||||
|
||||
const handleEmojiSelect = (emoji) => {
|
||||
insertContent.value = undefined
|
||||
// Force reactivity so the user can select the same emoji multiple times
|
||||
nextTick(() => (insertContent.value = emoji))
|
||||
}
|
||||
|
||||
const { uploadingFiles, handleFileUpload, handleFileDelete, mediaFiles, clearMediaFiles } =
|
||||
useFileUpload({
|
||||
linkedModel: 'messages'
|
||||
})
|
||||
|
||||
const isDisabled = computed(() => {
|
||||
if (loading.value || uploadingFiles.value.length > 0) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const formSchema = z.object({
|
||||
subject: z.string().min(
|
||||
3,
|
||||
t('form.error.min', {
|
||||
min: 3
|
||||
1,
|
||||
t('globals.messages.cannotBeEmpty', {
|
||||
name: t('globals.terms.subject')
|
||||
})
|
||||
),
|
||||
content: z.string().min(
|
||||
@@ -298,7 +309,25 @@ const formSchema = z.object({
|
||||
agent_id: z.any().optional(),
|
||||
contact_email: z.string().email(t('globals.messages.invalidEmailAddress')),
|
||||
first_name: z.string().min(1, t('globals.messages.required')),
|
||||
last_name: z.string().min(1, t('globals.messages.required'))
|
||||
last_name: z.string().optional()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimeout(timeoutId)
|
||||
clearMediaFiles()
|
||||
conversationStore.resetMacro('new-conversation')
|
||||
emitter.emit(EMITTER_EVENTS.SET_NESTED_COMMAND, {
|
||||
command: null,
|
||||
open: false
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
macroStore.setCurrentView('starting_conversation')
|
||||
emitter.emit(EMITTER_EVENTS.SET_NESTED_COMMAND, {
|
||||
command: 'apply-macro-to-new-conversation',
|
||||
open: false
|
||||
})
|
||||
})
|
||||
|
||||
const form = useForm({
|
||||
@@ -352,10 +381,29 @@ const selectContact = (contact) => {
|
||||
const createConversation = form.handleSubmit(async (values) => {
|
||||
loading.value = true
|
||||
try {
|
||||
await api.createConversation(values)
|
||||
// convert ids to numbers if they are not already
|
||||
values.inbox_id = Number(values.inbox_id)
|
||||
values.team_id = values.team_id ? Number(values.team_id) : null
|
||||
values.agent_id = values.agent_id ? Number(values.agent_id) : null
|
||||
// array of attachment ids.
|
||||
values.attachments = mediaFiles.value.map((file) => file.id)
|
||||
const conversation = await api.createConversation(values)
|
||||
const conversationUUID = conversation.data.data.uuid
|
||||
|
||||
// Get macro from context, and set if any actions are available.
|
||||
const macro = conversationStore.getMacro('new-conversation')
|
||||
if (conversationUUID !== '' && macro?.id && macro?.actions?.length > 0) {
|
||||
try {
|
||||
await api.applyMacro(conversationUUID, macro.id, macro.actions)
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
}
|
||||
}
|
||||
dialogOpen.value = false
|
||||
form.resetForm()
|
||||
emailQuery.value = ''
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
@@ -365,4 +413,19 @@ const createConversation = form.handleSubmit(async (values) => {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Watches for changes in the macro id and update message content.
|
||||
*/
|
||||
watch(
|
||||
() => conversationStore.getMacro('new-conversation').id,
|
||||
() => {
|
||||
// Setting timestamp, so the same macro can be set again.
|
||||
contentToSet.value = JSON.stringify({
|
||||
content: conversationStore.getMacro('new-conversation').message_content,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
</Tooltip>
|
||||
</div>
|
||||
<button
|
||||
@click.stop="onRemove(action)"
|
||||
@click.prevent="onRemove(action)"
|
||||
class="p-2 text-gray-400 hover:text-red-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 rounded transition-colors duration-300 ease-in-out"
|
||||
title="Remove action"
|
||||
>
|
||||
|
||||
@@ -69,8 +69,7 @@
|
||||
@toggleFullscreen="isEditorFullscreen = !isEditorFullscreen"
|
||||
@send="processSend"
|
||||
@fileUpload="handleFileUpload"
|
||||
@inlineImageUpload="handleInlineImageUpload"
|
||||
@fileDelete="handleOnFileDelete"
|
||||
@fileDelete="handleFileDelete"
|
||||
@aiPromptSelected="handleAiPromptSelected"
|
||||
class="h-full flex-grow"
|
||||
/>
|
||||
@@ -90,6 +89,7 @@
|
||||
:uploadingFiles="uploadingFiles"
|
||||
:clearEditorContent="clearEditorContent"
|
||||
:contentToSet="contentToSet"
|
||||
:uploadedFiles="mediaFiles"
|
||||
v-model:htmlContent="htmlContent"
|
||||
v-model:textContent="textContent"
|
||||
v-model:selectedText="selectedText"
|
||||
@@ -105,8 +105,7 @@
|
||||
@toggleFullscreen="isEditorFullscreen = !isEditorFullscreen"
|
||||
@send="processSend"
|
||||
@fileUpload="handleFileUpload"
|
||||
@inlineImageUpload="handleInlineImageUpload"
|
||||
@fileDelete="handleOnFileDelete"
|
||||
@fileDelete="handleFileDelete"
|
||||
@aiPromptSelected="handleAiPromptSelected"
|
||||
/>
|
||||
</div>
|
||||
@@ -115,7 +114,6 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick, watch, computed } from 'vue'
|
||||
import { transformImageSrcToCID } from '@/utils/strings'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
@@ -133,6 +131,7 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { useFileUpload } from '@/composables/useFileUpload'
|
||||
import ReplyBoxContent from '@/features/conversation/ReplyBoxContent.vue'
|
||||
import {
|
||||
Form,
|
||||
@@ -155,10 +154,21 @@ const { t } = useI18n()
|
||||
const conversationStore = useConversationStore()
|
||||
const emitter = useEmitter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// Setup file upload composable
|
||||
const {
|
||||
uploadingFiles,
|
||||
handleFileUpload,
|
||||
handleFileDelete,
|
||||
mediaFiles,
|
||||
clearMediaFiles,
|
||||
} = useFileUpload({
|
||||
linkedModel: 'messages'
|
||||
})
|
||||
|
||||
// Rest of existing state
|
||||
const openAIKeyPrompt = ref(false)
|
||||
const isOpenAIKeyUpdating = ref(false)
|
||||
|
||||
// Shared state between the two editor components.
|
||||
const clearEditorContent = ref(false)
|
||||
const isEditorFullscreen = ref(false)
|
||||
const isSending = ref(false)
|
||||
@@ -169,7 +179,6 @@ const bcc = ref('')
|
||||
const showBcc = ref(false)
|
||||
const emailErrors = ref([])
|
||||
const aiPrompts = ref([])
|
||||
const uploadingFiles = ref([])
|
||||
const htmlContent = ref('')
|
||||
const textContent = ref('')
|
||||
const selectedText = ref('')
|
||||
@@ -249,63 +258,6 @@ const updateProvider = async (values) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the file upload process when files are selected.
|
||||
* Uploads each file to the server and adds them to the conversation's mediaFiles.
|
||||
* @param {Event} event - The file input change event containing selected files
|
||||
*/
|
||||
const handleFileUpload = (event) => {
|
||||
const files = Array.from(event.target.files)
|
||||
uploadingFiles.value = files
|
||||
|
||||
for (const file of files) {
|
||||
api
|
||||
.uploadMedia({
|
||||
files: file,
|
||||
inline: false,
|
||||
linked_model: 'messages'
|
||||
})
|
||||
.then((resp) => {
|
||||
conversationStore.conversation.mediaFiles.push(resp.data.data)
|
||||
uploadingFiles.value = uploadingFiles.value.filter((f) => f.name !== file.name)
|
||||
})
|
||||
.catch((error) => {
|
||||
uploadingFiles.value = uploadingFiles.value.filter((f) => f.name !== file.name)
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Inline image upload is not supported yet.
|
||||
const handleInlineImageUpload = (event) => {
|
||||
for (const file of event.target.files) {
|
||||
api
|
||||
.uploadMedia({
|
||||
files: file,
|
||||
inline: true,
|
||||
linked_model: 'messages'
|
||||
})
|
||||
.then((resp) => {
|
||||
const imageData = {
|
||||
src: resp.data.data.url,
|
||||
alt: resp.data.data.filename,
|
||||
title: resp.data.data.uuid
|
||||
}
|
||||
conversationStore.conversation.mediaFiles.push(resp.data.data)
|
||||
return imageData
|
||||
})
|
||||
.catch((error) => {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the editor has text content.
|
||||
*/
|
||||
@@ -317,36 +269,17 @@ const hasTextContent = computed(() => {
|
||||
* Processes the send action.
|
||||
*/
|
||||
const processSend = async () => {
|
||||
let hasAPIErrored = false
|
||||
let hasMessageSendingErrored = false
|
||||
isEditorFullscreen.value = false
|
||||
try {
|
||||
isSending.value = true
|
||||
|
||||
// Send message if there is text content in the editor.
|
||||
if (hasTextContent.value > 0) {
|
||||
// Replace inline image url with cid.
|
||||
const message = transformImageSrcToCID(htmlContent.value)
|
||||
|
||||
// Check which images are still in editor before sending.
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(htmlContent.value, 'text/html')
|
||||
const inlineImageUUIDs = Array.from(doc.querySelectorAll('img.inline-image'))
|
||||
.map((img) => img.getAttribute('title'))
|
||||
.filter(Boolean)
|
||||
|
||||
// TODO: Inline images are not supported yet, this is some old boilerplate code.
|
||||
conversationStore.conversation.mediaFiles = conversationStore.conversation.mediaFiles.filter(
|
||||
(file) =>
|
||||
// Keep if:
|
||||
// 1. Not an inline image OR
|
||||
// 2. Is an inline image that exists in editor
|
||||
file.disposition !== 'inline' || inlineImageUUIDs.includes(file.uuid)
|
||||
)
|
||||
|
||||
const message = htmlContent.value
|
||||
await api.sendMessage(conversationStore.current.uuid, {
|
||||
private: messageType.value === 'private_note',
|
||||
message: message,
|
||||
attachments: conversationStore.conversation.mediaFiles.map((file) => file.id),
|
||||
attachments: mediaFiles.value.map((file) => file.id),
|
||||
// Convert email addresses to array and remove empty strings.
|
||||
cc: cc.value
|
||||
.split(',')
|
||||
@@ -367,15 +300,12 @@ const processSend = async () => {
|
||||
})
|
||||
}
|
||||
|
||||
// Apply macro actions if any.
|
||||
// For macro errors just show toast and clear the editor.
|
||||
if (conversationStore.conversation?.macro?.actions?.length > 0) {
|
||||
// Apply macro actions if any, for macro errors just show toast and clear the editor.
|
||||
const macroID = conversationStore.getMacro('reply')?.id
|
||||
const macroActions = conversationStore.getMacro('reply')?.actions || []
|
||||
if (macroID > 0 && macroActions.length > 0) {
|
||||
try {
|
||||
await api.applyMacro(
|
||||
conversationStore.current.uuid,
|
||||
conversationStore.conversation.macro.id,
|
||||
conversationStore.conversation.macro.actions
|
||||
)
|
||||
await api.applyMacro(conversationStore.current.uuid, macroID, macroActions)
|
||||
} catch (error) {
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
@@ -384,22 +314,22 @@ const processSend = async () => {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
hasAPIErrored = true
|
||||
hasMessageSendingErrored = true
|
||||
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
|
||||
variant: 'destructive',
|
||||
description: handleHTTPError(error).message
|
||||
})
|
||||
} finally {
|
||||
// If API has NOT errored clear state.
|
||||
if (hasAPIErrored === false) {
|
||||
if (hasMessageSendingErrored === false) {
|
||||
// Clear editor.
|
||||
clearEditorContent.value = true
|
||||
|
||||
// Clear macro.
|
||||
conversationStore.resetMacro()
|
||||
conversationStore.resetMacro('reply')
|
||||
|
||||
// Clear media files.
|
||||
conversationStore.resetMediaFiles()
|
||||
clearMediaFiles()
|
||||
|
||||
// Clear any email errors.
|
||||
emailErrors.value = []
|
||||
@@ -410,30 +340,17 @@ const processSend = async () => {
|
||||
}
|
||||
isSending.value = false
|
||||
}
|
||||
// Update assignee last seen timestamp.
|
||||
api.updateAssigneeLastSeen(conversationStore.current.uuid)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the file delete event.
|
||||
* Removes the file from the conversation's mediaFiles.
|
||||
* @param {String} uuid - The UUID of the file to delete
|
||||
*/
|
||||
const handleOnFileDelete = (uuid) => {
|
||||
conversationStore.conversation.mediaFiles = conversationStore.conversation.mediaFiles.filter(
|
||||
(item) => item.uuid !== uuid
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches for changes in the conversation's macro id and update message content.
|
||||
*/
|
||||
watch(
|
||||
() => conversationStore.conversation.macro.id,
|
||||
() => conversationStore.getMacro('reply').id,
|
||||
() => {
|
||||
// Setting timestamp, so the same macro can be set again.
|
||||
contentToSet.value = JSON.stringify({
|
||||
content: conversationStore.conversation.macro.message_content,
|
||||
content: conversationStore.getMacro('reply').message_content,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
class="px-3 py-1 rounded transition-colors duration-200"
|
||||
:class="{ 'bg-background text-foreground': messageType === 'private_note' }"
|
||||
>
|
||||
{{ $t('replyBox.privateNote') }}
|
||||
{{ $t('globals.terms.privateNote') }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
@@ -90,7 +90,7 @@
|
||||
v-model:htmlContent="htmlContent"
|
||||
v-model:textContent="textContent"
|
||||
v-model:cursorPosition="cursorPosition"
|
||||
:placeholder="editorPlaceholder"
|
||||
:placeholder="t('editor.newLine') + t('editor.send') + t('editor.cmdK')"
|
||||
:aiPrompts="aiPrompts"
|
||||
@aiPromptSelected="handleAiPromptSelected"
|
||||
:contentToSet="contentToSet"
|
||||
@@ -98,23 +98,24 @@
|
||||
:clearContent="clearEditorContent"
|
||||
:setInlineImage="setInlineImage"
|
||||
:insertContent="insertContent"
|
||||
:autoFocus="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Macro preview -->
|
||||
<MacroActionsPreview
|
||||
v-if="conversationStore.conversation?.macro?.actions?.length > 0"
|
||||
:actions="conversationStore.conversation.macro.actions"
|
||||
:onRemove="conversationStore.removeMacroAction"
|
||||
v-if="conversationStore.getMacro('reply')?.actions?.length > 0"
|
||||
:actions="conversationStore.getMacro('reply').actions"
|
||||
:onRemove="(action) => conversationStore.removeMacroAction(action, 'reply')"
|
||||
class="mt-2"
|
||||
/>
|
||||
|
||||
<!-- Attachments preview -->
|
||||
<AttachmentsPreview
|
||||
:attachments="attachments"
|
||||
:attachments="uploadedFiles"
|
||||
:uploadingFiles="uploadingFiles"
|
||||
:onDelete="handleOnFileDelete"
|
||||
v-if="attachments.length > 0 || uploadingFiles.length > 0"
|
||||
v-if="uploadedFiles.length > 0 || uploadingFiles.length > 0"
|
||||
class="mt-2"
|
||||
/>
|
||||
|
||||
@@ -123,7 +124,6 @@
|
||||
class="mt-1 shrink-0"
|
||||
:isFullscreen="isFullscreen"
|
||||
:handleFileUpload="handleFileUpload"
|
||||
:handleInlineImageUpload="handleInlineImageUpload"
|
||||
:isBold="isBold"
|
||||
:isItalic="isItalic"
|
||||
:isSending="isSending"
|
||||
@@ -131,16 +131,17 @@
|
||||
@toggleItalic="toggleItalic"
|
||||
:enableSend="enableSend"
|
||||
:handleSend="handleSend"
|
||||
:showSendButton="true"
|
||||
@emojiSelect="handleEmojiSelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
import { ref, computed, nextTick, watch } from 'vue'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { Maximize2, Minimize2 } from 'lucide-vue-next'
|
||||
import Editor from './ConversationTextEditor.vue'
|
||||
import Editor from '@/components/editor/TextEditor.vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -151,8 +152,8 @@ import MacroActionsPreview from '@/features/conversation/MacroActionsPreview.vue
|
||||
import ReplyBoxMenuBar from '@/features/conversation/ReplyBoxMenuBar.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { validateEmail } from '@/utils/strings'
|
||||
import { useMacroStore } from '@/stores/macro'
|
||||
|
||||
// Define models for two-way binding
|
||||
const messageType = defineModel('messageType', { default: 'reply' })
|
||||
const to = defineModel('to', { default: '' })
|
||||
const cc = defineModel('cc', { default: '' })
|
||||
@@ -165,6 +166,7 @@ const selectedText = defineModel('selectedText', { default: '' })
|
||||
const isBold = defineModel('isBold', { default: false })
|
||||
const isItalic = defineModel('isItalic', { default: false })
|
||||
const cursorPosition = defineModel('cursorPosition', { default: 0 })
|
||||
const macroStore = useMacroStore()
|
||||
|
||||
const props = defineProps({
|
||||
isFullscreen: {
|
||||
@@ -190,6 +192,11 @@ const props = defineProps({
|
||||
contentToSet: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
uploadedFiles: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
@@ -208,7 +215,6 @@ const { t } = useI18n()
|
||||
|
||||
const insertContent = ref(null)
|
||||
const setInlineImage = ref(null)
|
||||
const editorPlaceholder = t('replyBox.editor.placeholder')
|
||||
|
||||
const toggleBcc = async () => {
|
||||
showBcc.value = !showBcc.value
|
||||
@@ -233,16 +239,10 @@ const toggleItalic = () => {
|
||||
isItalic.value = !isItalic.value
|
||||
}
|
||||
|
||||
const attachments = computed(() => {
|
||||
return conversationStore.conversation.mediaFiles.filter(
|
||||
(upload) => upload.disposition === 'attachment'
|
||||
)
|
||||
})
|
||||
|
||||
const enableSend = computed(() => {
|
||||
return (
|
||||
(textContent.value.trim().length > 0 ||
|
||||
conversationStore.conversation?.macro?.actions?.length > 0) &&
|
||||
conversationStore.getMacro('reply')?.actions?.length > 0) &&
|
||||
emailErrors.value.length === 0 &&
|
||||
!props.uploadingFiles.length
|
||||
)
|
||||
@@ -289,10 +289,6 @@ const handleFileUpload = (event) => {
|
||||
emit('fileUpload', event)
|
||||
}
|
||||
|
||||
const handleInlineImageUpload = (event) => {
|
||||
emit('inlineImageUpload', event)
|
||||
}
|
||||
|
||||
const handleOnFileDelete = (uuid) => {
|
||||
emit('fileDelete', uuid)
|
||||
}
|
||||
@@ -306,4 +302,17 @@ const handleEmojiSelect = (emoji) => {
|
||||
const handleAiPromptSelected = (key) => {
|
||||
emit('aiPromptSelected', key)
|
||||
}
|
||||
|
||||
// Watch and update macro view based on message type this filters our macros.
|
||||
watch(
|
||||
messageType,
|
||||
(newType) => {
|
||||
if (newType === 'reply') {
|
||||
macroStore.setCurrentView('replying')
|
||||
} else if (newType === 'private_note') {
|
||||
macroStore.setCurrentView('adding_private_note')
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -13,13 +13,13 @@
|
||||
<div class="flex justify-items-start gap-2">
|
||||
<!-- File inputs -->
|
||||
<input type="file" class="hidden" ref="attachmentInput" multiple @change="handleFileUpload" />
|
||||
<input
|
||||
<!-- <input
|
||||
type="file"
|
||||
class="hidden"
|
||||
ref="inlineImageInput"
|
||||
accept="image/*"
|
||||
@change="handleInlineImageUpload"
|
||||
/>
|
||||
/> -->
|
||||
<!-- Editor buttons -->
|
||||
<Toggle
|
||||
class="px-2 py-2 border-0"
|
||||
@@ -38,7 +38,7 @@
|
||||
<Smile class="h-4 w-4" />
|
||||
</Toggle>
|
||||
</div>
|
||||
<Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!enableSend" :isLoading="isSending">
|
||||
<Button class="h-8 w-6 px-8" @click="handleSend" :disabled="!enableSend" :isLoading="isSending" v-if="showSendButton">
|
||||
{{ $t('globals.buttons.send') }}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -54,7 +54,7 @@ import EmojiPicker from 'vue3-emoji-picker'
|
||||
import 'vue3-emoji-picker/css'
|
||||
|
||||
const attachmentInput = ref(null)
|
||||
const inlineImageInput = ref(null)
|
||||
// const inlineImageInput = ref(null)
|
||||
const isEmojiPickerVisible = ref(false)
|
||||
const emojiPickerRef = ref(null)
|
||||
const emit = defineEmits(['emojiSelect'])
|
||||
@@ -65,6 +65,7 @@ defineProps({
|
||||
isSending: Boolean,
|
||||
enableSend: Boolean,
|
||||
handleSend: Function,
|
||||
showSendButton: Boolean,
|
||||
handleFileUpload: Function,
|
||||
handleInlineImageUpload: Function
|
||||
})
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
<button
|
||||
v-if="!attachment.loading"
|
||||
@click.stop="onDelete(attachment.uuid)"
|
||||
@click.prevent="onDelete(attachment.uuid)"
|
||||
class="text-gray-400 hover:text-red-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 rounded transition-colors duration-300 ease-in-out"
|
||||
title="Remove attachment"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col" v-if="conversation.subject">
|
||||
<p class="font-medium">{{ $t('form.field.subject') }}</p>
|
||||
<p class="font-medium">{{ $t('globals.terms.subject') }}</p>
|
||||
<Skeleton v-if="conversationStore.conversation.loading" class="w-32 h-4" />
|
||||
<p v-else>
|
||||
{{ conversation.subject }}
|
||||
|
||||
@@ -10,107 +10,31 @@
|
||||
<!-- `Agent, team, and priority assignment -->
|
||||
<AccordionContent class="space-y-4 p-4">
|
||||
<!-- Agent assignment -->
|
||||
<ComboBox
|
||||
v-model="assignedUserID"
|
||||
<SelectComboBox
|
||||
v-model="conversationStore.current.assigned_user_id"
|
||||
:items="[{ value: 'none', label: 'None' }, ...usersStore.options]"
|
||||
:placeholder="t('form.field.selectAgent')"
|
||||
@select="selectAgent"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div class="flex items-center gap-3 py-2">
|
||||
<Avatar class="w-8 h-8">
|
||||
<AvatarImage
|
||||
:src="item.value === 'none' ? '' : item.avatar_url || ''"
|
||||
:alt="item.value === 'none' ? 'N' : item.label.slice(0, 2)"
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{{ item.value === 'none' ? 'N' : item.label.slice(0, 2).toUpperCase() }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span class="text-sm">{{ item.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #selected="{ selected }">
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar class="w-7 h-7" v-if="selected">
|
||||
<AvatarImage
|
||||
:src="selected?.value === 'none' ? '' : selected?.avatar_url || ''"
|
||||
:alt="selected?.value === 'none' ? 'N' : selected?.label?.slice(0, 2)"
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{{
|
||||
selected?.value === 'none' ? 'N' : selected?.label?.slice(0, 2)?.toUpperCase()
|
||||
}}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span class="text-sm">{{ selected?.label || t('form.field.assignAgent') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</ComboBox>
|
||||
type="user"
|
||||
/>
|
||||
|
||||
<!-- Team assignment -->
|
||||
<ComboBox
|
||||
v-model="assignedTeamID"
|
||||
<SelectComboBox
|
||||
v-model="conversationStore.current.assigned_team_id"
|
||||
:items="[{ value: 'none', label: 'None' }, ...teamsStore.options]"
|
||||
:placeholder="t('form.field.selectTeam')"
|
||||
@select="selectTeam"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div class="flex items-center gap-3 py-2">
|
||||
<div class="w-7 h-7 flex items-center justify-center">
|
||||
<span v-if="item.emoji">{{ item.emoji }}</span>
|
||||
<div
|
||||
v-else
|
||||
class="text-primary bg-muted rounded-full w-7 h-7 flex items-center justify-center"
|
||||
>
|
||||
<Users size="14" />
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm">{{ item.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #selected="{ selected }">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-7 h-7 flex items-center justify-center" v-if="selected">
|
||||
{{ selected?.emoji }}
|
||||
</div>
|
||||
<span class="text-sm">{{ selected?.label || t('form.field.assignTeam') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</ComboBox>
|
||||
type="team"
|
||||
/>
|
||||
|
||||
<!-- Priority assignment -->
|
||||
<ComboBox
|
||||
v-model="priorityID"
|
||||
<SelectComboBox
|
||||
v-model="conversationStore.current.priority_id"
|
||||
:items="priorityOptions"
|
||||
:placeholder="t('form.field.selectPriority')"
|
||||
@select="selectPriority"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div class="flex items-center gap-3 py-2">
|
||||
<div
|
||||
class="w-7 h-7 flex items-center text-center justify-center bg-muted rounded-full"
|
||||
>
|
||||
<component :is="getPriorityIcon(item.value)" size="14" />
|
||||
</div>
|
||||
<span class="text-sm">{{ item.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #selected="{ selected }">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-7 h-7 flex items-center text-center justify-center bg-muted rounded-full"
|
||||
v-if="selected"
|
||||
>
|
||||
<component :is="getPriorityIcon(selected?.value)" size="14" />
|
||||
</div>
|
||||
<span class="text-sm">{{ selected?.label || t('form.field.selectPriority') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</ComboBox>
|
||||
type="priority"
|
||||
/>
|
||||
|
||||
<!-- Tags assignment -->
|
||||
<SelectTag
|
||||
@@ -169,7 +93,6 @@ import { ref, onMounted, watch, computed } from 'vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
import { useTeamStore } from '@/stores/team'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
@@ -178,17 +101,16 @@ import {
|
||||
} from '@/components/ui/accordion'
|
||||
import ConversationInfo from './ConversationInfo.vue'
|
||||
import ConversationSideBarContact from '@/features/conversation/sidebar/ConversationSideBarContact.vue'
|
||||
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
|
||||
import { SelectTag } from '@/components/ui/select'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
|
||||
import { useEmitter } from '@/composables/useEmitter'
|
||||
import { CircleAlert, SignalLow, SignalMedium, SignalHigh, Users } from 'lucide-vue-next'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import CustomAttributes from '@/features/conversation/sidebar/CustomAttributes.vue'
|
||||
import { useCustomAttributeStore } from '@/stores/customAttributes'
|
||||
import PreviousConversations from '@/features/conversation/sidebar/PreviousConversations.vue'
|
||||
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
|
||||
import api from '@/api'
|
||||
|
||||
const customAttributeStore = useCustomAttributeStore()
|
||||
@@ -246,9 +168,6 @@ watch(
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
const assignedUserID = computed(() => String(conversationStore.current?.assigned_user_id))
|
||||
const assignedTeamID = computed(() => String(conversationStore.current?.assigned_team_id))
|
||||
const priorityID = computed(() => String(conversationStore.current?.priority_id))
|
||||
const priorityOptions = computed(() => conversationStore.priorityOptions)
|
||||
|
||||
const fetchTags = async () => {
|
||||
@@ -288,7 +207,6 @@ const selectAgent = (agent) => {
|
||||
handleRemoveAssignee('user')
|
||||
return
|
||||
}
|
||||
if (conversationStore.current.assigned_user_id == agent.value) return
|
||||
conversationStore.current.assigned_user_id = agent.value
|
||||
handleAssignedUserChange(agent.value)
|
||||
}
|
||||
@@ -298,31 +216,15 @@ const selectTeam = (team) => {
|
||||
handleRemoveAssignee('team')
|
||||
return
|
||||
}
|
||||
if (conversationStore.current.assigned_team_id == team.value) return
|
||||
conversationStore.current.assigned_team_id = team.value
|
||||
handleAssignedTeamChange(team.value)
|
||||
}
|
||||
|
||||
const selectPriority = (priority) => {
|
||||
if (conversationStore.current.priority_id == priority.value) return
|
||||
conversationStore.current.priority = priority.label
|
||||
conversationStore.current.priority_id = priority.value
|
||||
handlePriorityChange(priority.label)
|
||||
}
|
||||
|
||||
const getPriorityIcon = (value) => {
|
||||
switch (value) {
|
||||
case '1':
|
||||
return SignalLow
|
||||
case '2':
|
||||
return SignalMedium
|
||||
case '3':
|
||||
return SignalHigh
|
||||
default:
|
||||
return CircleAlert
|
||||
}
|
||||
}
|
||||
|
||||
const updateContactCustomAttributes = async (attributes) => {
|
||||
let previousAttributes = conversationStore.current.contact.custom_attributes
|
||||
try {
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
<button
|
||||
v-if="!conversationSidebarOpen"
|
||||
@click="toggleSidebar"
|
||||
class="absolute right-full top-16 p-2 rounded-l-full bg-background text-primary hover:bg-opacity-90 transition-all duration-200 shadow-lg group dark:border"
|
||||
class="absolute right-full top-16 p-2 rounded-l-full bg-background text-primary hover:bg-opacity-90 transition-all duration-200 border shadow hover:scale-105"
|
||||
>
|
||||
<ChevronLeft size="16" class="group-hover:scale-110 transition-transform" />
|
||||
<ChevronLeft size="16"/>
|
||||
</button>
|
||||
</Sidebar>
|
||||
</SidebarProvider>
|
||||
|
||||
@@ -196,7 +196,7 @@ const getValidationSchema = (attribute) => {
|
||||
z
|
||||
.number({
|
||||
invalid_type_error: t('globals.messages.invalid', {
|
||||
name: t('form.field.value').toLowerCase()
|
||||
name: t('globals.terms.value').toLowerCase()
|
||||
})
|
||||
})
|
||||
.nullable()
|
||||
@@ -209,7 +209,7 @@ const getValidationSchema = (attribute) => {
|
||||
.refine(
|
||||
(val) => !isNaN(Date.parse(val)),
|
||||
t('globals.messages.invalid', {
|
||||
name: t('form.field.value').toLowerCase()
|
||||
name: t('globals.terms.value').toLowerCase()
|
||||
})
|
||||
)
|
||||
.nullable()
|
||||
@@ -227,7 +227,7 @@ const getValidationSchema = (attribute) => {
|
||||
.string()
|
||||
.refine((val) => attribute.values.includes(val), {
|
||||
message: t('globals.messages.invalid', {
|
||||
name: t('form.field.value').toLowerCase()
|
||||
name: t('globals.terms.value').toLowerCase()
|
||||
})
|
||||
})
|
||||
.nullable()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, reactive, ref, nextTick, watchEffect } from 'vue'
|
||||
import { computed, reactive, ref, watchEffect } from 'vue'
|
||||
import { CONVERSATION_LIST_TYPE, CONVERSATION_DEFAULT_STATUSES } from '@/constants/conversation'
|
||||
import { handleHTTPError } from '@/utils/http'
|
||||
import { computeRecipientsFromMessage } from '@/utils/email-recipients'
|
||||
@@ -16,6 +16,7 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
const currentTo = ref([])
|
||||
const currentBCC = ref([])
|
||||
const currentCC = ref([])
|
||||
const macros = ref({})
|
||||
|
||||
// Options for select fields
|
||||
const priorityOptions = computed(() => {
|
||||
@@ -99,8 +100,6 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
const conversation = reactive({
|
||||
data: null,
|
||||
participants: {},
|
||||
mediaFiles: [],
|
||||
macro: {},
|
||||
loading: false,
|
||||
errorMessage: ''
|
||||
})
|
||||
@@ -118,25 +117,6 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
|
||||
const incrementMessageVersion = () => setTimeout(() => messages.version++, 0)
|
||||
|
||||
async function setMacro (macro) {
|
||||
// Clear existing macro.
|
||||
conversation.macro = {}
|
||||
await nextTick()
|
||||
conversation.macro = macro
|
||||
}
|
||||
|
||||
function removeMacroAction (action) {
|
||||
conversation.macro.actions = conversation.macro.actions.filter(a => a.type !== action.type)
|
||||
}
|
||||
|
||||
function resetMacro () {
|
||||
conversation.macro = {}
|
||||
}
|
||||
|
||||
function resetMediaFiles () {
|
||||
conversation.mediaFiles = []
|
||||
}
|
||||
|
||||
function setListStatus (status, fetch = true) {
|
||||
conversations.status = status
|
||||
if (fetch) {
|
||||
@@ -646,7 +626,6 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
Object.assign(conversation, {
|
||||
data: null,
|
||||
participants: {},
|
||||
mediaFiles: [],
|
||||
macro: {},
|
||||
loading: false,
|
||||
errorMessage: ''
|
||||
@@ -660,6 +639,24 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
}
|
||||
|
||||
|
||||
/** Macros for new conversation or open conversation **/
|
||||
async function setMacro (macro, context) {
|
||||
macros.value[context] = macro
|
||||
}
|
||||
|
||||
function getMacro (context) {
|
||||
return macros.value[context] || {}
|
||||
}
|
||||
|
||||
function removeMacroAction (action, context) {
|
||||
if (!macros.value[context]) return
|
||||
macros.value[context].actions = macros.value[context].actions.filter(a => a.type !== action.type)
|
||||
}
|
||||
|
||||
function resetMacro (context) {
|
||||
macros.value = { ...macros.value, [context]: {} }
|
||||
}
|
||||
|
||||
return {
|
||||
conversations,
|
||||
conversation,
|
||||
@@ -699,9 +696,9 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
setListSortField,
|
||||
setListStatus,
|
||||
removeMacroAction,
|
||||
getMacro,
|
||||
setMacro,
|
||||
resetMacro,
|
||||
resetMediaFiles,
|
||||
removeAssignee,
|
||||
getListSortField,
|
||||
getListStatus,
|
||||
|
||||
@@ -7,11 +7,11 @@ import { useUserStore } from './user'
|
||||
import api from '@/api'
|
||||
import { permissions as perms } from '@/constants/permissions.js'
|
||||
|
||||
|
||||
export const useMacroStore = defineStore('macroStore', () => {
|
||||
const macroList = ref([])
|
||||
const emitter = useEmitter()
|
||||
const userStore = useUserStore()
|
||||
const currentView = ref('')
|
||||
|
||||
// actionPermissions is a map of action names to their corresponding permissions that a user must have to perform the action.
|
||||
const actionPermissions = {
|
||||
@@ -34,6 +34,14 @@ export const useMacroStore = defineStore('macroStore', () => {
|
||||
userTeams.includes(macro.team_id) ||
|
||||
String(macro.user_id) === String(userStore.userID)
|
||||
)
|
||||
|
||||
// Filter by visible_when if currentView is set.
|
||||
if (currentView.value) {
|
||||
filtered = filtered.filter(macro =>
|
||||
!macro.visible_when?.length || macro.visible_when.includes(currentView.value)
|
||||
)
|
||||
}
|
||||
|
||||
// Filter macros based on permissions.
|
||||
filtered.forEach(macro => {
|
||||
macro.actions = macro.actions.filter(action => {
|
||||
@@ -42,14 +50,17 @@ export const useMacroStore = defineStore('macroStore', () => {
|
||||
return userStore.can(permission)
|
||||
})
|
||||
})
|
||||
|
||||
// Skip macros that do not have any actions left AND the macro field `message_content` is empty.
|
||||
filtered = filtered.filter(macro => !(macro.actions.length === 0 && macro.message_content === ""))
|
||||
|
||||
return filtered.map(macro => ({
|
||||
...macro,
|
||||
label: macro.name,
|
||||
value: String(macro.id),
|
||||
}))
|
||||
})
|
||||
|
||||
const loadMacros = async () => {
|
||||
if (macroList.value.length) return
|
||||
try {
|
||||
@@ -62,9 +73,15 @@ export const useMacroStore = defineStore('macroStore', () => {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const setCurrentView = (view) => {
|
||||
currentView.value = view
|
||||
}
|
||||
|
||||
return {
|
||||
macroList,
|
||||
macroOptions,
|
||||
loadMacros,
|
||||
setCurrentView
|
||||
}
|
||||
})
|
||||
+18
-19
@@ -93,8 +93,15 @@
|
||||
"globals.terms.security": "Security | Security",
|
||||
"globals.terms.myInbox": "My Inbox | My Inboxes",
|
||||
"globals.terms.teamInbox": "Team Inbox | Team Inboxes",
|
||||
"globals.terms.optional": "Optional | Optionals",
|
||||
"globals.terms.visibility": "Visibility | Visibilities",
|
||||
"globals.terms.privateNote": "Private note | Private notes",
|
||||
"globals.terms.automationRule": "Automation Rule | Automation Rules",
|
||||
"globals.terms.subject": "Subject | Subjects",
|
||||
"globals.messages.replying": "Replying",
|
||||
"globals.messages.golangDurationHoursMinutes": "Duration in hours or minutes. Example: 1h, 30m, 1h30m",
|
||||
"globals.messages.badRequest": "Bad request",
|
||||
"globals.messages.visibleWhen": "Visible when",
|
||||
"globals.messages.adjustFilters": "Try adjusting filters",
|
||||
"globals.messages.errorUpdating": "Error updating {name}",
|
||||
"globals.messages.errorCreating": "Error creating {name}",
|
||||
@@ -132,21 +139,21 @@
|
||||
"globals.messages.create": "Create {name}",
|
||||
"globals.messages.new": "New {name}",
|
||||
"globals.messages.add": "Add {name}",
|
||||
"globals.messages.adding": "Adding {name}",
|
||||
"globals.messages.starting": "Starting {name}",
|
||||
"globals.messages.all": "All {name}",
|
||||
"globals.messages.denied": "{name} denied",
|
||||
"globals.messages.noResults": "No {name} found",
|
||||
"globals.messages.enter": "Enter {name}",
|
||||
"globals.messages.yes": "Yes",
|
||||
"globals.messages.no": "No",
|
||||
"globals.messages.no": "No {name}",
|
||||
"globals.messages.type": "{name} type",
|
||||
"globals.messages.typeOf": "Type of {name}",
|
||||
"globals.messages.invalidEmailAddress": "Invalid email address",
|
||||
"globals.messages.pleaseSelectAtLeastOne": "Please select at least one {name}",
|
||||
"globals.messages.strongPassword": "Password must be between {min} and {max} characters long, should contain at least one uppercase letter, one lowercase letter, one number, and one special character.",
|
||||
"globals.messages.couldNotReload": "Could not reload {name}. Please restart the app",
|
||||
"globals.messages.invalid": "Invalid {name}",
|
||||
"globals.messages.disabled": "{name} is disabled",
|
||||
"globals.messages.fieldRequired": "{name} required",
|
||||
"globals.messages.required": "Required",
|
||||
"globals.messages.couldNotReload": "Could not reload {name}",
|
||||
"globals.messages.required": "{name} Required",
|
||||
"globals.messages.invalidPortNumber": "Invalid port number",
|
||||
"globals.messages.mustBeNumber": "Must be a number",
|
||||
"globals.messages.fileTypeisNotAnImage": "File type is not an image",
|
||||
@@ -170,6 +177,7 @@
|
||||
"globals.messages.snooze": "Snooze",
|
||||
"globals.messages.resolve": "Resolve",
|
||||
"globals.messages.applyMacro": "Apply macro",
|
||||
"globals.messages.deletionConfirmation": "This action cannot be undone. This will permanently delete this {name}.",
|
||||
"globals.messages.atleastOneRecipient": "At least one recipient is required",
|
||||
"globals.messages.startTypingToSearch": "Start typing to search...",
|
||||
"globals.messages.goHourMinuteDuration": "Invalid duration format. Should be a number followed by h (hours), m (minutes).",
|
||||
@@ -256,7 +264,6 @@
|
||||
"form.field.clientID": "Client ID",
|
||||
"form.field.clientSecret": "Client Secret",
|
||||
"form.field.callbackURL": "Callback URL",
|
||||
"form.field.subject": "Subject",
|
||||
"form.field.referenceNumber": "Reference Number",
|
||||
"form.field.initiatedAt": "Initiated at",
|
||||
"form.field.firstReplyAt": "First Reply At",
|
||||
@@ -311,15 +318,12 @@
|
||||
"form.field.addNewAction": "Add new action",
|
||||
"form.field.selectType": "Select type",
|
||||
"form.field.selectInbox": "Select an inbox",
|
||||
"form.field.assignTeamOptional": "Assign team (optional)",
|
||||
"form.field.assignAgentOptional": "Assign agent (optional)",
|
||||
"form.field.assignAgent": "Assign agent",
|
||||
"form.field.assignTeam": "Assign team",
|
||||
"form.field.message": "Message",
|
||||
"form.field.setValue": "Set value",
|
||||
"form.field.selectEvents": "Select events",
|
||||
"form.field.selectOperator": "Select operator",
|
||||
"form.field.value": "Value",
|
||||
"form.error.min": "Must be at least {min} characters",
|
||||
"form.error.max": "Must be at most {max} characters",
|
||||
"form.error.minmax": "Must be between {min} and {max} characters",
|
||||
@@ -387,13 +391,8 @@
|
||||
"admin.conversationTags.deleteConfirmation": "This action cannot be undone. This will permanently delete this tag, and remove it from all conversations.",
|
||||
"admin.macro.messageContent": "Response to be sent when macro is used (optional)",
|
||||
"admin.macro.actions": "Actions (optional)",
|
||||
"admin.macro.visibility": "Visibility",
|
||||
"admin.macro.visibility.all": "All users",
|
||||
"admin.macro.messageOrActionRequired": "Either message content or actions are required",
|
||||
"admin.macro.actionTypeRequired": "Action type is required",
|
||||
"admin.macro.actionValueRequired": "Action value is required",
|
||||
"admin.macro.teamOrUserRequired": "team is required when visibility is `team` & a user is required when visibility is `user`",
|
||||
"admin.macro.deleteConfirmation": "This action cannot be undone. This will permanently delete this macro.",
|
||||
"admin.macro.actionInvalid": "Each action must have a type and a value",
|
||||
"admin.conversationStatus.name.description": "Set status name. Click save when you're done.",
|
||||
"admin.conversationStatus.deleteConfirmation": "This action cannot be undone. This will permanently delete this status.",
|
||||
"admin.inbox.name.description": "Name for your inbox.",
|
||||
@@ -590,14 +589,14 @@
|
||||
"conversation.sidebar.previousConvo": "Previous conversations",
|
||||
"conversation.sidebar.noPreviousConvo": "No previous conversations",
|
||||
"conversation.sidebar.notAvailable": "Not available",
|
||||
"editor.placeholder": "Shift + Enter to add a new line",
|
||||
"editor.newLine": "Shift + Enter to add a new line. ",
|
||||
"editor.send": " Cmd + Enter to send. ",
|
||||
"editor.cmdK": "Cmd + K to open command bar. ",
|
||||
"ai.apiKeyNotSet": "{provider} API Key is not set. Please ask administrator to set it up",
|
||||
"ai.enterOpenAIAPIKey": "Enter OpenAI API Key",
|
||||
"ai.apiKey.description": "{provider} API Key is not set or invalid. Please enter a valid API key to use AI features.",
|
||||
"replyBox.reply": "Reply",
|
||||
"replyBox.privateNote": "Private note",
|
||||
"replyBox.emailAddresess": "Email addresses separated by comma",
|
||||
"replyBox.editor.placeholder": "Shift + Enter to add a new line. Cmd + Enter to send. Cmd + K to open command bar.",
|
||||
"replyBox.invalidEmailsIn": "Invalid email(s) in",
|
||||
"replyBox.correctEmailErrors": "Please correct the email errors before sending.",
|
||||
"contact.blockConfirm": "Are you sure you want to block this contact? They will no longer be able to interact with you.",
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/abhinavxd/libredesk/internal/macro/models"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/go-i18n"
|
||||
"github.com/lib/pq"
|
||||
"github.com/zerodha/logf"
|
||||
)
|
||||
|
||||
@@ -67,8 +68,8 @@ func (m *Manager) Get(id int) (models.Macro, error) {
|
||||
}
|
||||
|
||||
// Create adds a new macro.
|
||||
func (m *Manager) Create(name, messageContent string, userID, teamID *int, visibility string, actions json.RawMessage) error {
|
||||
_, err := m.q.Create.Exec(name, messageContent, userID, teamID, visibility, actions)
|
||||
func (m *Manager) Create(name, messageContent string, userID, teamID *int, visibility string, visibleWhen []string, actions json.RawMessage) error {
|
||||
_, err := m.q.Create.Exec(name, messageContent, userID, teamID, visibility, pq.StringArray(visibleWhen), actions)
|
||||
if err != nil {
|
||||
m.lo.Error("error creating macro", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.macro}"), nil)
|
||||
@@ -77,8 +78,8 @@ func (m *Manager) Create(name, messageContent string, userID, teamID *int, visib
|
||||
}
|
||||
|
||||
// Update modifies an existing macro.
|
||||
func (m *Manager) Update(id int, name, messageContent string, userID, teamID *int, visibility string, actions json.RawMessage) error {
|
||||
result, err := m.q.Update.Exec(id, name, messageContent, userID, teamID, visibility, actions)
|
||||
func (m *Manager) Update(id int, name, messageContent string, userID, teamID *int, visibility string, visibleWhen []string, actions json.RawMessage) error {
|
||||
result, err := m.q.Update.Exec(id, name, messageContent, userID, teamID, visibility, pq.StringArray(visibleWhen), actions)
|
||||
if err != nil {
|
||||
m.lo.Error("error updating macro", "error", err)
|
||||
return envelope.NewError(envelope.GeneralError, m.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.macro}"), nil)
|
||||
|
||||
@@ -3,6 +3,8 @@ package models
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
type Macro struct {
|
||||
@@ -11,7 +13,8 @@ type Macro struct {
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
Name string `db:"name" json:"name"`
|
||||
MessageContent string `db:"message_content" json:"message_content"`
|
||||
Visibility string `db:"visibility" json:"visibility"`
|
||||
VisibleWhen pq.StringArray `db:"visible_when" json:"visible_when"`
|
||||
Visibility string `db:"visibility" json:"visibility"`
|
||||
UserID *int `db:"user_id" json:"user_id,string"`
|
||||
TeamID *int `db:"team_id" json:"team_id,string"`
|
||||
UsageCount int `db:"usage_count" json:"usage_count"`
|
||||
|
||||
@@ -9,6 +9,7 @@ SELECT
|
||||
user_id,
|
||||
team_id,
|
||||
actions,
|
||||
visible_when,
|
||||
usage_count
|
||||
FROM
|
||||
macros
|
||||
@@ -26,6 +27,7 @@ SELECT
|
||||
user_id,
|
||||
team_id,
|
||||
actions,
|
||||
visible_when,
|
||||
usage_count
|
||||
FROM
|
||||
macros
|
||||
@@ -34,9 +36,9 @@ ORDER BY
|
||||
|
||||
-- name: create
|
||||
INSERT INTO
|
||||
macros (name, message_content, user_id, team_id, visibility, actions)
|
||||
macros (name, message_content, user_id, team_id, visibility, visible_when, actions)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6);
|
||||
($1, $2, $3, $4, $5, $6, $7);
|
||||
|
||||
-- name: update
|
||||
UPDATE
|
||||
@@ -47,7 +49,8 @@ SET
|
||||
user_id = $4,
|
||||
team_id = $5,
|
||||
visibility = $6,
|
||||
actions = $7,
|
||||
visible_when = $7,
|
||||
actions = $8,
|
||||
updated_at = NOW()
|
||||
WHERE
|
||||
id = $1;
|
||||
@@ -62,6 +65,7 @@ WHERE
|
||||
UPDATE
|
||||
macros
|
||||
SET
|
||||
usage_count = usage_count + 1
|
||||
usage_count = usage_count + 1,
|
||||
updated_at = NOW()
|
||||
WHERE
|
||||
id = $1;
|
||||
@@ -287,5 +287,29 @@ func V0_6_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add macro macro_visible_when enum type if it doesn't exist
|
||||
_, err = db.Exec(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_type WHERE typname = 'macro_visible_when'
|
||||
) THEN
|
||||
CREATE TYPE macro_visible_when AS ENUM ('replying', 'starting_conversation', 'adding_private_note');
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add visible_when column to macros table if it doesn't exist
|
||||
_, err = db.Exec(`
|
||||
ALTER TABLE macros
|
||||
ADD COLUMN IF NOT EXISTS visible_when macro_visible_when[] NOT NULL DEFAULT ARRAY['replying', 'starting_conversation', 'adding_private_note']::macro_visible_when[];
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ DROP TYPE IF EXISTS "sla_event_status" CASCADE; CREATE TYPE "sla_event_status" A
|
||||
DROP TYPE IF EXISTS "sla_metric" CASCADE; CREATE TYPE "sla_metric" AS ENUM ('first_response', 'resolution', 'next_response');
|
||||
DROP TYPE IF EXISTS "sla_notification_type" CASCADE; CREATE TYPE "sla_notification_type" AS ENUM ('warning', 'breach');
|
||||
DROP TYPE IF EXISTS "activity_log_type" CASCADE; CREATE TYPE "activity_log_type" AS ENUM ('agent_login', 'agent_logout', 'agent_away', 'agent_away_reassigned', 'agent_online');
|
||||
DROP TYPE IF EXISTS "macro_visible_when" CASCADE; CREATE TYPE "macro_visible_when" AS ENUM ('replying', 'starting_conversation', 'adding_private_note');
|
||||
|
||||
-- Sequence to generate reference number for conversations.
|
||||
DROP SEQUENCE IF EXISTS conversation_reference_number_sequence; CREATE SEQUENCE conversation_reference_number_sequence START 100;
|
||||
@@ -291,6 +292,7 @@ CREATE TABLE macros (
|
||||
name TEXT NOT NULL,
|
||||
actions JSONB DEFAULT '{}'::jsonb NOT NULL,
|
||||
visibility macro_visibility NOT NULL,
|
||||
visible_when macro_visible_when[] NOT NULL DEFAULT ARRAY['replying', 'starting_conversation', 'adding_private_note']::macro_visible_when[],
|
||||
message_content TEXT NOT NULL,
|
||||
-- Cascade deletes when user is deleted.
|
||||
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
|
||||
Reference in New Issue
Block a user