Merge remote-tracking branch 'origin/main' into pr/sidebar-counts

# Conflicts:
#	frontend/apps/main/src/stores/conversation.js
#	internal/conversation/conversation.go
This commit is contained in:
Abhinav Raut
2026-09-02 00:45:50 +05:30
124 changed files with 5881 additions and 822 deletions
+2 -1
View File
@@ -57,6 +57,7 @@ type createConversationRequest struct {
Content string `json:"content"`
Attachments []int `json:"attachments"`
Initiator string `json:"initiator"` // "contact" | "agent"
SourceID string `json:"source_id"` // RFC 5322 Message-ID of the inbound message; stored on the created contact message so replies thread on it. Contact-initiated only.
CustomAttributes map[string]any `json:"custom_attributes"`
}
@@ -880,7 +881,7 @@ func handleCreateConversation(r *fastglue.Request) error {
}
case umodels.UserTypeContact:
// Create contact message.
if _, err := app.conversation.CreateContactMessage(media, contact.ID, conversationUUID, req.Content, cmodels.ContentTypeHTML, true); err != nil {
if _, err := app.conversation.CreateContactMessage(media, contact.ID, conversationUUID, req.Content, cmodels.ContentTypeHTML, true, req.SourceID); err != nil {
// Delete the conversation if message creation fails.
if err := app.conversation.DeleteConversation(conversationUUID); err != nil {
app.lo.Error("error deleting conversation", "error", err)
+46 -5
View File
@@ -14,7 +14,10 @@ import (
"github.com/zerodha/fastglue"
)
const maxPageSize = 500
const (
maxPageSize = 500
maxIDsParam = 200
)
// initHandlers initializes the HTTP routes and handlers for the application.
func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
@@ -71,9 +74,9 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
g.GET("/api/v1/conversations/{cuuid}/messages/{uuid}", perm(handleGetMessage, "messages:read"))
g.GET("/api/v1/conversations/{uuid}/messages", perm(handleGetMessages, "messages:read"))
g.GET("/api/v1/conversations/{uuid}/transcript", perm(handleDownloadConversationTranscript, "messages:read"))
g.POST("/api/v1/conversations/{cuuid}/messages", perm(handleSendMessage, "messages:write"))
g.POST("/api/v1/conversations/{cuuid}/messages", auth(handleSendMessage))
g.PUT("/api/v1/conversations/{cuuid}/messages/{uuid}/retry", perm(handleRetryMessage, "messages:write"))
g.DELETE("/api/v1/conversations/{cuuid}/messages/{uuid}", perm(handleDeleteMessage, "messages:write"))
g.DELETE("/api/v1/conversations/{cuuid}/messages/{uuid}", perm(handleDeleteMessage, "messages:write_private"))
g.POST("/api/v1/conversations", perm(handleCreateConversation, "conversations:write"))
g.PUT("/api/v1/conversations/{uuid}/custom-attributes", auth(handleUpdateConversationCustomAttributes))
g.PUT("/api/v1/conversations/{uuid}/contacts/custom-attributes", auth(handleUpdateContactCustomAttributes))
@@ -118,7 +121,9 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// Macros.
g.GET("/api/v1/macros", auth(handleGetMacros))
g.GET("/api/v1/macros/{id}", perm(handleGetMacro, "macros:manage"))
g.GET("/api/v1/macros/compact", perm(handleGetMacrosCompact, "macros:manage"))
g.GET("/api/v1/macros/search", auth(handleSearchMacros))
g.GET("/api/v1/macros/{id}", auth(handleGetMacro))
g.POST("/api/v1/macros", perm(handleCreateMacro, "macros:manage"))
g.PUT("/api/v1/macros/{id}", perm(handleUpdateMacro, "macros:manage"))
g.DELETE("/api/v1/macros/{id}", perm(handleDeleteMacro, "macros:manage"))
@@ -268,7 +273,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) {
// AI assistant: reply drafting + copilot chat.
g.POST("/api/v1/ai/generate-reply", auth(handleAIGenerateReply))
g.POST("/api/v1/ai/summarize", perm(handleAISummarizeConversation, "messages:write"))
g.POST("/api/v1/ai/summarize", perm(handleAISummarizeConversation, "messages:write_private"))
g.POST("/api/v1/ai/suggest-tags", auth(handleAISuggestTags))
g.POST("/api/v1/ai/copilot", auth(handleAICopilot))
g.GET("/api/v1/ai/copilot/messages", auth(handleGetCopilotMessages))
@@ -546,6 +551,26 @@ func serveWidgetJS(r *fastglue.Request) error {
return nil
}
// getIDsParam parses a comma separated list of positive IDs from a query param.
func getIDsParam(r *fastglue.Request, name string) []int {
raw := strings.TrimSpace(string(r.RequestCtx.QueryArgs().Peek(name)))
if raw == "" {
return nil
}
var ids []int
for part := range strings.SplitSeq(raw, ",") {
id, err := strconv.Atoi(strings.TrimSpace(part))
if err != nil || id <= 0 {
continue
}
ids = append(ids, id)
if len(ids) == maxIDsParam {
break
}
}
return ids
}
// getPagination extracts page and page_size from query params with defaults.
func getPagination(r *fastglue.Request) (page, pageSize int) {
page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
@@ -562,6 +587,22 @@ func getPagination(r *fastglue.Request) (page, pageSize int) {
return page, pageSize
}
// getOptionalPagination reads page/page_size, returning pageSize 0 (fetch everything) when absent.
func getOptionalPagination(r *fastglue.Request) (page, pageSize int) {
page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page")))
pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size")))
if page < 1 {
page = 1
}
if pageSize < 0 {
pageSize = 0
}
if pageSize > maxPageSize {
pageSize = maxPageSize
}
return page, pageSize
}
// sendErrorEnvelope sends a standardized error response to the client.
func sendErrorEnvelope(r *fastglue.Request, err error) error {
e, ok := err.(envelope.Error)
+26 -2
View File
@@ -22,6 +22,10 @@ const (
var (
localeI18nMu sync.Mutex
localeI18nCache = map[string]*i18n.I18n{}
// Keyed by resolved language code
i18nJSONMu sync.Mutex
i18nJSONCache = map[string][]byte{}
)
// handleGetI18nLang returns the JSON language pack for the given language code.
@@ -30,11 +34,14 @@ func handleGetI18nLang(r *fastglue.Request) error {
app = r.Context.(*App)
lang = r.RequestCtx.UserValue("lang").(string)
)
i, err := loadI18nLang(lang, app.fs)
b, err := i18nLangJSON(app, lang)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendBytes(http.StatusOK, "application/json", i.JSON())
r.RequestCtx.Response.Header.SetContentType("application/json")
r.RequestCtx.Response.SetStatusCode(http.StatusOK)
r.RequestCtx.Response.SetBodyRaw(b)
return nil
}
// handleGetAvailableLanguages returns the list of available languages
@@ -96,6 +103,23 @@ func localeI18n(app *App, locale string) *i18n.I18n {
return i
}
// i18nLangJSON returns the marshaled language pack for a language code, cached per resolved code.
func i18nLangJSON(app *App, lang string) ([]byte, error) {
code := matchLangFile(app.fs, strings.ToLower(strings.TrimSpace(lang)))
i18nJSONMu.Lock()
defer i18nJSONMu.Unlock()
if b, ok := i18nJSONCache[code]; ok {
return b, nil
}
i, err := loadI18nLang(code, app.fs)
if err != nil {
return nil, err
}
b := i.JSON()
i18nJSONCache[code] = b
return b, nil
}
// matchLangFile finds the language pack code for a locale: exact match first, then language prefix.
func matchLangFile(fs stuffbin.FileSystem, loc string) string {
files, err := fs.Glob("/i18n/*.json")
+3 -2
View File
@@ -860,7 +860,8 @@ func reloadAuth(app *App) error {
app.lo.Info("reloading auth manager")
providers, err := buildProviders(app.oidc)
if err != nil {
log.Fatalf("error reloading auth: %v", err)
app.lo.Error("error reloading auth", "error", err)
return err
}
if err := app.auth.Reload(auth_.Config{Providers: providers}); err != nil {
app.lo.Error("error reloading auth", "error", err)
@@ -885,7 +886,7 @@ func buildProviders(o *oidc.Manager) ([]auth_.Provider, error) {
ID: config.ID,
Provider: config.Provider,
ProviderURL: config.ProviderURL,
RedirectURL: config.RedirectURI,
RedirectURL: func() (string, error) { return o.RedirectURL(config.ID) },
ClientID: config.ClientID,
ClientSecret: config.ClientSecret,
})
+64 -20
View File
@@ -21,17 +21,58 @@ func handleGetMacros(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
for i, m := range macros {
var actions []autoModels.RuleAction
if err := json.Unmarshal(m.Actions, &actions); err != nil {
app.lo.Error("error unmarshalling macro actions", "macro_id", m.ID, "error", err)
if macros[i].Actions, err = decorateMacroActions(app, m.Actions); err != nil {
app.lo.Error("error decorating macro actions", "macro_id", m.ID, "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError)
}
// Set display values for actions as the value field can contain DB IDs
if err := setDisplayValues(app, actions); err != nil {
app.lo.Warn("error setting display values", "error", err)
}
return r.SendEnvelope(macros)
}
// handleGetMacrosCompact returns macros without message content, all of them without page params.
func handleGetMacrosCompact(r *fastglue.Request) error {
var (
app = r.Context.(*App)
query = string(r.RequestCtx.QueryArgs().Peek("q"))
)
page, pageSize := getOptionalPagination(r)
macros, err := app.macro.GetAllCompact(query, page, pageSize)
if err != nil {
return sendErrorEnvelope(r, err)
}
for i, m := range macros {
if macros[i].Actions, err = decorateMacroActions(app, m.Actions); err != nil {
app.lo.Error("error decorating macro actions", "macro_id", m.ID, "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError)
}
if macros[i].Actions, err = json.Marshal(actions); err != nil {
app.lo.Error("error marshalling macro actions", "macro_id", m.ID, "error", err)
}
return r.SendEnvelope(macros)
}
// handleSearchMacros returns the top macros without message content visible to the requesting agent.
func handleSearchMacros(r *fastglue.Request) error {
var (
app = r.Context.(*App)
auser = r.RequestCtx.UserValue("user").(amodels.User)
query = string(r.RequestCtx.QueryArgs().Peek("q"))
view = string(r.RequestCtx.QueryArgs().Peek("view"))
)
switch view {
case "", models.VisibleWhenReplying, models.VisibleWhenStartingConversation, models.VisibleWhenAddingPrivateNote:
default:
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError)
}
user, err := app.user.GetAgentCachedOrLoad(auser.ID)
if err != nil {
return sendErrorEnvelope(r, err)
}
macros, err := app.macro.SearchCompact(query, view, user.ID, user.Teams.IDs())
if err != nil {
return sendErrorEnvelope(r, err)
}
for i, m := range macros {
if macros[i].Actions, err = decorateMacroActions(app, m.Actions); err != nil {
app.lo.Error("error decorating macro actions", "macro_id", m.ID, "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError)
}
}
@@ -53,17 +94,8 @@ func handleGetMacro(r *fastglue.Request) error {
return sendErrorEnvelope(r, err)
}
var actions []autoModels.RuleAction
if err := json.Unmarshal(macro.Actions, &actions); err != nil {
app.lo.Error("error unmarshalling macro actions", "macro_id", id, "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError)
}
// Set display values for actions as the value field can contain DB IDs
if err := setDisplayValues(app, actions); err != nil {
app.lo.Warn("error setting display values", "error", err)
}
if macro.Actions, err = json.Marshal(actions); err != nil {
app.lo.Error("error marshalling macro actions", "macro_id", id, "error", err)
if macro.Actions, err = decorateMacroActions(app, macro.Actions); err != nil {
app.lo.Error("error decorating macro actions", "macro_id", id, "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError)
}
@@ -226,6 +258,18 @@ func hasActionPermission(action string, userPerms []string) bool {
return slices.Contains(userPerms, requiredPerm)
}
// decorateMacroActions resolves display values for action IDs and re-marshals the actions.
func decorateMacroActions(app *App, raw json.RawMessage) (json.RawMessage, error) {
var actions []autoModels.RuleAction
if err := json.Unmarshal(raw, &actions); err != nil {
return nil, err
}
if err := setDisplayValues(app, actions); err != nil {
app.lo.Warn("error setting display values", "error", err)
}
return json.Marshal(actions)
}
// setDisplayValues sets display values for actions.
func setDisplayValues(app *App, actions []autoModels.RuleAction) error {
getters := map[string]func(int) (string, error){
@@ -238,7 +282,7 @@ func setDisplayValues(app *App, actions []autoModels.RuleAction) error {
return t.Name, nil
},
autoModels.ActionAssignUser: func(id int) (string, error) {
u, err := app.user.GetAgent(id, "")
u, err := app.user.GetAgentCachedOrLoad(id)
if err != nil {
app.lo.Warn("user not found for macro action", "user_id", id)
return "", err
+55
View File
@@ -0,0 +1,55 @@
package main
import (
"encoding/json"
"testing"
"github.com/abhinavxd/libredesk/internal/macro/models"
"github.com/abhinavxd/libredesk/internal/testutil"
)
// Both list endpoints marshal these structs directly, their JSON shape is the API contract.
func TestMacroJSONShapes(t *testing.T) {
full := testutil.JSONKeys(t, models.Macro{Actions: json.RawMessage(`[]`)})
for _, key := range []string{"id", "name", "actions", "visibility", "visible_when", "message_content", "user_id", "team_id", "usage_count", "created_at", "updated_at"} {
if !full[key] {
t.Errorf("Macro JSON is missing %q", key)
}
}
if full["has_message_content"] {
t.Error("Macro JSON must not have has_message_content")
}
compact := testutil.JSONKeys(t, models.MacroCompact{Actions: json.RawMessage(`[]`)})
for _, key := range []string{"id", "name", "actions", "visibility", "visible_when", "has_message_content", "user_id", "team_id", "usage_count", "created_at", "updated_at"} {
if !compact[key] {
t.Errorf("MacroCompact JSON is missing %q", key)
}
}
if compact["message_content"] {
t.Error("MacroCompact JSON must not have message_content")
}
}
func TestDecorateMacroActions(t *testing.T) {
app := newValidatorTestApp(t)
out, err := decorateMacroActions(app, json.RawMessage(`[{"type":"add_tags","value":["urgent"]}]`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var actions []map[string]any
if err := json.Unmarshal(out, &actions); err != nil {
t.Fatalf("output is not valid JSON: %v", err)
}
if len(actions) != 1 {
t.Fatalf("got %d actions, want 1", len(actions))
}
if _, ok := actions[0]["display_value"]; !ok {
t.Error("decorated action is missing display_value")
}
if _, err := decorateMacroActions(app, json.RawMessage(`{not json`)); err == nil {
t.Error("expected an error for malformed actions JSON")
}
}
+1 -1
View File
@@ -340,7 +340,7 @@ func main() {
g.Router.NotFound = helpCenterHostNotFound(app, g)
// Buffers above this are dropped rather than reused, and the ones we keep stay with the connection until it closes.
fasthttp.SetBodySizePoolLimit(64<<10, 1<<20) // request: 64 KiB, response: 1 MiB
fasthttp.SetBodySizePoolLimit(64<<10, 128<<10) // request: 64 KiB, response: 128 KiB
s := &fasthttp.Server{
Name: appName,
+44 -18
View File
@@ -1,8 +1,10 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime"
"net/http"
"path/filepath"
@@ -26,6 +28,12 @@ import (
// immutable suppresses revalidation, so this is how long a revoked file stays reachable from a client cache.
const mediaCacheTTL = 24 * time.Hour
type preparedImageUpload struct {
thumbnail *bytes.Reader
meta []byte
thumbnailErr error
}
// handleMediaUpload handles media uploads.
func handleMediaUpload(r *fastglue.Request) error {
var (
@@ -123,29 +131,24 @@ func handleMediaUpload(r *fastglue.Request) error {
// Generate and upload thumbnail and store image dimensions in the media meta.
var meta = []byte("{}")
if slices.Contains(image.Exts, srcExt) && image.IsImageByContent(file) {
file.Seek(0, 0)
thumbFile, err := image.CreateThumb(image.DefThumbSize, file)
if err != nil {
app.lo.Error("error creating thumb image", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError)
}
thumbName, _, err = app.media.Upload(thumbName, srcContentType, thumbFile)
if err != nil {
return sendErrorEnvelope(r, err)
}
// Store image dimensions in media meta, storing dimensions for image previews in future.
file.Seek(0, 0)
width, height, err := image.GetDimensions(file)
prepared, err := prepareImageUpload(file)
if err != nil {
cleanUp = true
app.lo.Error("error getting image dimensions", "error", err)
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.errorUploadingFile"), nil, envelope.GeneralError)
}
meta, _ = json.Marshal(map[string]interface{}{
"width": width,
"height": height,
})
if prepared.thumbnailErr != nil {
app.lo.Error("error creating thumb image", "error", prepared.thumbnailErr)
} else {
// A failed upload returns an empty name, keep the original so cleanup can delete a partial file.
uploadedThumb, _, err := app.media.Upload(thumbName, srcContentType, prepared.thumbnail)
if err != nil {
cleanUp = true
return sendErrorEnvelope(r, err)
}
thumbName = uploadedThumb
}
meta = prepared.meta
}
// Reset ptr.
@@ -321,3 +324,26 @@ func cacheVisibility(private bool) string {
}
return "public"
}
func prepareImageUpload(file io.ReadSeeker) (preparedImageUpload, error) {
if _, err := file.Seek(0, io.SeekStart); err != nil {
return preparedImageUpload{}, err
}
thumbnail, thumbnailErr := image.CreateThumb(image.DefThumbSize, file)
if _, err := file.Seek(0, io.SeekStart); err != nil {
return preparedImageUpload{}, err
}
width, height, err := image.GetDimensions(file)
if err != nil {
return preparedImageUpload{}, err
}
meta, err := json.Marshal(map[string]any{
"width": width,
"height": height,
})
if err != nil {
return preparedImageUpload{}, err
}
return preparedImageUpload{thumbnail: thumbnail, meta: meta, thumbnailErr: thumbnailErr}, nil
}
+33
View File
@@ -0,0 +1,33 @@
package main
import (
"bytes"
"encoding/json"
"testing"
)
func TestPrepareImageUploadAllowsOversizedImageWithoutThumbnail(t *testing.T) {
imageData := []byte{'G', 'I', 'F', '8', '9', 'a', 0x10, 0x27, 0x88, 0x13, 0x00, 0x00, 0x00}
prepared, err := prepareImageUpload(bytes.NewReader(imageData))
if err != nil {
t.Fatal(err)
}
if prepared.thumbnail != nil {
t.Fatal("expected thumbnail to be skipped")
}
if prepared.thumbnailErr == nil {
t.Fatal("expected thumbnail error to be retained")
}
var meta struct {
Width int `json:"width"`
Height int `json:"height"`
}
if err := json.Unmarshal(prepared.meta, &meta); err != nil {
t.Fatal(err)
}
if meta.Width != 10_000 || meta.Height != 5_000 {
t.Fatalf("dimensions = %dx%d, want 10000x5000", meta.Width, meta.Height)
}
}
+16 -1
View File
@@ -1,6 +1,7 @@
package main
import (
"slices"
"strings"
amodels "github.com/abhinavxd/libredesk/internal/auth/models"
@@ -22,6 +23,7 @@ type messageReq struct {
SenderType string `json:"sender_type"`
Mentions []cmodels.MentionInput `json:"mentions"`
EchoID string `json:"echo_id"`
SourceID string `json:"source_id"` // RFC 5322 Message-ID of the inbound message; stored on the created contact message so replies thread on it. Contact sender only.
}
// handleGetMessages returns messages for a conversation.
@@ -198,6 +200,10 @@ func handleSendMessage(r *fastglue.Request) error {
return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError)
}
if !canCreateConversationMessage(user, req) {
return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("status.deniedPermission"), nil, envelope.PermissionError)
}
// Make sure the inbox is enabled.
inbox, err := app.inbox.GetDBRecord(conv.InboxID)
if err != nil {
@@ -243,7 +249,7 @@ func handleSendMessage(r *fastglue.Request) error {
// Create contact message.
if req.SenderType == umodels.UserTypeContact {
message, err := app.conversation.CreateContactMessage(media, int(conv.ContactID), cuuid, req.Message, cmodels.ContentTypeHTML, false)
message, err := app.conversation.CreateContactMessage(media, int(conv.ContactID), cuuid, req.Message, cmodels.ContentTypeHTML, false, req.SourceID)
if err != nil {
return sendErrorEnvelope(r, err)
}
@@ -303,3 +309,12 @@ func resolveQuotedCIDs(app *App, msg *cmodels.Message) {
msg.Content = strings.ReplaceAll(msg.Content, "cid:"+ref.ContentID, url)
}
}
// canCreateConversationMessage returns whether the user may create a message of the requested visibility.
func canCreateConversationMessage(user umodels.User, req messageReq) bool {
requiredPermission := authzModels.PermMessagesWrite
if req.Private {
requiredPermission = authzModels.PermMessagesWritePrivate
}
return slices.Contains(user.Permissions, requiredPermission)
}
+41
View File
@@ -4,9 +4,50 @@ import (
"testing"
"github.com/abhinavxd/libredesk/internal/attachment"
authzmodels "github.com/abhinavxd/libredesk/internal/authz/models"
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
"github.com/lib/pq"
)
func TestCanCreateConversationMessagePermissionCombinations(t *testing.T) {
tests := []struct {
name string
permissions pq.StringArray
wantPublic bool
wantPrivateNote bool
}{
{"both permissions", pq.StringArray{authzmodels.PermMessagesWrite, authzmodels.PermMessagesWritePrivate}, true, true},
{"public message only", pq.StringArray{authzmodels.PermMessagesWrite}, true, false},
{"private note only", pq.StringArray{authzmodels.PermMessagesWritePrivate}, false, true},
{"neither permission", nil, false, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
user := umodels.User{Permissions: tt.permissions}
public := canCreateConversationMessage(user, messageReq{SenderType: umodels.UserTypeAgent})
privateNote := canCreateConversationMessage(user, messageReq{SenderType: umodels.UserTypeAgent, Private: true})
if public != tt.wantPublic {
t.Errorf("public reply allowed = %v, want %v", public, tt.wantPublic)
}
if privateNote != tt.wantPrivateNote {
t.Errorf("private note allowed = %v, want %v", privateNote, tt.wantPrivateNote)
}
})
}
}
func TestContactMessageStillRequiresPublicMessagePermission(t *testing.T) {
req := messageReq{SenderType: umodels.UserTypeContact}
if canCreateConversationMessage(umodels.User{Permissions: pq.StringArray{authzmodels.PermMessagesWriteAsContact}}, req) {
t.Fatal("write_as_contact alone unexpectedly bypassed messages:write")
}
if !canCreateConversationMessage(umodels.User{Permissions: pq.StringArray{authzmodels.PermMessagesWrite}}, req) {
t.Fatal("messages:write should pass the base contact-message permission check")
}
}
func TestResolveAttachmentCIDs(t *testing.T) {
tests := []struct {
name string
+131
View File
@@ -0,0 +1,131 @@
package main
import (
"fmt"
"testing"
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/valyala/fasthttp"
"github.com/zerodha/fastglue"
)
func requestWithQuery(query string) *fastglue.Request {
ctx := &fasthttp.RequestCtx{}
ctx.Request.SetRequestURI("/api/v1/thing?" + query)
return &fastglue.Request{RequestCtx: ctx}
}
func TestGetIDsParam(t *testing.T) {
tests := []struct {
query string
want []int
}{
{"", nil},
{"ids=", nil},
{"ids=%20%20", nil},
{"ids=5", []int{5}},
{"ids=5,7,9", []int{5, 7, 9}},
{"ids=%205%20,%207%20", []int{5, 7}},
{"ids=5,5,5", []int{5, 5, 5}},
{"ids=abc", nil},
{"ids=0", nil},
{"ids=-3", nil},
{"ids=1,abc,2", []int{1, 2}},
{"ids=1,,2", []int{1, 2}},
{"ids=1,0,-2,3", []int{1, 3}},
{"ids=9223372036854775808", nil},
{"other=1", nil},
}
for _, tc := range tests {
got := getIDsParam(requestWithQuery(tc.query), "ids")
if len(got) != len(tc.want) {
t.Fatalf("query %q = %v, want %v", tc.query, got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Fatalf("query %q = %v, want %v", tc.query, got, tc.want)
}
}
}
}
func TestGetIDsParamCap(t *testing.T) {
query := "ids=1"
for i := 2; i <= maxIDsParam+50; i++ {
query += fmt.Sprintf(",%d", i)
}
got := getIDsParam(requestWithQuery(query), "ids")
if len(got) != maxIDsParam {
t.Fatalf("got %d ids, want the cap of %d", len(got), maxIDsParam)
}
if got[0] != 1 || got[maxIDsParam-1] != maxIDsParam {
t.Fatalf("cap dropped the wrong end: first %d, last %d", got[0], got[maxIDsParam-1])
}
}
func TestGetPaginationDefaultsAndClamps(t *testing.T) {
tests := []struct {
query string
wantPage int
wantPageSize int
}{
{"", 1, 30},
{"page=0&page_size=0", 1, 30},
{"page=-4&page_size=-9", 1, 30},
{"page=abc&page_size=abc", 1, 30},
{"page=3&page_size=50", 3, 50},
{"page_size=100000", 1, maxPageSize},
}
for _, tc := range tests {
page, pageSize := getPagination(requestWithQuery(tc.query))
if page != tc.wantPage || pageSize != tc.wantPageSize {
t.Fatalf("query %q = page %d size %d, want page %d size %d", tc.query, page, pageSize, tc.wantPage, tc.wantPageSize)
}
}
}
func TestGetOptionalPaginationFetchesEverythingWithoutParams(t *testing.T) {
tests := []struct {
query string
wantPage int
wantPageSize int
}{
{"", 1, 0},
{"page=2", 2, 0},
{"page_size=0", 1, 0},
{"page_size=-5", 1, 0},
{"page=2&page_size=50", 2, 50},
{"page_size=100000", 1, maxPageSize},
}
for _, tc := range tests {
page, pageSize := getOptionalPagination(requestWithQuery(tc.query))
if page != tc.wantPage || pageSize != tc.wantPageSize {
t.Fatalf("query %q = page %d size %d, want page %d size %d", tc.query, page, pageSize, tc.wantPage, tc.wantPageSize)
}
}
}
func TestPageOffset(t *testing.T) {
tests := []struct {
page int
pageSize int
want int
}{
{1, 30, 0},
{2, 30, 30},
{3, 50, 100},
{1, 0, 0},
{5, 0, 0},
}
for _, tc := range tests {
if got := dbutil.PageOffset(tc.page, tc.pageSize); got != tc.want {
t.Fatalf("PageOffset(%d, %d) = %d, want %d", tc.page, tc.pageSize, got, tc.want)
}
}
}
+12 -3
View File
@@ -9,12 +9,21 @@ import (
"github.com/zerodha/fastglue"
)
// handleGetTags returns all tags from the database.
// handleGetTags returns tags from the database, all of them without page params.
func handleGetTags(r *fastglue.Request) error {
var (
app = r.Context.(*App)
app = r.Context.(*App)
query = string(r.RequestCtx.QueryArgs().Peek("q"))
)
t, err := app.tag.GetAll()
if ids := getIDsParam(r, "ids"); len(ids) > 0 {
t, err := app.tag.GetByIDs(ids)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(t)
}
page, pageSize := getOptionalPagination(r)
t, err := app.tag.GetAll(query, page, pageSize)
if err != nil {
return sendErrorEnvelope(r, err)
}
+12 -3
View File
@@ -21,12 +21,21 @@ func handleGetTeams(r *fastglue.Request) error {
return r.SendEnvelope(teams)
}
// handleGetTeamsCompact returns a list of all teams in a compact format.
// handleGetTeamsCompact returns teams in a compact format, all of them without page params.
func handleGetTeamsCompact(r *fastglue.Request) error {
var (
app = r.Context.(*App)
app = r.Context.(*App)
query = string(r.RequestCtx.QueryArgs().Peek("q"))
)
teams, err := app.team.GetAllCompact()
if ids := getIDsParam(r, "ids"); len(ids) > 0 {
teams, err := app.team.GetAllCompactByIDs(ids)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(teams)
}
page, pageSize := getOptionalPagination(r)
teams, err := app.team.GetAllCompact(query, page, pageSize)
if err != nil {
return sendErrorEnvelope(r, err)
}
+1
View File
@@ -47,6 +47,7 @@ var migList = []migFunc{
{"v2.5.0", migrations.V2_5_0},
{"v2.6.0", migrations.V2_6_0},
{"v2.8.0", migrations.V2_8_0},
{"v2.9.0", migrations.V2_9_0},
}
// upgrade upgrades the database to the current version by running SQL migration files
+21 -3
View File
@@ -65,10 +65,28 @@ func handleGetAgents(r *fastglue.Request) error {
return r.SendEnvelope(agents)
}
// handleGetAgentsCompact returns all agents in a compact format.
// handleGetAgentsCompact returns agents in a compact format, all of them without page params.
func handleGetAgentsCompact(r *fastglue.Request) error {
var app = r.Context.(*App)
agents, err := app.user.GetAgentsCompact()
var (
app = r.Context.(*App)
query = string(r.RequestCtx.QueryArgs().Peek("q"))
userType = string(r.RequestCtx.QueryArgs().Peek("type"))
enabledOnly = string(r.RequestCtx.QueryArgs().Peek("enabled")) == "true"
)
switch userType {
case "", models.UserTypeAgent, models.UserTypeAIAssistant:
default:
return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError)
}
if ids := getIDsParam(r, "ids"); len(ids) > 0 {
agents, err := app.user.GetAgentsCompactByIDs(ids)
if err != nil {
return sendErrorEnvelope(r, err)
}
return r.SendEnvelope(agents)
}
page, pageSize := getOptionalPagination(r)
agents, err := app.user.GetAgentsCompact(query, userType, enabledOnly, page, pageSize)
if err != nil {
return sendErrorEnvelope(r, err)
}
-3
View File
@@ -81,7 +81,6 @@ import { useInboxStore } from './stores/inbox'
import { useUsersStore } from './stores/users'
import { useTeamStore } from './stores/team'
import { useSlaStore } from './stores/sla'
import { useMacroStore } from './stores/macro'
import { useSharedViewStore } from './stores/sharedView'
import { useTagStore } from './stores/tag'
import { useCustomAttributeStore } from './stores/customAttributes'
@@ -146,7 +145,6 @@ const usersStore = useUsersStore()
const teamStore = useTeamStore()
const inboxStore = useInboxStore()
const slaStore = useSlaStore()
const macroStore = useMacroStore()
const sharedViewStore = useSharedViewStore()
const tagStore = useTagStore()
const customAttributeStore = useCustomAttributeStore()
@@ -197,7 +195,6 @@ const initStores = async () => {
teamStore.fetchTeams(),
inboxStore.fetchInboxes(),
slaStore.fetchSlas(),
macroStore.loadMacros(),
tagStore.fetchTags(),
customAttributeStore.fetchCustomAttributes()
])
+7 -5
View File
@@ -229,7 +229,7 @@ const createTeam = (data) => http.post('/api/v1/teams', data, {
'Content-Type': 'application/json'
}
})
const getTeamsCompact = () => http.get('/api/v1/teams/compact')
const getTeamsCompact = (params) => http.get('/api/v1/teams/compact', { params })
const deleteTeam = (id) => http.delete(`/api/v1/teams/${id}`)
const updateUser = (id, data) =>
http.put(`/api/v1/agents/${id}`, data, {
@@ -238,7 +238,7 @@ const updateUser = (id, data) =>
}
})
const getUsers = () => http.get('/api/v1/agents')
const getUsersCompact = () => http.get('/api/v1/agents/compact')
const getUsersCompact = (params) => http.get('/api/v1/agents/compact', { params })
const updateCurrentUser = (data) =>
http.put('/api/v1/agents/me', data, {
headers: {
@@ -278,7 +278,7 @@ const createUser = (data) =>
'Content-Type': 'application/json'
}
})
const getTags = () => http.get('/api/v1/tags')
const getTags = (params) => http.get('/api/v1/tags', { params })
const importTags = (data) =>
http.post('/api/v1/tags/import', data, {
headers: {
@@ -349,7 +349,8 @@ const getConversation = (uuid) => http.get(`/api/v1/conversations/${uuid}`, { ab
const getConversationTranscript = (uuid) =>
http.get(`/api/v1/conversations/${uuid}/transcript`, { responseType: 'blob' })
const getContactPageVisits = (uuid) => http.get(`/api/v1/conversations/${uuid}/page-visits`, { abortOnRoute: true })
const getAllMacros = () => http.get('/api/v1/macros')
const getMacrosCompact = (params) => http.get('/api/v1/macros/compact', { params })
const searchMacros = (params) => http.get('/api/v1/macros/search', { params })
const getMacro = (id) => http.get(`/api/v1/macros/${id}`)
const createMacro = (data) =>
http.post('/api/v1/macros', data, {
@@ -667,7 +668,8 @@ export default {
getConversationTranscript,
getCurrentUser,
getCurrentUserTeams,
getAllMacros,
getMacrosCompact,
searchMacros,
getMacro,
createMacro,
updateMacro,
@@ -0,0 +1,97 @@
<template>
<SelectTag
v-if="multiple"
v-bind="$attrs"
:model-value="modelValue"
:items="items"
:placeholder="placeholderText"
:search="usersStore.searchUsers"
/>
<SelectComboBox
v-else
v-bind="$attrs"
:model-value="modelValue"
:items="items"
:placeholder="placeholderText"
:search="usersStore.searchUsers"
type="user"
>
<template v-if="$slots.trigger" #trigger="slotProps">
<slot name="trigger" v-bind="slotProps" />
</template>
</SelectComboBox>
</template>
<script setup>
import { computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useUsersStore } from '@/stores/users'
import { useUserStore } from '@/stores/user'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
import { SelectTag } from '@shared-ui/components/ui/select'
defineOptions({ inheritAttrs: false })
const props = defineProps({
modelValue: {
type: [String, Number, Array],
default: undefined
},
placeholder: {
type: String,
default: ''
},
multiple: {
type: Boolean,
default: false
},
includeNone: {
type: Boolean,
default: false
},
prependItems: {
type: Array,
default: () => []
},
excludeAiAssistants: {
type: Boolean,
default: false
},
currentUserFirst: {
type: Boolean,
default: false
}
})
const { t } = useI18n()
const usersStore = useUsersStore()
const userStore = useUserStore()
const placeholderText = computed(() => props.placeholder || t('placeholders.selectAgent'))
const items = computed(() => {
let options = props.excludeAiAssistants
? usersStore.options.filter((option) => option.type !== 'ai_assistant')
: usersStore.options
if (props.currentUserFirst) {
const isMe = (option) => String(option.value) === String(userStore.userID)
const me = options.find(isMe)
if (me) options = [me, ...options.filter((option) => !isMe(option))]
}
const prepended = props.includeNone
? [{ value: 'none', label: t('globals.terms.none') }, ...props.prependItems]
: props.prependItems
return [...prepended, ...options]
})
onMounted(usersStore.fetchUsers)
watch(
() => props.modelValue,
(value) => {
const ids = Array.isArray(value) ? value : [value]
usersStore.ensureUserIDs(ids)
},
{ immediate: true }
)
</script>
@@ -6,6 +6,7 @@
:items="items"
:placeholder="placeholder"
:align="align"
:search="search"
>
<!-- Custom trigger passthrough -->
<template v-if="$slots.trigger" #trigger="slotProps">
@@ -91,6 +92,10 @@ const props = defineProps({
align: {
type: String,
default: 'center'
},
search: {
type: Function,
default: null
}
})
@@ -0,0 +1,81 @@
<template>
<SelectTag
v-if="multiple"
v-bind="$attrs"
:model-value="modelValue"
:items="items"
:placeholder="placeholderText"
:search="searchTags"
/>
<SelectComboBox
v-else
v-bind="$attrs"
:model-value="modelValue"
:items="items"
:placeholder="placeholderText"
:search="searchTags"
>
<template v-if="$slots.trigger" #trigger="slotProps">
<slot name="trigger" v-bind="slotProps" />
</template>
</SelectComboBox>
</template>
<script setup>
import { computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTagStore } from '@/stores/tag'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
import { SelectTag } from '@shared-ui/components/ui/select'
defineOptions({ inheritAttrs: false })
const props = defineProps({
modelValue: {
type: [String, Number, Array],
default: undefined
},
placeholder: {
type: String,
default: ''
},
multiple: {
type: Boolean,
default: false
},
// Conversations store tags as names; filters store them as ids.
valueField: {
type: String,
default: 'name',
validator: (value) => ['name', 'id'].includes(value)
}
})
const { t } = useI18n()
const tagStore = useTagStore()
const placeholderText = computed(() => props.placeholder || t('placeholders.selectTags'))
const items = computed(() =>
props.valueField === 'id'
? tagStore.tagOptions
: tagStore.tagNames.map((name) => ({ label: name, value: name }))
)
const searchTags = (query) =>
props.valueField === 'id'
? tagStore.searchTagOptions(query)
: tagStore.searchTagNames(query)
onMounted(tagStore.fetchTags)
watch(
() => props.modelValue,
(value) => {
if (props.valueField !== 'id') return
const ids = Array.isArray(value) ? value : [value]
tagStore.ensureTagIDs(ids)
},
{ immediate: true }
)
</script>
@@ -0,0 +1,79 @@
<template>
<SelectTag
v-if="multiple"
v-bind="$attrs"
:model-value="modelValue"
:items="items"
:placeholder="placeholderText"
:search="teamStore.searchTeams"
/>
<SelectComboBox
v-else
v-bind="$attrs"
:model-value="modelValue"
:items="items"
:placeholder="placeholderText"
:search="teamStore.searchTeams"
type="team"
>
<template v-if="$slots.trigger" #trigger="slotProps">
<slot name="trigger" v-bind="slotProps" />
</template>
</SelectComboBox>
</template>
<script setup>
import { computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTeamStore } from '@/stores/team'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
import { SelectTag } from '@shared-ui/components/ui/select'
defineOptions({ inheritAttrs: false })
const props = defineProps({
modelValue: {
type: [String, Number, Array],
default: undefined
},
placeholder: {
type: String,
default: ''
},
multiple: {
type: Boolean,
default: false
},
includeNone: {
type: Boolean,
default: false
},
prependItems: {
type: Array,
default: () => []
}
})
const { t } = useI18n()
const teamStore = useTeamStore()
const placeholderText = computed(() => props.placeholder || t('placeholders.selectTeam'))
const items = computed(() => {
const prepended = props.includeNone
? [{ value: 'none', label: t('globals.terms.none') }, ...props.prependItems]
: props.prependItems
return [...prepended, ...teamStore.options]
})
onMounted(teamStore.fetchTeams)
watch(
() => props.modelValue,
(value) => {
const ids = Array.isArray(value) ? value : [value]
teamStore.ensureTeamIDs(ids)
},
{ immediate: true }
)
</script>
@@ -0,0 +1,76 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createSSRApp, defineComponent, h } from 'vue'
import { renderToString } from 'vue/server-renderer'
const ensureTeamIDs = vi.fn()
const ensureUserIDs = vi.fn()
const ensureTagIDs = vi.fn()
const childProps = { modelValue: undefined }
const ChildStub = defineComponent({
props: { modelValue: { type: [String, Number, Array], default: undefined } },
setup (props) {
childProps.modelValue = props.modelValue
return () => h('div')
}
})
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key) => key }) }))
vi.mock('@shared-ui/components/ui/select', () => ({ SelectTag: ChildStub }))
vi.mock('@/components/combobox/SelectCombobox.vue', () => ({ default: ChildStub }))
vi.mock('@/stores/team', () => ({
useTeamStore: () => ({ options: [], fetchTeams: vi.fn(), searchTeams: vi.fn(), ensureTeamIDs })
}))
vi.mock('@/stores/users', () => ({
useUsersStore: () => ({ options: [], fetchUsers: vi.fn(), searchUsers: vi.fn(), ensureUserIDs })
}))
vi.mock('@/stores/user', () => ({ useUserStore: () => ({ userID: 1 }) }))
vi.mock('@/stores/tag', () => ({
useTagStore: () => ({
tagOptions: [],
tagNames: [],
fetchTags: vi.fn(),
searchTagNames: vi.fn(),
searchTagOptions: vi.fn(),
ensureTagIDs
})
}))
const SelectTeamCombobox = (await import('./SelectTeamCombobox.vue')).default
const SelectAgentCombobox = (await import('./SelectAgentCombobox.vue')).default
const SelectTagCombobox = (await import('./SelectTagCombobox.vue')).default
const renderWithKebabModelValue = (component, props) =>
renderToString(createSSRApp(defineComponent({ render: () => h(component, props) })))
describe('remote comboboxes with a kebab-case model-value', () => {
beforeEach(() => {
ensureTeamIDs.mockClear()
ensureUserIDs.mockClear()
ensureTagIDs.mockClear()
childProps.modelValue = undefined
})
it('pins the selected team id so an out-of-page team keeps its label', async () => {
await renderWithKebabModelValue(SelectTeamCombobox, { 'model-value': 7 })
expect(ensureTeamIDs).toHaveBeenCalledWith([7])
expect(childProps.modelValue).toBe(7)
})
it('pins the selected agent id', async () => {
await renderWithKebabModelValue(SelectAgentCombobox, { 'model-value': 42 })
expect(ensureUserIDs).toHaveBeenCalledWith([42])
expect(childProps.modelValue).toBe(42)
})
it('pins the selected tag ids', async () => {
await renderWithKebabModelValue(SelectTagCombobox, { 'model-value': [3, 9], 'value-field': 'id', multiple: true })
expect(ensureTagIDs).toHaveBeenCalledWith([3, 9])
expect(childProps.modelValue).toEqual([3, 9])
})
it('leaves the child model value untouched when no value is given', async () => {
await renderWithKebabModelValue(SelectTeamCombobox, {})
expect(childProps.modelValue).toBeUndefined()
})
})
@@ -84,6 +84,7 @@ const { editor, extractMentions, focus } = useTextEditor({
isInlineEnabled: () => props.enableInlineImages,
linkedModel: props.linkedModel,
getSuggestions: props.getSuggestions,
enableMentions: () => props.enableMentions,
onSend: () => {
emit('send')
stopTyping()
@@ -5,6 +5,11 @@ export default {
char: '@',
allowSpaces: true,
allow: ({ editor }) => {
const enableMentions = editor.options.editorProps?.enableMentions
return enableMentions ? enableMentions() : false
},
items: async ({ query, editor }) => {
// Get the suggestion handler from editor options
const getSuggestions = editor.options.editorProps?.getSuggestions
@@ -12,6 +12,7 @@ export function useTextEditor({
isInlineEnabled = () => false,
linkedModel = 'messages',
getSuggestions = null,
enableMentions = () => false,
onSend = () => {},
onUpdate = () => {},
onBlur = () => {},
@@ -50,6 +51,7 @@ export function useTextEditor({
editorProps: {
attributes: { class: 'outline-none' },
getSuggestions,
enableMentions,
handlePaste,
handleDrop,
handleKeyDown: (view, event) => {
@@ -1,21 +1,40 @@
<template>
<button
type="button"
class="inline-flex cursor-pointer items-center rounded-full border border-border bg-background px-3 py-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
:title="t('filter.toggleConnector')"
@click.stop="toggle"
>
{{ modelValue === LOGIC.OR ? t('admin.automation.or') : t('admin.automation.and') }}
</button>
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span class="h-px w-8 bg-border" aria-hidden="true"></span>
<button
type="button"
:class="[
'inline-flex cursor-pointer items-center rounded-md border border-border bg-background font-semibold uppercase tracking-wide text-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
props.variant === 'group' ? 'min-h-8 px-3 text-sm' : 'min-h-7 px-2.5 text-xs'
]"
:title="t('filter.toggleConnector')"
@click.stop="toggle"
>
{{ connectorLabel }}
</button>
<span class="h-px w-8 bg-border" aria-hidden="true"></span>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { LOGIC } from '@/constants/filterConfig'
const modelValue = defineModel('modelValue', { required: true })
const props = defineProps({
mode: { type: String, default: 'and-or' },
variant: { type: String, default: 'condition' }
})
const { t } = useI18n()
const connectorLabel = computed(() => {
if (props.mode === 'all-any') {
return modelValue.value === LOGIC.OR ? t('admin.automation.any') : t('admin.automation.all')
}
return modelValue.value === LOGIC.OR ? t('admin.automation.or') : t('admin.automation.and')
})
const toggle = () => {
modelValue.value = modelValue.value === LOGIC.OR ? LOGIC.AND : LOGIC.OR
}
@@ -56,33 +56,30 @@
<div class="flex-1">
<div v-if="modelFilter.field && modelFilter.operator">
<template v-if="modelFilter.operator !== OPERATOR.SET && modelFilter.operator !== OPERATOR.NOT_SET">
<SelectTagCombobox
v-if="getFieldEntity(modelFilter) === 'tag'"
:multiple="getFieldType(modelFilter) === FIELD_TYPE.MULTI_SELECT"
value-field="id"
v-model="modelFilter.value"
/>
<SelectTag
v-if="getFieldType(modelFilter) === FIELD_TYPE.MULTI_SELECT"
v-else-if="getFieldType(modelFilter) === FIELD_TYPE.MULTI_SELECT"
v-model="modelFilter.value"
:items="getFieldOptions(modelFilter)"
:placeholder="t('placeholders.selectTags')"
/>
<SelectComboBox
v-else-if="
getFieldOptions(modelFilter).length > 0 &&
modelFilter.field === 'assigned_user_id'
"
<SelectAgentCombobox
v-else-if="getFieldEntity(modelFilter) === 'agent'"
v-model="modelFilter.value"
:items="getFieldOptions(modelFilter)"
:placeholder="t('placeholders.selectValue')"
type="user"
/>
<SelectComboBox
v-else-if="
getFieldOptions(modelFilter).length > 0 &&
modelFilter.field === 'assigned_team_id'
"
<SelectTeamCombobox
v-else-if="getFieldEntity(modelFilter) === 'team'"
v-model="modelFilter.value"
:items="getFieldOptions(modelFilter)"
:placeholder="t('placeholders.selectValue')"
type="team"
/>
<SelectComboBox
@@ -146,6 +143,9 @@ import { useI18n } from 'vue-i18n'
import { FIELD_TYPE, OPERATOR } from '@/constants/filterConfig'
import CloseButton from '@/components/button/CloseButton.vue'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
import SelectAgentCombobox from '@/components/combobox/SelectAgentCombobox.vue'
import SelectTeamCombobox from '@/components/combobox/SelectTeamCombobox.vue'
import SelectTagCombobox from '@/components/combobox/SelectTagCombobox.vue'
import SelectTag from '@shared-ui/components/ui/select/SelectTag.vue'
import DateFilterValue from '@/components/filter/DateFilterValue.vue'
@@ -263,6 +263,11 @@ const getFieldOptions = (fieldValue) => {
return field?.options || []
}
const getFieldEntity = (fieldValue) => {
const field = props.fields.find((f) => f.field === fieldValue.field)
return field?.entity || ''
}
const getFieldOperators = (modelFilter) => {
const field = props.fields.find((f) => f.field === modelFilter.field)
return field?.operators || []
@@ -1,9 +1,13 @@
<template>
<div class="space-y-2">
<div class="max-h-[50vh] overflow-y-auto pr-1 pb-2 space-y-2">
<div class="space-y-3">
<div class="space-y-3 pb-2">
<template v-for="(grp, gi) in modelValue.rules" :key="grp.__id">
<div v-if="gi > 0" class="flex justify-center">
<ConnectorToggle :modelValue="modelValue.logic" @update:modelValue="setLogic" />
<ConnectorToggle
:modelValue="modelValue.logic"
variant="group"
@update:modelValue="setLogic"
/>
</div>
<FilterGroupCard
:modelValue="grp"
@@ -1,17 +1,20 @@
<template>
<div class="rounded-lg border border-border bg-muted/30 p-3 space-y-2">
<div v-if="canRemove" class="flex justify-end">
<CloseButton :aria-label="t('filter.removeGroup')" :onClose="() => emit('remove')">
<div class="space-y-3 rounded-lg border border-border bg-muted/30 p-3" data-cy="filter-group">
<div class="flex items-center justify-between gap-3 border-b border-border pb-2">
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
<span>{{ t('admin.automation.matchTheseRules') }}</span>
<ConnectorToggle :modelValue="group.logic" mode="all-any" @update:modelValue="setLogic" />
</div>
<CloseButton
v-if="canRemove"
:aria-label="t('filter.removeGroup')"
:onClose="() => emit('remove')"
>
<Trash2 class="w-4 h-4" />
</CloseButton>
</div>
<template v-for="(rule, index) in group.rules" :key="rule.__id">
<ConnectorToggle
v-if="index > 0"
:modelValue="group.logic"
@update:modelValue="setLogic"
/>
<FilterRow
:modelValue="rule"
:fields="fields"
@@ -1,9 +1,13 @@
<template>
<div class="group flex items-center gap-3">
<div class="flex gap-2 w-full">
<div class="group flex items-start gap-2 sm:items-center" data-cy="filter-row">
<div class="grid min-w-0 flex-1 gap-2 sm:grid-cols-3">
<div
class="flex-1 rounded-md"
:class="[shake && missingField && 'animate-shake', showInvalid && missingField && 'ring-1 ring-destructive']"
class="min-w-0 rounded-md"
data-cy="filter-field"
:class="[
shake && missingField && 'animate-shake',
showInvalid && missingField && 'ring-1 ring-destructive'
]"
>
<Select :model-value="modelValue.field" @update:model-value="onFieldChange">
<SelectTrigger>
@@ -20,8 +24,12 @@
</div>
<div
class="flex-1 rounded-md"
:class="[shake && missingOperator && 'animate-shake', showInvalid && missingOperator && 'ring-1 ring-destructive']"
class="min-w-0 rounded-md"
data-cy="filter-operator"
:class="[
shake && missingOperator && 'animate-shake',
showInvalid && missingOperator && 'ring-1 ring-destructive'
]"
>
<Select
v-if="modelValue.field"
@@ -42,32 +50,41 @@
</div>
<div
class="flex-1 rounded-md"
:class="[shake && missingValue && 'animate-shake', showInvalid && missingValue && 'ring-1 ring-destructive']"
class="min-w-0 rounded-md"
data-cy="filter-value"
:class="[
shake && missingValue && 'animate-shake',
showInvalid && missingValue && 'ring-1 ring-destructive'
]"
>
<div v-if="modelValue.field && modelValue.operator">
<template v-if="modelValue.operator !== OPERATOR.SET && modelValue.operator !== OPERATOR.NOT_SET">
<template
v-if="modelValue.operator !== OPERATOR.SET && modelValue.operator !== OPERATOR.NOT_SET"
>
<SelectTagCombobox
v-if="fieldEntity === 'tag'"
:multiple="fieldType === FIELD_TYPE.MULTI_SELECT"
value-field="id"
v-model="leafValue"
/>
<SelectTag
v-if="fieldType === FIELD_TYPE.MULTI_SELECT"
v-else-if="fieldType === FIELD_TYPE.MULTI_SELECT"
v-model="leafValue"
:items="fieldOptions"
:placeholder="t('placeholders.selectTags')"
/>
<SelectComboBox
v-else-if="fieldOptions.length > 0 && modelValue.field === 'assigned_user_id'"
<SelectAgentCombobox
v-else-if="fieldEntity === 'agent'"
v-model="leafValue"
:items="fieldOptions"
:placeholder="t('placeholders.selectValue')"
type="user"
/>
<SelectComboBox
v-else-if="fieldOptions.length > 0 && modelValue.field === 'assigned_team_id'"
<SelectTeamCombobox
v-else-if="fieldEntity === 'team'"
v-model="leafValue"
:items="fieldOptions"
:placeholder="t('placeholders.selectValue')"
type="team"
/>
<SelectComboBox
@@ -107,6 +124,9 @@ import { useI18n } from 'vue-i18n'
import { FIELD_TYPE, OPERATOR, operatorLabel } from '@/constants/filterConfig'
import CloseButton from '@/components/button/CloseButton.vue'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
import SelectAgentCombobox from '@/components/combobox/SelectAgentCombobox.vue'
import SelectTeamCombobox from '@/components/combobox/SelectTeamCombobox.vue'
import SelectTagCombobox from '@/components/combobox/SelectTagCombobox.vue'
import SelectTag from '@shared-ui/components/ui/select/SelectTag.vue'
import DateFilterValue from '@/components/filter/DateFilterValue.vue'
@@ -123,6 +143,7 @@ const { t } = useI18n()
const fieldConfig = computed(() => props.fields.find((f) => f.field === modelValue.value.field))
const fieldOptions = computed(() => fieldConfig.value?.options || [])
const fieldOperators = computed(() => fieldConfig.value?.operators || [])
const fieldEntity = computed(() => fieldConfig.value?.entity || '')
const fieldType = computed(() => fieldConfig.value?.type || '')
// "contains any of" only applies to multi-value fields; single-value text stays "contains"
@@ -0,0 +1,405 @@
import { describe, it, expect } from 'vitest'
import {
createLeaf,
createGroup,
createRoot,
isGroupNode,
isStrictTwoLevel,
collectLeaves,
isPartialLeaf,
normalizeToTwoLevel,
serializeFilterTree,
deserializeFilterTree
} from '@main/components/filter/filterTree'
import { FIELD_TYPE, LOGIC, OPERATOR } from '@main/constants/filterConfig'
const FIELDS = [
{ field: 'status_id', type: FIELD_TYPE.SELECT, model: 'conversations' },
{ field: 'priority_id', type: FIELD_TYPE.SELECT, model: 'conversations' },
{ field: 'tags', type: FIELD_TYPE.MULTI_SELECT, model: 'conversations' },
{ field: 'email', type: FIELD_TYPE.TEXT, model: 'users' }
]
const leaf = (over = {}) => ({
model: 'conversations',
field: 'status_id',
operator: OPERATOR.EQUALS,
value: '1',
...over
})
const stripIds = (node) => {
if (isGroupNode(node)) {
const { __id, ...rest } = node
return { ...rest, rules: (node.rules || []).map(stripIds) }
}
const { __id, ...rest } = node
return rest
}
describe('filterTree constructors', () => {
it('builds a root holding one group holding one blank leaf', () => {
const root = createRoot()
expect(root.logic).toBe(LOGIC.AND)
expect(root.rules).toHaveLength(1)
expect(root.rules[0].rules).toHaveLength(1)
expect(isStrictTwoLevel(root)).toBe(true)
expect(collectLeaves(root)).toHaveLength(1)
})
it('gives every node a unique key', () => {
const ids = [createRoot(), createGroup(), createLeaf(), createLeaf()].map((n) => n.__id)
expect(new Set(ids).size).toBe(ids.length)
})
it('honours the requested logic', () => {
expect(createRoot(LOGIC.OR).logic).toBe(LOGIC.OR)
expect(createGroup(LOGIC.OR).logic).toBe(LOGIC.OR)
})
})
describe('isStrictTwoLevel', () => {
it('accepts a root of groups of leaves', () => {
expect(
isStrictTwoLevel({ logic: LOGIC.AND, rules: [{ logic: LOGIC.OR, rules: [leaf(), leaf()] }] })
).toBe(true)
})
it('rejects a three-level tree', () => {
expect(
isStrictTwoLevel({
logic: LOGIC.AND,
rules: [{ logic: LOGIC.AND, rules: [{ logic: LOGIC.OR, rules: [leaf()] }] }]
})
).toBe(false)
})
it('rejects loose leaves at the top level', () => {
expect(isStrictTwoLevel({ logic: LOGIC.AND, rules: [leaf()] })).toBe(false)
})
it('rejects an array, an empty root, null and undefined', () => {
expect(isStrictTwoLevel([leaf()])).toBe(false)
expect(isStrictTwoLevel({ logic: LOGIC.AND, rules: [] })).toBe(false)
expect(isStrictTwoLevel(null)).toBe(false)
expect(isStrictTwoLevel(undefined)).toBe(false)
})
})
describe('normalizeToTwoLevel', () => {
it('wraps a legacy flat array into one AND group', () => {
const out = normalizeToTwoLevel([leaf(), leaf({ field: 'priority_id', value: '2' })])
expect(isStrictTwoLevel(out)).toBe(true)
expect(stripIds(out)).toEqual({
logic: LOGIC.AND,
rules: [
{
logic: LOGIC.AND,
rules: [leaf(), leaf({ field: 'priority_id', value: '2' })]
}
]
})
})
it('leaves an already strict two-level tree semantically unchanged', () => {
const input = {
logic: LOGIC.OR,
rules: [
{ logic: LOGIC.AND, rules: [leaf()] },
{ logic: LOGIC.OR, rules: [leaf({ field: 'priority_id', value: '2' })] }
]
}
expect(stripIds(normalizeToTwoLevel(input))).toEqual(input)
})
it('flattens a group nested inside a group instead of dropping its leaves', () => {
const deep = {
logic: LOGIC.AND,
rules: [
{
logic: LOGIC.OR,
rules: [leaf(), { logic: LOGIC.OR, rules: [leaf({ field: 'priority_id', value: '2' })] }]
}
]
}
const out = normalizeToTwoLevel(deep)
expect(isStrictTwoLevel(out)).toBe(true)
expect(collectLeaves(out).map((l) => l.field)).toEqual(['status_id', 'priority_id'])
})
it('keeps loose top-level leaves as their own leading group, ahead of real groups', () => {
const mixed = {
logic: LOGIC.AND,
rules: [
leaf(),
{ logic: LOGIC.OR, rules: [leaf({ field: 'priority_id', value: '2' }), leaf({ field: 'priority_id', value: '3' })] }
]
}
const out = normalizeToTwoLevel(mixed)
expect(isStrictTwoLevel(out)).toBe(true)
expect(stripIds(out)).toEqual({
logic: LOGIC.AND,
rules: [
{ logic: LOGIC.AND, rules: [leaf()] },
{
logic: LOGIC.OR,
rules: [leaf({ field: 'priority_id', value: '2' }), leaf({ field: 'priority_id', value: '3' })]
}
]
})
})
it('carries the outer logic onto the group it invents for loose leaves', () => {
const out = normalizeToTwoLevel({ logic: LOGIC.OR, rules: [leaf(), leaf({ field: 'priority_id' })] })
expect(out.logic).toBe(LOGIC.OR)
expect(out.rules[0].logic).toBe(LOGIC.OR)
})
it('returns a usable blank tree for an empty root and junk input', () => {
for (const input of [{ logic: LOGIC.AND, rules: [] }, null, undefined, 'nonsense', 42]) {
const out = normalizeToTwoLevel(input)
expect(isStrictTwoLevel(out), JSON.stringify(input)).toBe(true)
expect(collectLeaves(out).length).toBeGreaterThan(0)
}
})
// Every other empty input gets a blank leaf, this one does not.
it('yields a group with no conditions for an empty legacy array', () => {
const out = normalizeToTwoLevel([])
expect(isStrictTwoLevel(out)).toBe(true)
expect(out.rules).toHaveLength(1)
expect(collectLeaves(out)).toEqual([])
})
it('defaults a missing logic to AND at both levels', () => {
const out = normalizeToTwoLevel({ rules: [{ rules: [leaf()] }] })
expect(out.logic).toBe(LOGIC.AND)
expect(out.rules[0].logic).toBe(LOGIC.AND)
})
it('does not mutate the input', () => {
const input = { logic: LOGIC.AND, rules: [{ logic: LOGIC.OR, rules: [leaf()] }] }
const before = JSON.stringify(input)
normalizeToTwoLevel(input)
expect(JSON.stringify(input)).toBe(before)
})
})
describe('collectLeaves', () => {
it('returns leaves in order across groups', () => {
const tree = {
logic: LOGIC.AND,
rules: [
{ logic: LOGIC.AND, rules: [leaf({ field: 'a' }), leaf({ field: 'b' })] },
{ logic: LOGIC.OR, rules: [leaf({ field: 'c' })] }
]
}
expect(collectLeaves(tree).map((l) => l.field)).toEqual(['a', 'b', 'c'])
})
it('returns an empty list for a group with no rules', () => {
expect(collectLeaves({ logic: LOGIC.AND, rules: [] })).toEqual([])
})
})
describe('isPartialLeaf', () => {
it('accepts a fully filled leaf', () => {
expect(isPartialLeaf(leaf())).toBe(false)
})
it('rejects a leaf with no field or no operator', () => {
expect(isPartialLeaf(leaf({ field: '' }))).toBe(true)
expect(isPartialLeaf(leaf({ operator: '' }))).toBe(true)
})
it('rejects a leaf whose value is empty, null, undefined or an empty array', () => {
expect(isPartialLeaf(leaf({ value: '' }))).toBe(true)
expect(isPartialLeaf(leaf({ value: null }))).toBe(true)
expect(isPartialLeaf(leaf({ value: undefined }))).toBe(true)
expect(isPartialLeaf(leaf({ value: [] }))).toBe(true)
})
it('accepts set and not-set with no value, since those operators take none', () => {
expect(isPartialLeaf(leaf({ operator: OPERATOR.SET, value: '' }))).toBe(false)
expect(isPartialLeaf(leaf({ operator: OPERATOR.NOT_SET, value: undefined }))).toBe(false)
})
it('accepts a zero and a false value', () => {
expect(isPartialLeaf(leaf({ value: 0 }))).toBe(false)
expect(isPartialLeaf(leaf({ value: false }))).toBe(false)
})
})
describe('serializeFilterTree', () => {
it('drops __id from every node and keeps the group logic', () => {
const out = serializeFilterTree(
deserializeFilterTree(normalizeToTwoLevel([leaf()]), FIELDS)
)
expect(JSON.stringify(out)).not.toContain('__id')
expect(out).toEqual({
logic: LOGIC.AND,
rules: [{ logic: LOGIC.AND, rules: [leaf()] }]
})
})
it('turns a multi-select array into a JSON string of numbers', () => {
const out = serializeFilterTree({
logic: LOGIC.AND,
rules: [{ logic: LOGIC.AND, rules: [leaf({ field: 'tags', value: ['3', '7'] })] }]
})
expect(out.rules[0].rules[0].value).toBe('[3,7]')
})
it('keeps non-numeric multi-select values as strings', () => {
const out = serializeFilterTree({
logic: LOGIC.AND,
rules: [{ logic: LOGIC.AND, rules: [leaf({ field: 'tags', value: ['billing', '7'] })] }]
})
expect(out.rules[0].rules[0].value).toBe('["billing",7]')
})
it('serializes an empty multi-select as an empty JSON array', () => {
const out = serializeFilterTree(leaf({ field: 'tags', value: [] }))
expect(out.value).toBe('[]')
})
it('leaves a scalar value untouched', () => {
expect(serializeFilterTree(leaf({ value: '1' })).value).toBe('1')
expect(serializeFilterTree(leaf({ value: 0 })).value).toBe(0)
})
it('emits only the four wire fields for a leaf', () => {
const out = serializeFilterTree({ ...leaf(), __id: 'f9', label: 'Status', options: [1, 2] })
expect(Object.keys(out).sort()).toEqual(['field', 'model', 'operator', 'value'])
})
it('defaults a missing group logic to AND', () => {
expect(serializeFilterTree({ rules: [leaf()] }).logic).toBe(LOGIC.AND)
})
})
describe('deserializeFilterTree', () => {
it('restores a multi-select JSON string to an array of string ids', () => {
const out = deserializeFilterTree(
{ logic: LOGIC.AND, rules: [{ logic: LOGIC.AND, rules: [leaf({ field: 'tags', value: '[3,7]' })] }] },
FIELDS
)
expect(out.rules[0].rules[0].value).toEqual(['3', '7'])
})
it('keeps an already-array multi-select value as string ids', () => {
const out = deserializeFilterTree(leaf({ field: 'tags', value: [3, 7] }), FIELDS)
expect(out.value).toEqual(['3', '7'])
})
it('falls back to an empty array on an unparseable multi-select value', () => {
expect(deserializeFilterTree(leaf({ field: 'tags', value: 'not json' }), FIELDS).value).toEqual([])
expect(deserializeFilterTree(leaf({ field: 'tags', value: '{"a":1}' }), FIELDS).value).toEqual([])
expect(deserializeFilterTree(leaf({ field: 'tags', value: null }), FIELDS).value).toEqual([])
})
it('leaves a non-multi-select value alone', () => {
expect(deserializeFilterTree(leaf({ value: '1' }), FIELDS).value).toBe('1')
expect(deserializeFilterTree(leaf({ field: 'email', value: 'a@b.com' }), FIELDS).value).toBe('a@b.com')
})
it('leaves the value alone when the field is not in the field list', () => {
expect(deserializeFilterTree(leaf({ field: 'unknown', value: '[3,7]' }), FIELDS).value).toBe('[3,7]')
})
it('gives every node a key so list rendering stays stable', () => {
const out = deserializeFilterTree(
{ logic: LOGIC.AND, rules: [{ logic: LOGIC.AND, rules: [leaf(), leaf()] }] },
FIELDS
)
expect(out.__id).toBeTruthy()
expect(out.rules[0].__id).toBeTruthy()
expect(out.rules[0].rules.map((r) => r.__id).filter(Boolean)).toHaveLength(2)
})
it('preserves an existing key rather than reassigning it', () => {
const out = deserializeFilterTree({ __id: 'keep-me', logic: LOGIC.AND, rules: [] }, FIELDS)
expect(out.__id).toBe('keep-me')
})
})
describe('round trip through the wire format', () => {
const load = (stored) => deserializeFilterTree(normalizeToTwoLevel(stored), FIELDS)
it('survives save, reload and save again unchanged', () => {
const edited = {
logic: LOGIC.OR,
rules: [
{
logic: LOGIC.AND,
rules: [leaf({ value: '1' }), leaf({ field: 'tags', value: ['3', '7'] })]
},
{
logic: LOGIC.OR,
rules: [leaf({ field: 'priority_id', value: '2' }), leaf({ field: 'email', operator: OPERATOR.SET, value: '' })]
}
]
}
const saved = serializeFilterTree(edited)
const savedAgain = serializeFilterTree(load(saved))
expect(savedAgain).toEqual(saved)
})
it('keeps every group logic and leaf order across the round trip', () => {
const saved = serializeFilterTree({
logic: LOGIC.OR,
rules: [
{ logic: LOGIC.OR, rules: [leaf({ field: 'a' }), leaf({ field: 'b' })] },
{ logic: LOGIC.AND, rules: [leaf({ field: 'c' })] }
]
})
const reloaded = serializeFilterTree(load(saved))
expect(reloaded.logic).toBe(LOGIC.OR)
expect(reloaded.rules.map((g) => g.logic)).toEqual([LOGIC.OR, LOGIC.AND])
expect(reloaded.rules.map((g) => g.rules.map((r) => r.field))).toEqual([['a', 'b'], ['c']])
})
it('upgrades a legacy flat array and saves it as a two-level tree', () => {
const saved = serializeFilterTree(load([leaf(), leaf({ field: 'priority_id', value: '2' })]))
expect(saved).toEqual({
logic: LOGIC.AND,
rules: [{ logic: LOGIC.AND, rules: [leaf(), leaf({ field: 'priority_id', value: '2' })] }]
})
})
it('does not double-encode a multi-select value on a second save', () => {
const first = serializeFilterTree(load([leaf({ field: 'tags', value: ['3', '7'] })]))
const second = serializeFilterTree(load(first))
expect(first.rules[0].rules[0].value).toBe('[3,7]')
expect(second.rules[0].rules[0].value).toBe('[3,7]')
})
})
@@ -558,7 +558,7 @@ onMounted(() => {
:is-active="route.params.teamID == team.id"
@click="navigateToTeamInbox(team.id)"
>
{{ team.emoji }}<span>{{ team.name }}</span>
{{ team.emoji }}<span class="flex-1 truncate" :title="team.name">{{ team.name }}</span>
</SidebarMenuButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
@@ -1,17 +1,15 @@
import { computed } from 'vue'
import { useUsersStore } from '../stores/users'
import { FIELD_TYPE, FIELD_OPERATORS } from '../constants/filterConfig'
import { useI18n } from 'vue-i18n'
export function useActivityLogFilters () {
const uStore = useUsersStore()
const { t } = useI18n()
const activityLogListFilters = computed(() => ({
actor_id: {
label: t('globals.terms.actor'),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: uStore.options
entity: 'agent'
},
activity_type: {
label: t('activityLog.entryType'),
@@ -1,22 +1,16 @@
import { computed } from 'vue'
import { useConversationStore } from '@/stores/conversation'
import { useInboxStore } from '@/stores/inbox'
import { useUsersStore } from '@/stores/users'
import { useTeamStore } from '@/stores/team'
import { useSlaStore } from '@/stores/sla'
import { useCustomAttributeStore } from '@/stores/customAttributes'
import { useTagStore } from '@/stores/tag'
import { FIELD_TYPE, FIELD_OPERATORS } from '@/constants/filterConfig'
import { useI18n } from 'vue-i18n'
export function useConversationFilters () {
const cStore = useConversationStore()
const iStore = useInboxStore()
const uStore = useUsersStore()
const tStore = useTeamStore()
const slaStore = useSlaStore()
const customAttributeStore = useCustomAttributeStore()
const tagStore = useTagStore()
const { t } = useI18n()
const customAttributeDataTypeToFieldType = {
@@ -54,13 +48,13 @@ export function useConversationFilters () {
label: t('actions.assignTeam'),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: tStore.options
entity: 'team'
},
assigned_user_id: {
label: t('actions.assignAgent'),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: uStore.options
entity: 'agent'
},
inbox_id: {
label: t('globals.terms.inbox'),
@@ -72,7 +66,7 @@ export function useConversationFilters () {
label: t('globals.terms.tag', 2),
type: FIELD_TYPE.MULTI_SELECT,
operators: FIELD_OPERATORS.MULTI_SELECT,
options: tagStore.tagOptions
entity: 'tag'
},
created_at: {
label: t('globals.terms.createdAt'),
@@ -192,13 +186,13 @@ export function useConversationFilters () {
label: t('actions.assignTeam'),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: tStore.options
entity: 'team'
},
assigned_user: {
label: t('actions.assignAgent'),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: uStore.options
entity: 'agent'
},
inbox: {
label: t('globals.terms.inbox'),
@@ -225,13 +219,13 @@ export function useConversationFilters () {
label: t('actions.assignTeam'),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: tStore.options
entity: 'team'
},
assigned_user: {
label: t('actions.assignAgent'),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: uStore.options
entity: 'agent'
},
hours_since_created: {
label: t('globals.messages.hoursSinceCreated'),
@@ -275,13 +269,13 @@ export function useConversationFilters () {
label: t('admin.automation.previousAssignedUser'),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: uStore.options
entity: 'agent'
},
previous_assigned_team: {
label: t('admin.automation.previousAssignedTeam'),
type: FIELD_TYPE.SELECT,
operators: FIELD_OPERATORS.SELECT,
options: tStore.options
entity: 'team'
}
}))
@@ -289,12 +283,12 @@ export function useConversationFilters () {
assign_team: {
label: t('actions.assignTeam'),
type: FIELD_TYPE.SELECT,
options: tStore.options
entity: 'team'
},
assign_user: {
label: t('actions.assignAgent'),
type: FIELD_TYPE.SELECT,
options: uStore.options
entity: 'agent'
},
set_status: {
label: t('actions.setStatus'),
@@ -352,12 +346,12 @@ export function useConversationFilters () {
assign_team: {
label: t('actions.assignTeam'),
type: FIELD_TYPE.SELECT,
options: tStore.options
entity: 'team'
},
assign_user: {
label: t('actions.assignAgent'),
type: FIELD_TYPE.SELECT,
options: uStore.options
entity: 'agent'
},
set_status: {
label: t('actions.setStatus'),
@@ -13,6 +13,7 @@ export const permissions = {
CONVERSATIONS_UPDATE_TAGS: 'conversations:update_tags',
MESSAGES_READ: 'messages:read',
MESSAGES_WRITE: 'messages:write',
MESSAGES_WRITE_PRIVATE: 'messages:write_private',
MESSAGES_WRITE_AS_CONTACT: 'messages:write_as_contact',
VIEW_MANAGE: 'view:manage',
SHARED_VIEWS_MANAGE: 'shared_views:manage',
@@ -89,6 +89,7 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useStorage } from '@vueuse/core'
import SimpleTable from '@main/components/table/SimpleTable.vue'
import {
Select,
@@ -116,7 +117,7 @@ const orderByField = ref('activity_logs.created_at')
const orderByDirection = ref('desc')
const totalCount = ref(0)
const totalPages = ref(0)
const filters = ref([])
const filters = useStorage('activity-log-filters', [])
const filtersOpen = ref(false)
const { activityLogListFilters } = useActivityLogFilters()
@@ -127,7 +128,8 @@ const filterFields = computed(() =>
field,
type: value.type,
operators: value.operators,
options: value.options ?? []
options: value.options ?? [],
entity: value.entity
}))
)
@@ -37,11 +37,7 @@
v-if="action.type && conversationActions[action.type]?.type === 'tag'"
class="flex-1 min-w-0"
>
<SelectTag
v-model="action.value"
:items="tagsStore.tagNames.map((tag) => ({ label: tag, value: tag }))"
:placeholder="t('placeholders.selectTags')"
/>
<SelectTagCombobox multiple v-model="action.value" />
</div>
<div
@@ -53,6 +49,7 @@
@update:modelValue="(value) => handleNotifyRecipientsChange(value, index)"
:items="notifyRecipientOptions"
:placeholder="t('placeholders.selectRecipients')"
:search="searchRecipients"
/>
<Input
type="text"
@@ -72,12 +69,22 @@
class="flex-1 min-w-0"
v-if="action.type && conversationActions[action.type]?.type === 'select'"
>
<SelectAgentCombobox
v-if="action.type === 'assign_user'"
v-model="action.value[0]"
@select="handleValueChange($event, index)"
/>
<SelectTeamCombobox
v-else-if="action.type === 'assign_team'"
v-model="action.value[0]"
@select="handleValueChange($event, index)"
/>
<SelectComboBox
v-else
v-model="action.value[0]"
:items="conversationActions[action.type]?.options"
:placeholder="t('placeholders.selectValue')"
@select="handleValueChange($event, index)"
:type="action.type === 'assign_user' ? 'user' : 'team'"
/>
</div>
@@ -145,12 +152,11 @@
</template>
<script setup>
import { toRefs, computed } from 'vue'
import { toRefs, computed, watch, onMounted } from 'vue'
import { Button } from '@shared-ui/components/ui/button'
import { Input } from '@shared-ui/components/ui/input'
import { Textarea } from '@shared-ui/components/ui/textarea'
import CloseButton from '@main/components/button/CloseButton.vue'
import { useTagStore } from '@main/stores/tag'
import { useWebhookStore } from '@main/stores/webhook'
import { useUsersStore } from '@main/stores/users'
import { useTeamStore } from '@main/stores/team'
@@ -168,6 +174,9 @@ import { getTextFromHTML } from '@shared-ui/utils/string'
import { useI18n } from 'vue-i18n'
import Editor from '@main/components/editor/ConversationEditor.vue'
import SelectComboBox from '@main/components/combobox/SelectCombobox.vue'
import SelectAgentCombobox from '@main/components/combobox/SelectAgentCombobox.vue'
import SelectTeamCombobox from '@main/components/combobox/SelectTeamCombobox.vue'
import SelectTagCombobox from '@main/components/combobox/SelectTagCombobox.vue'
const props = defineProps({
actions: {
@@ -179,7 +188,6 @@ const props = defineProps({
const { actions } = toRefs(props)
const { t } = useI18n()
const emit = defineEmits(['update-actions', 'add-action', 'remove-action'])
const tagsStore = useTagStore()
const webhookStore = useWebhookStore()
const uStore = useUsersStore()
const tStore = useTeamStore()
@@ -187,19 +195,64 @@ const { conversationActions } = useConversationFilters()
webhookStore.fetchWebhooks()
const notifyRecipientOptions = computed(() => [
const recipientKeywords = computed(() => [
{ label: t('globals.terms.assignee'), value: 'assignee' },
{ label: t('globals.terms.assignedTeam'), value: 'assigned_team' },
...tStore.options.map((o) => ({
{ label: t('globals.terms.assignedTeam'), value: 'assigned_team' }
])
const toTeamRecipients = (options) => options.map((o) => ({
label: `${t('globals.terms.team')}: ${o.label}`,
value: `team:${o.value}`
})),
...uStore.options.map((o) => ({
}))
const toUserRecipients = (options) => options.map((o) => ({
label: `${t('globals.terms.agent')}: ${o.label}`,
value: `user:${o.value}`
}))
const notifyRecipientOptions = computed(() => [
...recipientKeywords.value,
...toTeamRecipients(tStore.options),
...toUserRecipients(uStore.options)
])
const searchRecipients = async (query) => {
const [teams, users] = await Promise.all([
tStore.searchTeams(query),
uStore.searchUsers(query)
])
return [
...recipientKeywords.value,
...toTeamRecipients(teams),
...toUserRecipients(users)
]
}
// Recipients are prefixed, e.g. "user:12" / "team:3", alongside keywords like "assignee".
const recipientIDs = (prefix) =>
actions.value.flatMap((action) =>
(action.recipients || [])
.filter((r) => typeof r === 'string' && r.startsWith(prefix + ':'))
.map((r) => r.slice(prefix.length + 1))
)
onMounted(() => {
uStore.fetchUsers()
tStore.fetchTeams()
})
// Keyed on the ids alone: a deep watch on actions would refire on every subject/message keystroke.
const recipientIDKey = computed(() => JSON.stringify([recipientIDs('user'), recipientIDs('team')]))
watch(
recipientIDKey,
() => {
uStore.ensureUserIDs(recipientIDs('user'))
tStore.ensureTeamIDs(recipientIDs('team'))
},
{ immediate: true }
)
const handleNotifyRecipientsChange = (value, index) => {
actions.value[index].recipients = value || []
emitUpdate(index)
@@ -10,7 +10,9 @@
<RadioGroupItem value="OR" />
<Label>
<i18n-t keypath="admin.automation.matchBelow">
<template #any_or_all><b>{{ $t('admin.automation.any') }}</b></template>
<template #any_or_all
><b>{{ $t('admin.automation.any') }}</b></template
>
</i18n-t>
</Label>
</div>
@@ -18,7 +20,9 @@
<RadioGroupItem value="AND" />
<Label>
<i18n-t keypath="admin.automation.matchBelow">
<template #any_or_all><b>{{ $t('admin.automation.all') }}</b></template>
<template #any_or_all
><b>{{ $t('admin.automation.all') }}</b></template
>
</i18n-t>
</Label>
</div>
@@ -106,11 +110,21 @@
<!-- Select input -->
<div v-if="inputType(index) === 'select'">
<SelectAgentCombobox
v-if="getFieldEntity(rule.field, rule.field_type) === 'agent'"
v-model="rule.value"
@select="handleValueChange($event, index)"
/>
<SelectTeamCombobox
v-else-if="getFieldEntity(rule.field, rule.field_type) === 'team'"
v-model="rule.value"
@select="handleValueChange($event, index)"
/>
<SelectComboBox
v-else
v-model="rule.value"
:items="getFieldOptions(rule.field, rule.field_type)"
@select="handleValueChange($event, index)"
:type="rule.field === 'assigned_user' ? 'user' : 'team'"
/>
</div>
@@ -185,9 +199,7 @@
</div>
<div>
<Button type="button" variant="outline" size="sm" @click.prevent="addCondition">
{{
$t('actions.addCondition')
}}
{{ $t('actions.addCondition') }}
</Button>
</div>
</div>
@@ -221,6 +233,8 @@ import { Input } from '@shared-ui/components/ui/input'
import { useI18n } from 'vue-i18n'
import { useConversationFilters } from '../../../composables/useConversationFilters'
import SelectComboBox from '@main/components/combobox/SelectCombobox.vue'
import SelectAgentCombobox from '@main/components/combobox/SelectAgentCombobox.vue'
import SelectTeamCombobox from '@main/components/combobox/SelectTeamCombobox.vue'
import { operatorLabel } from '@/constants/filterConfig'
const props = defineProps({
@@ -353,6 +367,12 @@ const getFieldOperators = (field, fieldType) => {
return []
}
const getFieldEntity = (field, fieldType) => {
if ((fieldType || fieldTypeConstants.conversation) !== fieldTypeConstants.conversation)
return ''
return currentFilters.value[field]?.entity || ''
}
const getFieldOptions = (field, fieldType) => {
// Set default field type if not set for backwards compatibility as this field was added later.
if (!fieldType) {
@@ -153,29 +153,7 @@
<FormField v-slot="{ componentField }" name="author_id">
<FormItem>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger>
<SelectValue>{{ authorLabel }}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup v-if="agentOptions.length">
<SelectLabel>{{ t('globals.terms.agent', 2) }}</SelectLabel>
<SelectItem v-for="o in agentOptions" :key="o.value" :value="o.value">
{{ o.label }}
</SelectItem>
</SelectGroup>
<SelectGroup v-if="assistantOptions.length">
<SelectLabel>{{ t('admin.ai.assistants') }}</SelectLabel>
<SelectItem
v-for="o in assistantOptions"
:key="o.value"
:value="o.value"
>
{{ o.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<SelectAgentCombobox v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
@@ -319,12 +297,11 @@ import SwitchField from '@shared-ui/components/SwitchField.vue'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue
} from '@shared-ui/components/ui/select'
import SelectAgentCombobox from '@/components/combobox/SelectAgentCombobox.vue'
import { Sheet, SheetContent, SheetTitle, SheetDescription } from '@shared-ui/components/ui/sheet'
import {
FormControl,
@@ -343,7 +320,6 @@ import api from '@/api'
import { handleHTTPError } from '@shared-ui/utils/http.js'
import { useEmitter } from '@/composables/useEmitter.js'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useUsersStore } from '@/stores/users'
import { useUserStore } from '@/stores/user'
import { format } from 'date-fns'
@@ -390,11 +366,8 @@ const props = defineProps({
defineEmits(['update:open', 'cancel'])
const emitter = useEmitter()
const usersStore = useUsersStore()
const userStore = useUserStore()
const agentOptions = computed(() => usersStore.options.filter((o) => o.type === 'agent'))
const assistantOptions = computed(() => usersStore.options.filter((o) => o.type === 'ai_assistant'))
const isLoadingArticle = ref(false)
const availableCollections = ref([])
@@ -447,10 +420,6 @@ const collectionLabel = computed(
)?.name || ''
)
const authorLabel = computed(
() => usersStore.options.find((o) => o.value === String(form.values.author_id))?.label || ''
)
// A collection from another language is not a valid home for this article, so the choice is
// cleared rather than silently swapped for one the author never picked.
watch(localeCollections, (collections) => {
@@ -479,11 +448,7 @@ watch(
const seq = ++loadSeq
loadedArticle.value = null
isLoadingArticle.value = Boolean(props.article)
const [, , article] = await Promise.all([
usersStore.fetchUsers(),
fetchAvailableCollections(),
fetchArticle()
])
const [, article] = await Promise.all([fetchAvailableCollections(), fetchArticle()])
if (seq !== loadSeq) return
loadedArticle.value = article
isLoadingArticle.value = false
@@ -5,9 +5,6 @@
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('actions.noActions') }}
</h3>
@@ -64,28 +61,23 @@
>
<label class="block text-sm font-medium mb-2">{{ $t('globals.terms.value', 1) }}</label>
<SelectComboBox
<SelectAgentCombobox
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
<SelectTeamCombobox
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"
:search="config.actions[action.type].search"
@update:modelValue="(value) => updateValue(value, index)"
/>
</div>
@@ -97,11 +89,7 @@
class="max-w-md"
>
<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="$t('placeholders.selectTags')"
/>
<SelectTagCombobox multiple v-model="action.value" />
</div>
</div>
@@ -139,9 +127,10 @@ import {
SelectValue
} from '@shared-ui/components/ui/select'
import CloseButton from '@main/components/button/CloseButton.vue'
import { SelectTag } from '@shared-ui/components/ui/select'
import { useTagStore } from '../../../stores/tag'
import SelectComboBox from '@main/components/combobox/SelectCombobox.vue'
import SelectAgentCombobox from '@main/components/combobox/SelectAgentCombobox.vue'
import SelectTeamCombobox from '@main/components/combobox/SelectTeamCombobox.vue'
import SelectTagCombobox from '@main/components/combobox/SelectTagCombobox.vue'
const model = defineModel('actions', {
type: Array,
@@ -156,7 +145,6 @@ defineProps({
}
})
const tagsStore = useTagStore()
const updateField = (value, index) => {
const newModel = [...model.value]
@@ -48,31 +48,31 @@
</FormItem>
</FormField>
<FormField v-slot="{ componentField, handleChange }" name="visible_when">
<FormItem>
<FormLabel>{{ t('globals.messages.visibleWhen') }}</FormLabel>
<FormControl>
<SelectTag
:items="[
{ label: t('globals.messages.replying'), value: 'replying' },
{
label: t('actions.startingConversation'),
value: 'starting_conversation'
},
{
label: t('actions.addingPrivateNotes'),
value: 'adding_private_note'
}
]"
v-model="componentField.modelValue"
@update:modelValue="handleChange"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<FormField v-slot="{ componentField, handleChange }" name="visible_when">
<FormItem>
<FormLabel>{{ t('globals.messages.visibleWhen') }}</FormLabel>
<FormControl>
<SelectTag
:items="[
{ label: t('globals.messages.replying'), value: 'replying' },
{
label: t('actions.startingConversation'),
value: 'starting_conversation'
},
{
label: t('actions.addingPrivateNotes'),
value: 'adding_private_note'
}
]"
v-model="componentField.modelValue"
@update:modelValue="handleChange"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<div class="grid grid-cols-2 gap-4">
<FormField
v-slot="{ componentField }"
name="visibility"
@@ -102,31 +102,29 @@
</FormItem>
</FormField>
<FormField v-if="form.values.visibility === 'team'" v-slot="{ componentField }" name="team_id">
<FormField
v-if="form.values.visibility === 'team'"
v-slot="{ componentField }"
name="team_id"
>
<FormItem>
<FormLabel>{{ t('globals.terms.team') }}</FormLabel>
<FormControl>
<SelectComboBox
v-bind="componentField"
:items="tStore.options"
:placeholder="t('placeholders.selectTeam')"
type="team"
/>
<SelectTeamCombobox v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-if="form.values.visibility === 'user'" v-slot="{ componentField }" name="user_id">
<FormField
v-if="form.values.visibility === 'user'"
v-slot="{ componentField }"
name="user_id"
>
<FormItem>
<FormLabel>{{ t('globals.terms.agent') }}</FormLabel>
<FormControl>
<SelectComboBox
v-bind="componentField"
:items="uStore.options"
:placeholder="t('placeholders.selectAgent')"
type="user"
/>
<SelectAgentCombobox v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
@@ -143,14 +141,19 @@ import { toTypedSchema } from '@vee-validate/zod'
import { Button } from '@shared-ui/components/ui/button/index.js'
import { Spinner } from '@shared-ui/components/ui/spinner/index.js'
import { Input } from '@shared-ui/components/ui/input/index.js'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@shared-ui/components/ui/form/index.js'
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@shared-ui/components/ui/form/index.js'
import ActionBuilder from '@/features/admin/macros/ActionBuilder.vue'
import { useConversationFilters } from '../../../composables/useConversationFilters.js'
import { useUsersStore } from '../../../stores/users.js'
import { useTeamStore } from '../../../stores/team.js'
import { getTextFromHTML } from '@shared-ui/utils/string'
import { createFormSchema } from './formSchema.js'
import SelectComboBox from '@main/components/combobox/SelectCombobox.vue'
import SelectAgentCombobox from '@main/components/combobox/SelectAgentCombobox.vue'
import SelectTeamCombobox from '@main/components/combobox/SelectTeamCombobox.vue'
import {
Select,
SelectContent,
@@ -166,8 +169,6 @@ import Editor from '@main/components/editor/ConversationEditor.vue'
const { macroActions } = useConversationFilters()
const { t } = useI18n()
const formLoading = ref(false)
const uStore = useUsersStore()
const tStore = useTeamStore()
const props = defineProps({
initialValues: {
type: Object,
@@ -61,8 +61,8 @@ import {
AlertDialogTitle
} from '@shared-ui/components/ui/alert-dialog'
import { Button } from '@shared-ui/components/ui/button'
import { useEmitter } from '../../../composables/useEmitter'
import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
import { useEmitter } from '@main/composables/useEmitter'
import { EMITTER_EVENTS } from '@main/constants/emitterEvents.js'
import { useRouter } from 'vue-router'
import { useMacroStore } from '@main/stores/macro'
import api from '@main/api/index.js'
@@ -82,7 +82,7 @@ const props = defineProps({
const handleDelete = async () => {
await api.deleteMacro(props.macro.id)
await macroStore.loadMacros(true)
macroStore.clearCache()
isDeleteOpen.value = false
emit.emit(EMITTER_EVENTS.REFRESH_LIST, { model: 'macros' })
@@ -149,6 +149,7 @@ const permissions = ref([
{ name: perms.CONVERSATIONS_UPDATE_TAGS, label: t('admin.role.conversations.updateTags') },
{ name: perms.MESSAGES_READ, label: t('admin.role.messages.read') },
{ name: perms.MESSAGES_WRITE, label: t('admin.role.messages.write') },
{ name: perms.MESSAGES_WRITE_PRIVATE, label: t('admin.role.messages.writePrivate') },
{ name: perms.MESSAGES_WRITE_AS_CONTACT, label: t('admin.role.messages.writeAsContact') },
{ name: perms.VIEW_MANAGE, label: t('admin.role.view.manage') }
]
@@ -52,16 +52,15 @@
</FormItem>
</FormField>
<FormField v-if="form.values.visibility === 'team'" v-slot="{ componentField }" name="team_id">
<FormField
v-if="form.values.visibility === 'team'"
v-slot="{ componentField }"
name="team_id"
>
<FormItem>
<FormLabel>{{ t('globals.terms.team') }}</FormLabel>
<FormControl>
<SelectComboBox
v-bind="componentField"
:items="tStore.options"
:placeholder="t('placeholders.selectTeam')"
type="team"
/>
<SelectTeamCombobox v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
@@ -97,8 +96,7 @@ import {
createRoot
} from '@/components/filter/filterTree'
import { useConversationFilters } from '@/composables/useConversationFilters'
import { useTeamStore } from '@/stores/team'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
import SelectTeamCombobox from '@/components/combobox/SelectTeamCombobox.vue'
import {
Select,
SelectContent,
@@ -115,7 +113,6 @@ const { t } = useI18n()
const formLoading = ref(false)
const validateTick = ref(0)
provide('filterValidateTick', validateTick)
const tStore = useTeamStore()
const props = defineProps({
initialValues: {
type: Object,
@@ -149,7 +146,8 @@ const filterFields = computed(() =>
field,
type: value.type,
operators: value.operators,
options: value.options ?? []
options: value.options ?? [],
entity: value.entity
}))
)
@@ -202,15 +202,12 @@
{{ t('admin.sla.alertRecipients') }}
</FormLabel>
<FormControl>
<SelectTag
:items="
usersStore.options
.filter((o) => o.type !== 'ai_assistant')
.concat({
label: t('admin.sla.assignedUser'),
value: 'assigned_user'
})
"
<SelectAgentCombobox
multiple
exclude-ai-assistants
:prepend-items="[
{ label: t('admin.sla.assignedUser'), value: 'assigned_user' }
]"
:placeholder="t('globals.messages.startTypingToSearch')"
v-model="componentField.modelValue"
@update:modelValue="handleChange"
@@ -294,7 +291,6 @@ import {
Bell,
SlidersHorizontal
} from 'lucide-vue-next'
import { useUsersStore } from '../../../stores/users'
import {
FormControl,
FormField,
@@ -312,7 +308,7 @@ import {
SelectValue
} from '@shared-ui/components/ui/select'
import { useI18n } from 'vue-i18n'
import { SelectTag } from '@shared-ui/components/ui/select'
import SelectAgentCombobox from '@main/components/combobox/SelectAgentCombobox.vue'
import { Input } from '@shared-ui/components/ui/input'
const props = defineProps({
@@ -334,7 +330,6 @@ const props = defineProps({
}
})
const usersStore = useUsersStore()
const submitLabel = computed(() => {
return (
props.submitLabel ||
@@ -402,6 +397,7 @@ watch(
...newValues,
notifications: transformedNotifications
}, false)
},
{ immediate: true, deep: true }
)
@@ -4,15 +4,25 @@
v-model:search-term="searchTerm"
:filter-function="isMacroMode ? passThroughFilter : undefined"
@update:open="toggleOpen"
class="transform-gpu z-[51] !min-w-[50vw]"
:class="[
'z-[51] !top-[44%] !w-[calc(100%-1.5rem)] !min-w-0 gap-0 rounded-lg border-border/80 bg-popover shadow-lg [&>button]:right-3 [&>button]:top-3 [&>button]:rounded-md [&>button]:bg-muted/70 [&>button]:opacity-60 [&>button]:hover:opacity-100',
isMacroMode ? '!max-w-5xl' : '!max-w-2xl'
]"
command-class="rounded-lg bg-popover [&_[cmdk-input-wrapper]]:h-14 [&_[cmdk-input-wrapper]]:border-border/70 [&_[cmdk-input-wrapper]]:px-4 [&_[cmdk-input-wrapper]_svg]:text-muted-foreground [&_[cmdk-input]]:h-14 [&_[cmdk-input]]:pr-10 [&_[cmdk-input]]:text-base [&_[cmdk-group]]:py-2 [&_[cmdk-group-heading]]:px-3 [&_[cmdk-group-heading]]:pb-2 [&_[cmdk-group-heading]]:pt-1 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-wider [&_[cmdk-item]]:mx-0.5 [&_[cmdk-item]]:rounded-md [&_[cmdk-item]]:px-3 [&_[cmdk-item]]:py-2 [&_[cmdk-item]]:transition-colors [&_[cmdk-item]]:duration-150"
>
<CommandInput :placeholder="t('command.typeCmdOrSearch')" @keydown="onInputKeydown" />
<CommandList
class="!min-h-[50vh] h-[50vh] !min-w-[50vw]"
:class="{ 'overflow-hidden': nestedCommand === 'apply-macro' }"
:class="[
isMacroMode
? 'h-[50vh] min-h-[50vh] min-w-[50vw] overflow-hidden [&>div]:h-full'
: 'h-auto min-h-[220px] max-h-[min(52vh,440px)]',
{ 'overflow-hidden': nestedCommand === 'apply-macro' }
]"
>
<CommandEmpty>
<p class="text-sm text-muted-foreground">{{ $t('command.noCommandAvailable') }}</p>
<CommandEmpty v-if="!isMacroMode || !macroStore.searchLoading">
<p class="text-sm text-muted-foreground">
{{ $t(isMacroMode ? 'globals.messages.noResultsFound' : 'command.noCommandAvailable') }}
</p>
</CommandEmpty>
<!-- Snooze Options -->
@@ -47,12 +57,18 @@
</CommandGroup>
<!-- Macros -->
<div v-if="isMacroMode">
<CommandGroup :heading="$t('actions.applyMacro')">
<div class="min-h-[400px]">
<div class="h-[60vh] grid grid-cols-12">
<!-- Left Column: Macro List (30%) -->
<div ref="macroListRef" class="col-span-4 pr-2 border-r overflow-y-auto h-full">
<div v-if="isMacroMode" class="h-full">
<!-- Mounted only with results: radix's group counts its items once on mount and stays hidden when they arrive async. -->
<CommandGroup
v-if="visibleMacros.length"
class="flex h-full min-h-0 flex-col"
>
<div class="min-h-0 flex-1">
<div class="grid h-full min-h-0 grid-cols-12">
<div
ref="macroListRef"
class="col-span-4 h-full min-h-0 overflow-y-auto border-r border-border/70 pr-2"
>
<CommandItem
v-for="(macro, index) in visibleMacros"
:key="macro.value"
@@ -60,10 +76,9 @@
:data-index="index"
@select="handleApplyMacro(macro)"
@pointerenter="highlightedMacro = macro"
class="px-2 py-2 rounded-md cursor-pointer transition-colors duration-150 hover:bg-accent"
class="cursor-pointer"
>
<div class="flex items-center gap-2">
<Zap :size="16" class="shrink-0" />
<div class="flex min-w-0 items-center">
<span class="text-sm w-full break-words whitespace-normal">{{
macro.label
}}</span>
@@ -71,35 +86,35 @@
</CommandItem>
</div>
<!-- Right Column: Macro Details (70%) -->
<div class="col-span-8 px-4 overflow-y-auto h-full pb-6">
<div class="space-y-3 text-sm">
<!-- Reply Preview -->
<div v-if="replyContent" class="space-y-2">
<p class="text-xs font-medium uppercase tracking-wide text-muted-foreground">
<div class="col-span-8 h-full min-h-0 overflow-y-auto px-5 pb-5 pt-2">
<div class="flex min-h-full flex-col space-y-4 text-sm">
<div v-if="contentPending" class="flex flex-1 items-center justify-center">
<Spinner :absolute="false" />
</div>
<div v-else-if="replyContent" class="space-y-2">
<p class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{{ $t('command.replyPreview') }}
</p>
<Letter
:key="highlightedMacro?.value"
:html="replyContent"
:allowedSchemas="['cid', 'https', 'http', 'mailto']"
class="w-full min-h-[120px] p-3 bg-muted/30 rounded-md border overflow-auto native-html"
class="native-html min-h-[120px] w-full overflow-auto rounded-lg border bg-background p-4 shadow-sm"
/>
</div>
<!-- Actions -->
<div v-if="otherActions.length > 0" class="space-y-2">
<p class="text-xs font-medium uppercase tracking-wide text-muted-foreground">
<p class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{{ $t('globals.terms.action', 2) }}
</p>
<div class="space-y-1.5">
<div class="grid gap-2 sm:grid-cols-2">
<div
v-for="action in otherActions"
:key="action.type"
class="flex items-center gap-2 px-2.5 py-2 rounded-md text-sm bg-muted/50 hover:bg-accent hover:text-accent-foreground transition-colors duration-150 group"
class="flex min-w-0 items-center gap-2 rounded-lg border bg-muted/30 px-3 py-2 text-sm"
>
<div
class="p-1 rounded-md bg-muted/30 group-hover:bg-muted/50 transition-colors duration-150"
class="grid h-7 w-7 shrink-0 place-items-center rounded-md bg-background text-muted-foreground shadow-sm"
>
<User v-if="action.type === 'assign_user'" :size="14" class="shrink-0" />
<Users
@@ -124,12 +139,10 @@
</div>
</div>
<!-- Empty State -->
<div
v-if="!replyContent && otherActions.length === 0"
class="flex flex-col items-center justify-center h-32 gap-2"
v-if="!contentPending && !replyContent && otherActions.length === 0"
class="flex min-h-40 flex-1 flex-col items-center justify-center gap-2 rounded-lg border border-dashed bg-muted/20"
>
<Zap :size="20" class="text-muted-foreground/50" />
<p class="text-sm text-muted-foreground">
{{ $t('command.selectAMacro') }}
</p>
@@ -163,21 +176,35 @@
</CommandList>
<!-- Navigation -->
<div class="flex items-center gap-4 border-t px-3 py-2">
<div
class="flex flex-wrap items-center gap-x-4 gap-y-2 border-t border-border/70 bg-muted/30 px-4 py-2"
>
<span class="flex items-center gap-1 text-xs text-muted-foreground">
<kbd class="inline-flex h-5 items-center rounded-md border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">Enter</kbd>
<kbd
:class="KBD_CLASS"
>Enter</kbd
>
{{ $t('globals.terms.select') }}
</span>
<span class="flex items-center gap-1 text-xs text-muted-foreground">
<kbd class="inline-flex h-5 items-center rounded-md border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">&uarr;&darr;</kbd>
<kbd
:class="KBD_CLASS"
>&uarr;&darr;</kbd
>
{{ $t('command.navigate') }}
</span>
<span class="flex items-center gap-1 text-xs text-muted-foreground">
<kbd class="inline-flex h-5 items-center rounded-md border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">Esc</kbd>
<kbd
:class="KBD_CLASS"
>Esc</kbd
>
{{ $t('globals.messages.close') }}
</span>
<span v-if="nestedCommand" class="flex items-center gap-1 text-xs text-muted-foreground">
<kbd class="inline-flex h-5 items-center rounded-md border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">Backspace</kbd>
<kbd
:class="KBD_CLASS"
>Backspace</kbd
>
{{ $t('globals.messages.back') }}
</span>
</div>
@@ -199,7 +226,11 @@
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0">
<Calendar mode="single" v-model="selectedDate" @update:model-value="datePickerOpen = false" />
<Calendar
mode="single"
v-model="selectedDate"
@update:model-value="datePickerOpen = false"
/>
</PopoverContent>
</Popover>
<div class="grid gap-2">
@@ -215,13 +246,16 @@
</template>
<script setup>
const KBD_CLASS =
'inline-flex h-5 items-center rounded-sm border bg-background px-1.5 font-mono text-xs font-medium text-foreground shadow-sm'
import { ref, watch, onMounted, onUnmounted, computed } from 'vue'
import { useMagicKeys } from '@vueuse/core'
import { useMagicKeys, useDebounceFn } from '@vueuse/core'
import { CalendarIcon } from 'lucide-vue-next'
import { useConversationStore } from '@main/stores/conversation'
import { useMacroStore } from '@main/stores/macro'
import { CONVERSATION_DEFAULT_STATUSES, MACRO_CONTEXT } from '@main/constants/conversation'
import { Users, User, Pin, Rocket, Tags, Zap } from 'lucide-vue-next'
import { Users, User, Pin, Rocket, Tags } from 'lucide-vue-next'
import {
CommandDialog,
CommandInput,
@@ -245,11 +279,11 @@ import { Button } from '@shared-ui/components/ui/button'
import { Calendar } from '@shared-ui/components/ui/calendar'
import { Input } from '@shared-ui/components/ui/input'
import { Label } from '@shared-ui/components/ui/label'
import { Spinner } from '@shared-ui/components/ui/spinner'
import { handleHTTPError } from '@shared-ui/utils/http.js'
import { useI18n } from 'vue-i18n'
import { Letter } from 'vue-letter'
const RENDER_CAP = 200
const conversationStore = useConversationStore()
const macroStore = useMacroStore()
const { t } = useI18n()
@@ -271,22 +305,18 @@ const isMacroMode = computed(
nestedCommand.value === 'apply-macro-to-new-conversation'
)
const macroSearchIndex = computed(() =>
macroStore.macroOptions.map((m) => ({ macro: m, labelLower: String(m.label).toLowerCase() }))
)
const visibleMacros = computed(() => macroStore.macroOptions)
const visibleMacros = computed(() => {
const term = searchTerm.value?.trim().toLowerCase()
const index = macroSearchIndex.value
if (!term) {
const all = macroStore.macroOptions
return all.length > RENDER_CAP ? all.slice(0, RENDER_CAP) : all
}
const matched = []
for (let i = 0; i < index.length && matched.length < RENDER_CAP; i++) {
if (index[i].labelLower.includes(term)) matched.push(index[i].macro)
}
return matched
const searchMacrosDebounced = useDebounceFn(() => {
macroStore.searchMacros(searchTerm.value?.trim() || '')
}, 300)
watch(isMacroMode, (on) => {
if (on) macroStore.searchMacros(searchTerm.value?.trim() || '')
})
watch(searchTerm, () => {
if (isMacroMode.value) searchMacrosDebounced()
})
function preventDefaultOnHotkey(key) {
@@ -325,9 +355,29 @@ watch([Meta_M, Ctrl_M], ([mac, win]) => {
const highlightedMacro = ref(null)
function handleApplyMacro(macro) {
// New search results can drop the highlighted macro without any highlight mutation firing.
watch(visibleMacros, (macros) => {
if (highlightedMacro.value && !macros.some((m) => m.value === highlightedMacro.value.value)) {
highlightedMacro.value = null
}
})
async function handleApplyMacro(macro) {
let messageContent = ''
if (macro.has_message_content) {
try {
messageContent = await macroStore.fetchMacroContent(macro.id)
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
return
}
}
// Create a deep copy.
const plainMacro = JSON.parse(JSON.stringify(macro))
plainMacro.message_content = messageContent
if (nestedCommand.value === 'apply-macro-to-new-conversation') {
conversationStore.setMacro(plainMacro, MACRO_CONTEXT.NEW_CONVERSATION)
} else {
@@ -349,7 +399,29 @@ const getActionLabel = computed(() => (action) => {
return `${prefixes[action.type]}: ${action.display_value.length > 0 ? action.display_value.join(', ') : action.value.join(', ')}`
})
const replyContent = computed(() => highlightedMacro.value?.message_content || '')
const replyContent = computed(() => {
const macro = highlightedMacro.value
return macro ? macroStore.macroContents[macro.id] || '' : ''
})
const failedContentIDs = ref(new Set())
const contentPending = computed(() => {
const macro = highlightedMacro.value
if (!macro?.has_message_content) return false
return !(macro.id in macroStore.macroContents) && !failedContentIDs.value.has(macro.id)
})
let contentFetchTimer = null
watch(highlightedMacro, (macro) => {
clearTimeout(contentFetchTimer)
if (!macro?.has_message_content || macro.id in macroStore.macroContents) return
failedContentIDs.value.delete(macro.id)
contentFetchTimer = setTimeout(() => {
macroStore.fetchMacroContent(macro.id).catch(() => failedContentIDs.value.add(macro.id))
}, 150)
})
const otherActions = computed(
() =>
@@ -431,9 +503,13 @@ watch(macroListRef, (el) => {
if (!el) return
highlightObserver = new MutationObserver(() => {
const idx = el.querySelector('[data-highlighted]')?.getAttribute('data-index')
highlightedMacro.value = idx != null ? visibleMacros.value[idx] : null
if (idx != null) highlightedMacro.value = visibleMacros.value[idx]
})
highlightObserver.observe(el, {
attributes: true,
attributeFilter: ['data-highlighted'],
subtree: true
})
highlightObserver.observe(el, { attributes: true, attributeFilter: ['data-highlighted'], subtree: true })
})
onMounted(() => {
@@ -443,5 +519,6 @@ onMounted(() => {
onUnmounted(() => {
emitter.off(EMITTER_EVENTS.SET_NESTED_COMMAND, nestedCommandHandler)
highlightObserver?.disconnect()
clearTimeout(contentFetchTimer)
})
</script>
@@ -67,7 +67,7 @@
{{ t('conversation.downloadTranscript') }}
</DropdownMenuItem>
<DropdownMenuItem
v-if="userStore.can('messages:write')"
v-if="userStore.can(perms.MESSAGES_WRITE_PRIVATE)"
:disabled="isSummarizing"
@click="summarize"
>
@@ -81,7 +81,7 @@
<!-- Messages & reply box -->
<div class="flex flex-col flex-grow overflow-hidden">
<MessageList class="flex-1 overflow-y-auto" />
<ReplyBox />
<ReplyBox v-if="canCompose" />
</div>
</div>
</template>
@@ -111,6 +111,7 @@ import { useI18n } from 'vue-i18n'
import { handleHTTPError } from '@shared-ui/utils/http.js'
import { downloadBlobResponse, parseBlobError } from '@shared-ui/utils/file'
import api from '@main/api'
import { permissions as perms } from '@main/constants/permissions.js'
const conversationStore = useConversationStore()
const userStore = useUserStore()
const emitter = useEmitter()
@@ -118,6 +119,9 @@ const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const isMobile = useIsMobile()
const canCompose = computed(
() => userStore.can(perms.MESSAGES_WRITE) || userStore.can(perms.MESSAGES_WRITE_PRIVATE)
)
// Each detail route is `<list route name>-conversation`.
const goBackToList = () => {
@@ -148,15 +148,7 @@
({{ $t('globals.terms.optional') }})
</FormLabel>
<FormControl>
<SelectComboBox
v-bind="componentField"
:items="[
{ value: 'none', label: t('globals.terms.none') },
...teamStore.options
]"
:placeholder="t('placeholders.selectTeam')"
type="team"
/>
<SelectTeamCombobox v-bind="componentField" include-none />
</FormControl>
<FormMessage />
</FormItem>
@@ -170,15 +162,7 @@
({{ $t('globals.terms.optional') }})
</FormLabel>
<FormControl>
<SelectComboBox
v-bind="componentField"
:items="[
{ value: 'none', label: t('globals.terms.none') },
...uStore.options
]"
:placeholder="t('placeholders.selectAgent')"
type="user"
/>
<SelectAgentCombobox v-bind="componentField" include-none />
</FormControl>
<FormMessage />
</FormItem>
@@ -286,8 +270,6 @@ import { MACRO_CONTEXT } from '@main/constants/conversation'
import { useEmitter } from '@main/composables/useEmitter'
import { handleHTTPError } from '@shared-ui/utils/http.js'
import { useInboxStore } from '@main/stores/inbox'
import { useUsersStore } from '@main/stores/users'
import { useTeamStore } from '@main/stores/team'
import {
Select,
SelectContent,
@@ -300,7 +282,8 @@ import { useI18n } from 'vue-i18n'
import { useFileUpload } from '@/composables/useFileUpload'
import Editor from '@/components/editor/ConversationEditor.vue'
import { useMacroStore } from '@/stores/macro'
import SelectComboBox from '@/components/combobox/SelectCombobox.vue'
import SelectAgentCombobox from '@/components/combobox/SelectAgentCombobox.vue'
import SelectTeamCombobox from '@/components/combobox/SelectTeamCombobox.vue'
import { UserTypeAgent } from '@/constants/user'
import { IdCard } from 'lucide-vue-next'
import api from '@/api'
@@ -314,8 +297,6 @@ const dialogOpen = defineModel({
const inboxStore = useInboxStore()
const { t } = useI18n()
const uStore = useUsersStore()
const teamStore = useTeamStore()
const emitter = useEmitter()
const isCramped = useIsComposerCramped()
const loading = ref(false)
@@ -76,6 +76,8 @@
@filesDropped="uploadFiles"
@aiPromptSelected="handleAiPromptSelected"
:isGenerating="isGenerating"
:canSendReply="canSendReply"
:canSendPrivateNote="canSendPrivateNote"
@generateReply="handleGenerateReply"
class="h-full flex-grow"
/>
@@ -136,6 +138,8 @@
@filesDropped="uploadFiles"
@aiPromptSelected="handleAiPromptSelected"
:isGenerating="isGenerating"
:canSendReply="canSendReply"
:canSendPrivateNote="canSendPrivateNote"
@generateReply="handleGenerateReply"
/>
</div>
@@ -175,6 +179,7 @@ import { useFileUpload } from '@main/composables/useFileUpload'
import { hasInlineImage, hasPendingInlineUpload } from '@main/composables/useInlineImageUpload'
import ReplyBoxContent from '@/features/conversation/ReplyBoxContent.vue'
import { UserTypeAgent } from '@/constants/user'
import { permissions as perms } from '@main/constants/permissions.js'
const { t } = useI18n()
const conversationStore = useConversationStore()
@@ -185,6 +190,16 @@ const userStore = useUserStore()
const isCramped = useIsComposerCramped()
useVisualViewportHeight()
const canSendReply = computed(() => userStore.can(perms.MESSAGES_WRITE))
const canSendPrivateNote = computed(() => userStore.can(perms.MESSAGES_WRITE_PRIVATE))
const defaultMessageType = computed(() => (canSendReply.value ? 'reply' : 'private_note'))
const isAllowedMessageType = (type) =>
(type === 'reply' && canSendReply.value) || (type === 'private_note' && canSendPrivateNote.value)
const resolveAllowedDraftType = (uuid) => {
const type = conversationStore.resolveDraftType(uuid)
return isAllowedMessageType(type) ? type : defaultMessageType.value
}
// Setup file upload composable
const {
uploadingFiles,
@@ -205,14 +220,15 @@ watch(
async (uuid, prevUuid) => {
if (prevUuid) conversationStore.setSelectedDraftType(prevUuid, messageType.value)
if (!uuid) {
messageType.value = 'reply'
messageType.value = defaultMessageType.value
return
}
messageType.value = conversationStore.resolveDraftType(uuid)
const initialType = resolveAllowedDraftType(uuid)
messageType.value = initialType
// Prefetch may still be in flight on first load; re-resolve once drafts land.
await conversationStore.draftsReady
if (uuid !== currentConversationUUID.value) return
messageType.value = conversationStore.resolveDraftType(uuid)
if (uuid !== currentConversationUUID.value || messageType.value !== initialType) return
messageType.value = resolveAllowedDraftType(uuid)
},
{ immediate: true }
)
@@ -277,7 +293,7 @@ const handleGenerateReply = () =>
// Copilot's "Insert into reply" replaces the draft with its answer (already HTML from the panel),
// forcing reply mode so a private note in progress does not silently receive customer-facing text.
const handleCopilotInsertReply = (html) => {
if (!html) return
if (!html || !canSendReply.value) return
if (messageType.value === 'private_note') messageType.value = 'reply'
htmlContent.value = html
}
@@ -311,6 +327,8 @@ const processSend = async (skipContactEmailCheck = false, skipMissingTagsCheck =
const convUUID = conversationStore.current.uuid
const isPrivate = messageType.value === 'private_note'
if ((isPrivate && !canSendPrivateNote.value) || (!isPrivate && !canSendReply.value)) return
const currentInbox = inboxStore.inboxes.find(
(i) => i.id === conversationStore.current.inbox_id
)
@@ -3,28 +3,32 @@
<div class="flex flex-col h-full" :class="{ 'max-h-[600px]': !isFullscreen }">
<!-- Message type toggle -->
<div
class="flex justify-between items-center"
class="flex items-center justify-between"
:class="{ 'mb-4': !isFullscreen, 'border-b border-border pb-4': isFullscreen }"
>
<Tabs v-model="messageType" class="rounded-md border">
<TabsList class="bg-muted p-1 rounded-md">
<Tabs v-model="messageType" class="rounded-lg">
<TabsList class="rounded-lg border bg-muted/50 p-0.5">
<TabsTrigger
v-if="canSendReply"
value="reply"
class="px-3 py-1 max-md:py-2.5 rounded-md transition-colors duration-200"
:class="{ 'bg-background text-foreground': messageType === 'reply' }"
:class="TAB_TRIGGER_CLASS"
>
{{ $t('globals.terms.reply') }}
</TabsTrigger>
<TabsTrigger
v-if="canSendPrivateNote"
value="private_note"
class="px-3 py-1 max-md:py-2.5 rounded-md transition-colors duration-200"
:class="{ 'bg-background text-foreground': messageType === 'private_note' }"
:class="TAB_TRIGGER_CLASS"
>
{{ $t('globals.terms.privateNote') }}
</TabsTrigger>
</TabsList>
</Tabs>
<Button class="text-muted-foreground max-md:h-11 max-md:w-11 max-md:p-0" variant="ghost" @click="toggleFullscreen">
<Button
class="text-muted-foreground max-md:h-11 max-md:w-11 max-md:p-0"
variant="ghost"
@click="toggleFullscreen"
>
<component :is="isFullscreen ? Minimize2 : Maximize2" />
</Button>
</div>
@@ -32,43 +36,39 @@
<!-- To, CC, and BCC fields -->
<div v-if="conversationStore.current.inbox_channel === 'email'">
<div
:class="['space-y-3', isFullscreen ? 'p-4 border-b border-border' : 'mb-4']"
:class="['space-y-3', isFullscreen ? 'border-b border-border p-4' : 'mb-3']"
v-if="messageType === 'reply'"
>
<div class="flex items-center space-x-2">
<label class="w-12 text-sm font-medium text-muted-foreground">TO:</label>
<div class="flex items-center gap-2">
<label class="w-12 text-xs font-semibold tracking-wide text-muted-foreground">TO:</label>
<Input
type="text"
:placeholder="t('replyBox.emailAddresess')"
v-model="to"
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
:class="RECIPIENT_INPUT_CLASS"
@blur="validateEmails"
/>
</div>
<div class="flex items-center space-x-2">
<label class="w-12 text-sm font-medium text-muted-foreground">CC:</label>
<div class="flex items-center gap-2">
<label class="w-12 text-xs font-semibold tracking-wide text-muted-foreground">CC:</label>
<Input
type="text"
:placeholder="t('replyBox.emailAddresess')"
v-model="cc"
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
:class="RECIPIENT_INPUT_CLASS"
@blur="validateEmails"
/>
<Button
size="sm"
@click="toggleBcc"
class="text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80"
>
<Button size="sm" @click="toggleBcc" variant="secondary">
{{ showBcc ? $t('replyBox.removeBCC') : $t('replyBox.bcc') }}
</Button>
</div>
<div v-if="showBcc" class="flex items-center space-x-2">
<label class="w-12 text-sm font-medium text-muted-foreground">BCC:</label>
<div v-if="showBcc" class="flex items-center gap-2">
<label class="w-12 text-xs font-semibold tracking-wide text-muted-foreground">BCC:</label>
<Input
type="text"
:placeholder="t('replyBox.emailAddresess')"
v-model="bcc"
class="flex-grow px-3 py-2 text-sm border rounded-md focus:ring-2 focus:ring-ring"
:class="RECIPIENT_INPUT_CLASS"
@blur="validateEmails"
/>
</div>
@@ -77,7 +77,7 @@
<!-- email errors -->
<div
v-if="emailErrors.length > 0"
class="mb-4 px-2 py-1 bg-destructive/10 border border-destructive text-destructive rounded-md"
class="mb-3 rounded-md border border-destructive bg-destructive/10 px-3 py-2 text-destructive"
>
<p v-for="error in emailErrors" :key="error" class="text-sm">{{ error }}</p>
</div>
@@ -124,7 +124,7 @@
<!-- Editor menu bar with send button -->
<ReplyBoxMenuBar
class="mt-1 shrink-0"
class="mt-2"
:isFullscreen="isFullscreen"
:handleFileUpload="handleFileUpload"
:isSending="isSending"
@@ -140,7 +140,14 @@
</template>
<script setup>
const RECIPIENT_INPUT_CLASS =
'flex-grow border-input bg-card px-3 py-2 text-sm shadow-none focus-visible:ring-1 focus-visible:ring-ring'
const TAB_TRIGGER_CLASS =
'rounded-md px-3 py-1 text-sm transition-colors duration-150 max-md:py-2.5 data-[state=active]:bg-card data-[state=active]:shadow-sm'
import { ref, computed, nextTick, watch } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import { EMITTER_EVENTS } from '@main/constants/emitterEvents.js'
import { MACRO_CONTEXT } from '@main/constants/conversation'
import { Maximize2, Minimize2 } from 'lucide-vue-next'
@@ -158,8 +165,10 @@ import ReplyBoxMenuBar from '@/features/conversation/ReplyBoxMenuBar.vue'
import { useI18n } from 'vue-i18n'
import { validateEmail } from '@shared-ui/utils/string'
import { useMacroStore } from '@main/stores/macro'
import { useUsersStore } from '@main/stores/users'
import { useTeamStore } from '@main/stores/team'
import api from '@main/api'
const MENTION_LIMIT = 10
const MENTION_DEBOUNCE_MS = 250
const messageType = defineModel('messageType', { default: 'reply' })
const to = defineModel('to', { default: '' })
@@ -171,23 +180,16 @@ const htmlContent = defineModel('htmlContent', { default: '' })
const textContent = defineModel('textContent', { default: '' })
const mentions = defineModel('mentions', { default: () => [] })
const macroStore = useMacroStore()
const usersStore = useUsersStore()
const teamStore = useTeamStore()
// Get suggestions for the mention dropdown
const getSuggestions = async (query) => {
// Only show suggestions in private note mode
if (messageType.value !== 'private_note') {
return []
}
const fetchSuggestions = async (query) => {
// Mentions run their own query so typing here never disturbs the shared agent and team pickers.
const [agentsResponse, teamsResponse] = await Promise.all([
api.getUsersCompact({ q: query, page_size: MENTION_LIMIT, type: 'agent', enabled: true }),
api.getTeamsCompact({ q: query, page_size: MENTION_LIMIT })
])
await Promise.all([usersStore.fetchUsers(), teamStore.fetchTeams()])
const q = query.toLowerCase()
const users = usersStore.users
.filter((u) => u.enabled && u.type !== 'ai_assistant')
.filter((u) => `${u.first_name} ${u.last_name}`.toLowerCase().includes(q))
const users = (agentsResponse?.data?.data || [])
.map((u) => ({
id: u.id,
type: 'agent',
@@ -195,16 +197,21 @@ const getSuggestions = async (query) => {
avatar_url: u.avatar_url
}))
const teams = teamStore.teams
.filter((t) => t.name.toLowerCase().includes(q))
.map((t) => ({
id: t.id,
type: 'team',
label: t.name,
emoji: t.emoji
}))
const teams = (teamsResponse?.data?.data || []).map((t) => ({
id: t.id,
type: 'team',
label: t.name,
emoji: t.emoji
}))
return [...users, ...teams].slice(0, 25)
return [...users, ...teams].slice(0, MENTION_LIMIT)
}
const debouncedFetchSuggestions = useDebounceFn(fetchSuggestions, MENTION_DEBOUNCE_MS)
const getSuggestions = async (query) => {
if (messageType.value !== 'private_note') return []
return (await debouncedFetchSuggestions(query)) || []
}
// Handle mentions changed from editor
@@ -242,6 +249,14 @@ const props = defineProps({
isGenerating: {
type: Boolean,
default: false
},
canSendReply: {
type: Boolean,
required: true
},
canSendPrivateNote: {
type: Boolean,
required: true
}
})
@@ -288,7 +303,8 @@ const enableSend = computed(() => {
conversationStore.getMacro('reply')?.actions?.length > 0 ||
props.uploadedFiles.length > 0) &&
emailErrors.value.length === 0 &&
!props.uploadingFiles.length && !props.isDraftLoading
!props.uploadingFiles.length &&
!props.isDraftLoading
)
})
@@ -1,6 +1,6 @@
<template>
<div
class="flex justify-between h-14 relative"
class="relative flex h-14 justify-between"
:class="{ 'items-end': isFullscreen, 'items-center': !isFullscreen }"
>
<EmojiPicker
@@ -10,7 +10,7 @@
class="absolute bottom-14 left-0 md:left-14 z-20"
v-if="isEmojiPickerVisible"
/>
<div class="flex justify-items-start gap-2">
<div class="flex items-center gap-1">
<!-- File inputs -->
<input type="file" class="hidden" ref="attachmentInput" multiple @change="handleFileUpload" />
<!-- <input
@@ -21,36 +21,52 @@
@change="handleInlineImageUpload"
/> -->
<!-- Editor buttons -->
<Toggle
class="px-2 py-2 max-md:min-h-11 max-md:min-w-11 border-0"
variant="outline"
@click="triggerFileUpload"
:pressed="false"
>
<Paperclip class="h-4 w-4" />
</Toggle>
<Toggle
class="px-2 py-2 max-md:min-h-11 max-md:min-w-11 border-0"
variant="outline"
@click="toggleEmojiPicker"
:pressed="isEmojiPickerVisible"
>
<Smile class="h-4 w-4" />
</Toggle>
<Toggle
v-if="showGenerateReply"
class="px-2 py-2 max-md:min-h-11 max-md:min-w-11 border-0"
variant="outline"
:pressed="false"
:disabled="isGenerating"
:title="$t('replyBox.generateReply')"
@click="emit('generateReply')"
>
<Loader2 v-if="isGenerating" class="h-4 w-4 animate-spin" />
<Sparkles v-else class="h-4 w-4" />
</Toggle>
<Tooltip>
<TooltipTrigger as-child>
<Toggle
:class="ICON_BUTTON_CLASS"
variant="outline"
:aria-label="$t('globals.messages.attachFile')"
@click="triggerFileUpload"
:pressed="false"
>
<Paperclip class="h-4 w-4" />
</Toggle>
</TooltipTrigger>
<TooltipContent>{{ $t('globals.messages.attachFile') }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Toggle
:class="ICON_BUTTON_CLASS"
variant="outline"
:aria-label="$t('globals.messages.addEmoji')"
@click="toggleEmojiPicker"
:pressed="isEmojiPickerVisible"
>
<Smile class="h-4 w-4" />
</Toggle>
</TooltipTrigger>
<TooltipContent>{{ $t('globals.messages.addEmoji') }}</TooltipContent>
</Tooltip>
<Tooltip v-if="showGenerateReply">
<TooltipTrigger as-child>
<Toggle
:class="ICON_BUTTON_CLASS"
variant="outline"
:pressed="false"
:disabled="isGenerating"
:aria-label="$t('replyBox.generateReply')"
@click="emit('generateReply')"
>
<Loader2 v-if="isGenerating" class="h-4 w-4 animate-spin" />
<Sparkles v-else class="h-4 w-4" />
</Toggle>
</TooltipTrigger>
<TooltipContent>{{ $t('replyBox.generateReply') }}</TooltipContent>
</Tooltip>
</div>
<div class="flex items-center">
<div class="flex items-center rounded-md shadow-sm">
<Button
class="h-8 max-md:h-11 px-4 rounded-r-none"
@click="handleSend"
@@ -85,10 +101,14 @@
</template>
<script setup>
const ICON_BUTTON_CLASS =
'border-border/70 bg-background px-2 py-2 text-muted-foreground shadow-none max-md:min-h-11 max-md:min-w-11'
import { ref, defineAsyncComponent } from 'vue'
import { onClickOutside } from '@vueuse/core'
import { Button } from '@shared-ui/components/ui/button'
import { Toggle } from '@shared-ui/components/ui/toggle'
import { Tooltip, TooltipContent, TooltipTrigger } from '@shared-ui/components/ui/tooltip'
import { Paperclip, Smile, ChevronDownIcon, Sparkles, Loader2 } from 'lucide-vue-next'
import {
DropdownMenu,
@@ -101,10 +121,7 @@ import { useConversationStore } from '@main/stores/conversation'
const conversationStore = useConversationStore()
const EmojiPicker = defineAsyncComponent(async () => {
const [mod] = await Promise.all([
import('vue3-emoji-picker'),
import('vue3-emoji-picker/css'),
])
const [mod] = await Promise.all([import('vue3-emoji-picker'), import('vue3-emoji-picker/css')])
return mod.default
})
@@ -18,11 +18,9 @@
</span>
<!-- Assign Agent -->
<SelectComboBox
<SelectAgentCombobox
v-if="canAssignAgent"
:items="agentItems"
:placeholder="t('placeholders.selectAgent')"
type="user"
include-none
align="start"
@select="(item) => onAssigneeSelect('user', item)"
>
@@ -37,14 +35,12 @@
<UserPlus class="w-4 h-4" />
</Button>
</template>
</SelectComboBox>
</SelectAgentCombobox>
<!-- Assign Team -->
<SelectComboBox
<SelectTeamCombobox
v-if="canAssignTeam"
:items="teamItems"
:placeholder="t('placeholders.selectTeam')"
type="team"
include-none
align="start"
@select="(item) => onAssigneeSelect('team', item)"
>
@@ -59,13 +55,11 @@
<Users class="w-4 h-4" />
</Button>
</template>
</SelectComboBox>
</SelectTeamCombobox>
<!-- Add Tag -->
<SelectComboBox
<SelectTagCombobox
v-if="canUpdateTags"
:items="tagItems"
:placeholder="t('placeholders.selectTags')"
align="start"
@select="onTagSelect"
>
@@ -80,7 +74,7 @@
<Tag class="w-4 h-4" />
</Button>
</template>
</SelectComboBox>
</SelectTagCombobox>
<!-- Set Status -->
<DropdownMenu v-if="canUpdateStatus">
@@ -121,7 +115,7 @@
</template>
<script setup>
import { computed, ref, onMounted } from 'vue'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { UserPlus, Users, Tag, CircleDot, Loader2, X } from 'lucide-vue-next'
import { Button } from '@shared-ui/components/ui/button'
@@ -132,33 +126,23 @@ import {
DropdownMenuItem,
DropdownMenuTrigger
} from '@shared-ui/components/ui/dropdown-menu'
import SelectComboBox from '@main/components/combobox/SelectCombobox.vue'
import SelectAgentCombobox from '@main/components/combobox/SelectAgentCombobox.vue'
import SelectTeamCombobox from '@main/components/combobox/SelectTeamCombobox.vue'
import SelectTagCombobox from '@main/components/combobox/SelectTagCombobox.vue'
import { TAG_ACTION } from '@/constants/conversation'
import { useConversationStore } from '@/stores/conversation'
import { useUsersStore } from '@/stores/users'
import { useTeamStore } from '@/stores/team'
import { useTagStore } from '@/stores/tag'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents'
import { useBulkActionPermissions } from '@/composables/useBulkActionPermissions'
import api from '@/api'
const conversationStore = useConversationStore()
const usersStore = useUsersStore()
const teamsStore = useTeamStore()
const tagStore = useTagStore()
const { t } = useI18n()
const emitter = useEmitter()
const bulkLoading = ref(false)
const { canAssignAgent, canAssignTeam, canUpdateStatus, canUpdateTags } = useBulkActionPermissions()
onMounted(() => {
if (canAssignAgent.value) usersStore.fetchUsers()
if (canAssignTeam.value) teamsStore.fetchTeams()
if (canUpdateTags.value) tagStore.fetchTags()
})
const toggleSelectAll = () => {
if (conversationStore.allSelected) {
conversationStore.clearSelection()
@@ -167,17 +151,6 @@ const toggleSelectAll = () => {
}
}
const withNoneOption = (options) => [
{ value: 'none', label: t('globals.terms.none') },
...options
]
const agentItems = computed(() => withNoneOption(usersStore.options))
const teamItems = computed(() => withNoneOption(teamsStore.options))
const tagItems = computed(() =>
tagStore.tagNames.map((name) => ({ label: name, value: name }))
)
const runBulkAction = async (actionFn) => {
const uuids = [...conversationStore.selectedUUIDs]
bulkLoading.value = true
@@ -164,7 +164,22 @@
<!-- Status Icons (outgoing only) -->
<div v-if="isOutgoing" class="flex items-center space-x-2 mt-2 self-end">
<Lock :size="12" v-if="isPrivateMessage" class="text-muted-foreground" />
<Check :size="14" v-if="showCheckCheck" class="text-success" />
<Tooltip v-if="isReadByContact">
<TooltipTrigger>
<CheckCheck :size="14" class="text-success" />
</TooltipTrigger>
<TooltipContent>
<p>{{ t('globals.terms.read') }}</p>
</TooltipContent>
</Tooltip>
<Tooltip v-else-if="isDelivered">
<TooltipTrigger>
<Check :size="14" class="text-success" />
</TooltipTrigger>
<TooltipContent>
<p>{{ t('globals.terms.sent') }}</p>
</TooltipContent>
</Tooltip>
<Tooltip v-if="message.meta?.continuity_emailed">
<TooltipTrigger>
<Mail :size="12" class="text-muted-foreground" />
@@ -256,7 +271,7 @@ import { computed, ref, onMounted, nextTick } from 'vue'
import { useConversationStore } from '@main/stores/conversation'
import { useUserStore } from '@main/stores/user'
import { useI18n } from 'vue-i18n'
import { Lock, Mail, RotateCcw, Check, Maximize2, Trash2, MoreHorizontal } from 'lucide-vue-next'
import { Lock, Mail, RotateCcw, Check, CheckCheck, Maximize2, Trash2, MoreHorizontal } from 'lucide-vue-next'
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -399,9 +414,16 @@ const canDeleteNote = computed(
!isDeleted.value &&
(props.message.sender_id === userStore.userID || userStore.hasAdminRole)
)
const showCheckCheck = computed(
const isDelivered = computed(
() => isOutgoing.value && props.message.status === 'sent' && !isPrivateMessage.value
)
const isReadByContact = computed(() => {
const conversation = convStore.current
const lastSeenAt = conversation?.contact_last_seen_at
const isLiveChat = conversation?.inbox_channel === 'livechat'
if (!isDelivered.value || !lastSeenAt || !isLiveChat) return false
return new Date(props.message.created_at) <= new Date(lastSeenAt)
})
const showRetry = computed(() => isOutgoing.value && props.message.status === 'failed' && props.message.sender_id === userStore.userID)
const retryMessage = (msg) => {
@@ -16,22 +16,19 @@
<!-- Agent, team, priority, and tags assignment -->
<AccordionContent class="accordion-content--actions">
<div>
<SelectComboBox
<SelectAgentCombobox
v-model="conversationStore.current.assigned_user_id"
:items="agentOptions"
:placeholder="t('placeholders.selectAgent')"
include-none
current-user-first
@select="selectAgent"
type="user"
/>
</div>
<div>
<SelectComboBox
v-model="conversationStore.current.assigned_team_id"
:items="[{ value: 'none', label: t('globals.terms.none') }, ...teamsStore.options]"
:placeholder="t('placeholders.selectTeam')"
<SelectTeamCombobox
:model-value="conversationStore.current.assigned_team_id"
include-none
@select="selectTeam"
type="team"
/>
</div>
@@ -46,11 +43,10 @@
</div>
<div v-if="conversationStore.current">
<SelectTag
<SelectTagCombobox
multiple
:model-value="conversationStore.current.tags || []"
@update:modelValue="onTagsChange"
:items="tags.map((tag) => ({ label: tag, value: tag }))"
:placeholder="t('placeholders.selectTags')"
/>
<div class="mt-2 flex flex-wrap items-center gap-1">
<Button
@@ -156,13 +152,10 @@
</template>
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { ref, computed, watch } from 'vue'
import { Sparkles, Loader2, Plus } from 'lucide-vue-next'
import { Button } from '@shared-ui/components/ui/button'
import { useConversationStore } from '@/stores/conversation'
import { useUsersStore } from '@/stores/users'
import { useTeamStore } from '@/stores/team'
import { useTagStore } from '@/stores/tag'
import { useUserStore } from '@/stores/user'
import { COPILOT_NAME } from '@/constants/copilot'
import {
@@ -175,7 +168,6 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@shared-ui/components/
import ConversationInfo from './ConversationInfo.vue'
import ConversationSideBarContact from '@/features/conversation/sidebar/ConversationSideBarContact.vue'
import CopilotPanel from '@/features/conversation/sidebar/CopilotPanel.vue'
import { SelectTag } from '@shared-ui/components/ui/select'
import { handleHTTPError } from '@shared-ui/utils/http.js'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { useEmitter } from '@/composables/useEmitter'
@@ -187,26 +179,21 @@ import ContactNotes from '@/features/contact/ContactNotes.vue'
import PreviousConversations from '@/features/conversation/sidebar/PreviousConversations.vue'
import ConversationSideBarPageVisits from '@/features/conversation/sidebar/ConversationSideBarPageVisits.vue'
import SelectComboBox from '@main/components/combobox/SelectCombobox.vue'
import SelectAgentCombobox from '@main/components/combobox/SelectAgentCombobox.vue'
import SelectTeamCombobox from '@main/components/combobox/SelectTeamCombobox.vue'
import SelectTagCombobox from '@main/components/combobox/SelectTagCombobox.vue'
import { TAG_ACTION } from '@/constants/conversation'
import api from '@/api'
const customAttributeStore = useCustomAttributeStore()
const emitter = useEmitter()
const conversationStore = useConversationStore()
const usersStore = useUsersStore()
const teamsStore = useTeamStore()
const tagStore = useTagStore()
const userStore = useUserStore()
const tags = ref([])
const accordionState = useStorage('conversation-sidebar-accordion', [])
const activeTab = useStorage('conversation-sidebar-tab', 'details')
const { t } = useI18n()
customAttributeStore.fetchCustomAttributes()
onMounted(async () => {
await fetchTags()
})
const onTagsChange = (newTags) => {
const conv = conversationStore.current
if (!conv) return
@@ -264,19 +251,6 @@ const applySuggestedTag = (tag) => {
const priorityOptions = computed(() => conversationStore.priorityOptions)
const agentOptions = computed(() => {
const none = { value: 'none', label: t('globals.terms.none') }
const isMe = (option) => String(option.value) === String(userStore.userID)
const me = usersStore.options.find(isMe)
if (!me) return [none, ...usersStore.options]
return [none, me, ...usersStore.options.filter((option) => !isMe(option))]
})
const fetchTags = async () => {
await tagStore.fetchTags()
tags.value = tagStore.tags.map((item) => item.name)
}
const handleAssignedUserChange = (id) => {
conversationStore.updateAssignee('user', {
assignee_id: parseInt(id)
@@ -100,7 +100,7 @@
</TooltipTrigger>
<TooltipContent>{{ $t('globals.terms.copy') }}</TooltipContent>
</Tooltip>
<Tooltip>
<Tooltip v-if="canSendReply">
<TooltipTrigger as-child>
<Button
type="button"
@@ -114,7 +114,7 @@
</TooltipTrigger>
<TooltipContent>{{ $t('copilot.insertIntoReply') }}</TooltipContent>
</Tooltip>
<Tooltip>
<Tooltip v-if="canSendPrivateNote">
<TooltipTrigger as-child>
<Button
type="button"
@@ -178,20 +178,25 @@ import { Eraser, Bot, Copy, Reply, StickyNote } from 'lucide-vue-next'
import { useConversationStore } from '@/stores/conversation'
import { useCopilotStore } from '@/stores/copilot'
import { useAIAssistantStore } from '@/stores/aiAssistant'
import { useUserStore } from '@/stores/user'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { handleHTTPError } from '@shared-ui/utils/http.js'
import { getTextFromHTML } from '@shared-ui/utils/string.js'
import { UserTypeAgent } from '@/constants/user'
import { COPILOT_NAME } from '@/constants/copilot'
import { permissions as perms } from '@/constants/permissions.js'
import { useI18n } from 'vue-i18n'
import api from '@/api'
const conversationStore = useConversationStore()
const copilotStore = useCopilotStore()
const aiAssistantStore = useAIAssistantStore()
const userStore = useUserStore()
const emitter = useEmitter()
const { t } = useI18n()
const canSendReply = computed(() => userStore.can(perms.MESSAGES_WRITE))
const canSendPrivateNote = computed(() => userStore.can(perms.MESSAGES_WRITE_PRIVATE))
const presets = computed(() => [
t('copilot.preset.summarize'),
@@ -338,10 +343,12 @@ const copyAnswer = async (content) => {
}
const insertIntoReply = (content) => {
if (!canSendReply.value) return
emitter.emit(EMITTER_EVENTS.COPILOT_INSERT_REPLY, content)
}
const addAsPrivateNote = async (content) => {
if (!canSendPrivateNote.value) return
const uuid = conversationStore.current?.uuid || ''
if (!uuid) return
const rev = revision(uuid)
@@ -1,6 +1,6 @@
<template>
<Dialog :open="openDialog" @update:open="openDialog = false">
<DialogContent class="min-w-[40%] min-h-[30%]">
<DialogContent class="w-[min(92vw,960px)] max-w-4xl">
<DialogHeader class="space-y-1">
<DialogTitle
>{{ view?.id ? $t('globals.messages.edit') : $t('globals.messages.create') }}
@@ -11,7 +11,7 @@
</DialogDescription>
</DialogHeader>
<form @submit.prevent="onSubmit">
<div class="grid gap-4 py-4">
<div class="grid gap-5 py-4">
<FormField v-slot="{ componentField }" name="name" :validate-on-blur="false">
<FormItem>
<FormLabel>{{ $t('globals.terms.name') }}</FormLabel>
@@ -35,7 +35,7 @@
<FormControl>
<FilterGroupBuilder :fields="filterFields" v-bind="componentField" />
</FormControl>
<FormDescription> {{ $t('view.form.filters.description') }}</FormDescription>
<FormDescription>{{ $t('view.form.filters.description') }}</FormDescription>
<FormMessage />
</FormItem>
</FormField>
@@ -115,7 +115,8 @@ const filterFields = computed(() =>
field,
type: value.type,
operators: value.operators,
options: value.options ?? []
options: value.options ?? [],
entity: value.entity
}))
)
const formSchema = toTypedSchema(
+22 -3
View File
@@ -12,7 +12,7 @@ import { playNotificationSound } from '@shared-ui/composables/useNotificationSou
import MessageCache from '@main/utils/conversation-message-cache'
import { getI18n } from '@main/i18n'
import { CONVERSATION_LIST_TYPE, CONVERSATION_DEFAULT_STATUSES, TAG_ACTION } from '@/constants/conversation'
import { useThrottleFn } from '@vueuse/core'
import { useThrottleFn, useStorage } from '@vueuse/core'
import { useUserStore } from '@/stores/user'
import { useNotificationStore } from '@/stores/notification'
import { delayedLoading } from '@/utils/delayed-loading'
@@ -191,6 +191,12 @@ export const useConversationStore = defineStore('conversation', () => {
priority_first: 'conversation.sort.priorityFirst'
}
const persistedListStatus = useStorage('conversationListStatus', CONVERSATION_DEFAULT_STATUSES.OPEN)
const persistedListSortField = useStorage('conversationListSortField', 'newest')
if (!sortFieldMap[persistedListSortField.value]) {
persistedListSortField.value = 'newest'
}
let typingTimeout = null
const typingByUUID = reactive({})
const typingTimeoutsByUUID = new Map()
@@ -198,8 +204,8 @@ export const useConversationStore = defineStore('conversation', () => {
const conversations = reactive({
data: [],
listType: null,
status: 'Open',
sortField: 'newest',
status: persistedListStatus.value,
sortField: persistedListSortField.value,
listFilters: [],
viewID: 0,
teamID: 0,
@@ -251,6 +257,10 @@ export const useConversationStore = defineStore('conversation', () => {
function setListStatus (status, fetch = true) {
conversations.status = status
// Views clear the status, so only a real selection updates the remembered one.
if (status) {
persistedListStatus.value = status
}
if (fetch) {
resetConversations()
reFetchConversationsList()
@@ -264,6 +274,7 @@ export const useConversationStore = defineStore('conversation', () => {
function setListSortField (field) {
if (conversations.sortField === field) return
conversations.sortField = field
persistedListSortField.value = field
resetConversations()
reFetchConversationsList()
}
@@ -283,6 +294,10 @@ export const useConversationStore = defineStore('conversation', () => {
...status,
id: status.id.toString()
}))
// A remembered status can point at one that has since been deleted.
if (conversations.status && !statuses.value.some(s => s.name === conversations.status)) {
setListStatus(CONVERSATION_DEFAULT_STATUSES.OPEN)
}
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
@@ -816,8 +831,12 @@ export const useConversationStore = defineStore('conversation', () => {
async function updateAssignee (type, v) {
try {
const teamChanged =
type === 'team' && Number(conversation.data.assigned_team_id) !== Number(v.assignee_id)
await api.updateAssignee(conversation.data.uuid, type, v)
conversation.data[`assigned_${type}_id`] = v.assignee_id
// Backend clears the user assignee when the team changes.
if (teamChanged) conversation.data.assigned_user_id = null
fetchSidebarCounts({ force: true })
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
+61 -35
View File
@@ -1,17 +1,22 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { handleHTTPError } from '@shared-ui/utils/http.js'
import { useEmitter } from '../composables/useEmitter'
import { EMITTER_EVENTS } from '../constants/emitterEvents'
import { useUserStore } from './user'
import api from '../api'
import { permissions as perms } from '../constants/permissions.js'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents'
import { useUserStore } from '@/stores/user'
import api from '@/api'
import { permissions as perms } from '@/constants/permissions.js'
export const useMacroStore = defineStore('macroStore', () => {
const macroList = ref([])
const searchResults = ref([])
const searchLoading = ref(false)
// Per-macro message content, fetched on demand (the search response carries none).
const macroContents = ref({})
let contentFetches = {}
const emitter = useEmitter()
const userStore = useUserStore()
const currentView = ref('')
let searchSeq = 0
// actionPermissions is a map of action names to their corresponding permissions that a user must have to perform the action.
const actionPermissions = {
@@ -19,7 +24,7 @@ export const useMacroStore = defineStore('macroStore', () => {
assign_user: perms.CONVERSATIONS_UPDATE_USER_ASSIGNEE,
set_status: perms.CONVERSATIONS_UPDATE_STATUS,
set_priority: perms.CONVERSATIONS_UPDATE_PRIORITY,
send_private_note: perms.MESSAGES_WRITE,
send_private_note: perms.MESSAGES_WRITE_PRIVATE,
send_reply: perms.MESSAGES_WRITE,
add_tags: perms.CONVERSATIONS_UPDATE_TAGS,
set_tags: perms.CONVERSATIONS_UPDATE_TAGS,
@@ -27,32 +32,17 @@ export const useMacroStore = defineStore('macroStore', () => {
}
const macroOptions = computed(() => {
// Filter macros based on visibility set.
const userTeams = userStore.teams.map(team => String(team.id))
let filtered = macroList.value.filter(macro =>
macro.visibility === 'all' ||
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 => {
let filtered = searchResults.value.map(macro => ({
...macro,
actions: macro.actions.filter(action => {
const permission = actionPermissions[action.type]
if (!permission) return true
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 === ""))
// Skip macros that do not have any actions left AND no message content.
filtered = filtered.filter(macro => !(macro.actions.length === 0 && !macro.has_message_content))
return filtered.map(macro => ({
...macro,
@@ -61,28 +51,64 @@ export const useMacroStore = defineStore('macroStore', () => {
}))
})
const loadMacros = async (force = false) => {
if (!force && macroList.value.length) return
const searchMacros = async (query = '') => {
const seq = ++searchSeq
searchLoading.value = true
try {
const response = await api.getAllMacros()
macroList.value = response?.data?.data || []
const params = { view: currentView.value }
if (query.trim()) params.q = query.trim()
const response = await api.searchMacros(params)
if (seq !== searchSeq) return
searchResults.value = response?.data?.data || []
} catch (error) {
if (seq !== searchSeq) return
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
} finally {
if (seq === searchSeq) searchLoading.value = false
}
}
const fetchMacroContent = (id) => {
if (id in macroContents.value) return Promise.resolve(macroContents.value[id])
const fetches = contentFetches
if (!fetches[id]) {
fetches[id] = api.getMacro(id)
.then(response => {
const content = response?.data?.data?.message_content || ''
if (fetches === contentFetches) macroContents.value[id] = content
return content
})
.finally(() => {
delete fetches[id]
})
}
return fetches[id]
}
const clearCache = () => {
searchSeq++
searchLoading.value = false
searchResults.value = []
macroContents.value = {}
contentFetches = {}
}
const setCurrentView = (view) => {
currentView.value = view
}
return {
macroList,
searchResults,
searchLoading,
macroOptions,
macroContents,
currentView,
loadMacros,
searchMacros,
fetchMacroContent,
clearCache,
setCurrentView
}
})
})
+202
View File
@@ -0,0 +1,202 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
vi.mock('@main/api', () => ({
default: {
searchMacros: vi.fn(),
getMacro: vi.fn()
}
}))
vi.mock('@main/composables/useEmitter', () => ({
useEmitter: () => ({ emit: vi.fn(), on: vi.fn(), off: vi.fn() })
}))
const userMock = vi.hoisted(() => ({ allowed: null }))
vi.mock('@main/stores/user', () => ({
useUserStore: () => ({
teams: [],
userID: 1,
can: (permission) => userMock.allowed === null || userMock.allowed.includes(permission)
})
}))
import api from '@main/api'
import { useMacroStore } from '@main/stores/macro'
const compactMacro = (overrides = {}) => ({
id: 1,
name: 'Macro',
actions: [],
visibility: 'all',
visible_when: ['replying'],
has_message_content: true,
user_id: null,
team_id: null,
usage_count: 0,
...overrides
})
describe('macro store', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
userMock.allowed = null
})
it('searches with the query and the current view', async () => {
api.searchMacros.mockResolvedValue({ data: { data: [compactMacro()] } })
const store = useMacroStore()
store.setCurrentView('replying')
await store.searchMacros('refund')
expect(api.searchMacros).toHaveBeenCalledWith({ q: 'refund', view: 'replying' })
expect(store.searchResults).toHaveLength(1)
})
it('loads macros without sending an empty query', async () => {
api.searchMacros.mockResolvedValue({ data: { data: [compactMacro()] } })
const store = useMacroStore()
store.setCurrentView('replying')
await store.searchMacros(' ')
expect(api.searchMacros).toHaveBeenCalledWith({ view: 'replying' })
expect(store.searchResults).toHaveLength(1)
})
it('keeps only the newest search response', async () => {
let resolveFirst
api.searchMacros
.mockReturnValueOnce(new Promise((r) => (resolveFirst = r)))
.mockResolvedValueOnce({ data: { data: [compactMacro({ id: 2, name: 'newer' })] } })
const store = useMacroStore()
const first = store.searchMacros('a')
const second = store.searchMacros('ab')
resolveFirst({ data: { data: [compactMacro({ id: 1, name: 'stale' })] } })
await Promise.all([first, second])
expect(store.searchResults.map((m) => m.name)).toEqual(['newer'])
expect(store.searchLoading).toBe(false)
})
it('does not restore search results after clearing the cache', async () => {
let resolveSearch
api.searchMacros.mockReturnValue(new Promise((resolve) => { resolveSearch = resolve }))
const store = useMacroStore()
const search = store.searchMacros('old')
store.clearCache()
resolveSearch({ data: { data: [compactMacro()] } })
await search
expect(store.searchResults).toEqual([])
})
it('drops macros with no actions and no message content from the options', async () => {
api.searchMacros.mockResolvedValue({
data: {
data: [
compactMacro({ id: 1, name: 'content only', has_message_content: true }),
compactMacro({ id: 2, name: 'actions only', has_message_content: false, actions: [{ type: 'add_tags', value: ['x'] }] }),
compactMacro({ id: 3, name: 'empty', has_message_content: false })
]
}
})
const store = useMacroStore()
await store.searchMacros()
expect(store.macroOptions.map((m) => m.id)).toEqual([1, 2])
})
it('fetches macro content once and serves it from the cache after', async () => {
api.getMacro.mockResolvedValue({ data: { data: { id: 7, message_content: '<p>hi</p>' } } })
const store = useMacroStore()
const first = await store.fetchMacroContent(7)
const second = await store.fetchMacroContent(7)
expect(first).toBe('<p>hi</p>')
expect(second).toBe('<p>hi</p>')
expect(api.getMacro).toHaveBeenCalledTimes(1)
expect(store.macroContents[7]).toBe('<p>hi</p>')
})
it('dedupes concurrent fetches for the same macro', async () => {
let resolve
api.getMacro.mockReturnValue(new Promise((r) => (resolve = r)))
const store = useMacroStore()
const a = store.fetchMacroContent(7)
const b = store.fetchMacroContent(7)
resolve({ data: { data: { id: 7, message_content: '<p>hi</p>' } } })
expect(await a).toBe('<p>hi</p>')
expect(await b).toBe('<p>hi</p>')
expect(api.getMacro).toHaveBeenCalledTimes(1)
})
it('rejects and caches nothing when the fetch fails', async () => {
api.getMacro.mockRejectedValue(new Error('boom'))
const store = useMacroStore()
await expect(store.fetchMacroContent(7)).rejects.toThrow('boom')
expect(store.macroContents[7]).toBeUndefined()
api.getMacro.mockResolvedValue({ data: { data: { id: 7, message_content: '<p>hi</p>' } } })
expect(await store.fetchMacroContent(7)).toBe('<p>hi</p>')
expect(api.getMacro).toHaveBeenCalledTimes(2)
})
it('does not restore macro content after clearing the cache', async () => {
let resolveContent
api.getMacro.mockReturnValue(new Promise((resolve) => { resolveContent = resolve }))
const store = useMacroStore()
const fetch = store.fetchMacroContent(7)
store.clearCache()
resolveContent({ data: { data: { message_content: '<p>old</p>' } } })
await fetch
expect(store.macroContents[7]).toBeUndefined()
})
it('restores filtered actions when permissions change', () => {
userMock.allowed = ['messages:write']
const store = useMacroStore()
store.searchResults = [
compactMacro({
name: 'Reply and note',
has_message_content: false,
actions: [{ type: 'send_reply' }, { type: 'send_private_note' }]
})
]
expect(store.macroOptions[0].actions).toEqual([{ type: 'send_reply' }])
expect(store.searchResults[0].actions).toHaveLength(2)
userMock.allowed = ['messages:write', 'messages:write_private']
store.searchResults = [...store.searchResults]
expect(store.macroOptions[0].actions).toEqual([
{ type: 'send_reply' },
{ type: 'send_private_note' }
])
})
it('clears results and cached content on clearCache', async () => {
api.searchMacros.mockResolvedValue({ data: { data: [compactMacro()] } })
api.getMacro.mockResolvedValue({ data: { data: { id: 7, message_content: '<p>hi</p>' } } })
const store = useMacroStore()
await store.searchMacros()
await store.fetchMacroContent(7)
store.clearCache()
expect(store.searchResults).toEqual([])
expect(store.macroContents[7]).toBeUndefined()
})
})
+30 -20
View File
@@ -1,36 +1,46 @@
import { ref, computed } from 'vue'
import { computed } from 'vue'
import { defineStore } from 'pinia'
import { handleHTTPError } from '@shared-ui/utils/http.js'
import { useEmitter } from '../composables/useEmitter'
import { EMITTER_EVENTS } from '../constants/emitterEvents'
import api from '../api'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents'
import { createRemoteLookup } from '@/utils/remote-lookup'
import api from '@/api'
export const useTagStore = defineStore('tags', () => {
const tags = ref([])
const emitter = useEmitter()
const showFetchError = (error) => {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
const lookup = createRemoteLookup({
fetchRows: (params) => api.getTags(params),
onError: showFetchError
})
const tags = lookup.rows
const tagNames = computed(() => tags.value.map(tag => tag.name))
const tagOptions = computed(() => tags.value.map(tag => ({
label: tag.name,
value: String(tag.id),
})))
const fetchTags = async () => {
if (tags.value.length) return
try {
const response = await api.getTags()
tags.value = response?.data?.data || []
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
const searchTagNames = async (query) => (await lookup.search(query)).map(tag => ({
label: tag.name,
value: tag.name,
}))
const searchTagOptions = async (query) => (await lookup.search(query)).map(tag => ({
label: tag.name,
value: String(tag.id),
}))
return {
tags,
tagOptions,
tagNames,
fetchTags,
fetchTags: lookup.fetchFirstPage,
searchTagNames,
searchTagOptions,
ensureTagIDs: lookup.ensureIDs,
}
})
})
+26 -21
View File
@@ -1,33 +1,38 @@
import { ref, computed } from 'vue'
import { computed } from 'vue'
import { defineStore } from 'pinia'
import { handleHTTPError } from '@shared-ui/utils/http.js'
import { useEmitter } from '../composables/useEmitter'
import { EMITTER_EVENTS } from '../constants/emitterEvents'
import api from '../api'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents'
import { createRemoteLookup } from '@/utils/remote-lookup'
import api from '@/api'
export const useTeamStore = defineStore('team', () => {
const teams = ref([])
const emitter = useEmitter()
const options = computed(() => teams.value.map(team => ({
const showFetchError = (error) => {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
const lookup = createRemoteLookup({
fetchRows: (params) => api.getTeamsCompact(params),
onError: showFetchError
})
const toOptions = (teams) => teams.map(team => ({
label: team.name,
value: String(team.id),
emoji: team.emoji,
})))
const fetchTeams = async () => {
if (teams.value.length) return
try {
const response = await api.getTeamsCompact()
teams.value = response?.data?.data || []
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
}))
const teams = lookup.rows
const options = computed(() => toOptions(teams.value))
const searchTeams = async (query) => toOptions(await lookup.search(query))
return {
teams,
options,
fetchTeams,
fetchTeams: lookup.fetchFirstPage,
searchTeams,
ensureTeamIDs: lookup.ensureIDs,
}
})
})
+28 -23
View File
@@ -1,41 +1,46 @@
import { ref, computed } from 'vue'
import { computed } from 'vue'
import { defineStore } from 'pinia'
import { handleHTTPError } from '@shared-ui/utils/http.js'
import { useEmitter } from '../composables/useEmitter'
import { EMITTER_EVENTS } from '../constants/emitterEvents'
import api from '../api'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents'
import { createRemoteLookup } from '@/utils/remote-lookup'
import api from '@/api'
// TODO: rename this store to agents
export const useUsersStore = defineStore('users', () => {
const users = ref([])
const emitter = useEmitter()
const options = computed(() => users.value.map(user => ({
const showFetchError = (error) => {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
const lookup = createRemoteLookup({
fetchRows: (params) => api.getUsersCompact(params),
onError: showFetchError
})
const toOptions = (users) => users.map(user => ({
label: user.first_name + ' ' + user.last_name,
value: String(user.id),
type: user.type,
avatar_url: user.avatar_url,
availability_status: user.availability_status,
})))
const fetchUsers = async (force = false) => {
if (!force && users.value.length) return
try {
const response = await api.getUsersCompact()
users.value = response?.data?.data || []
} catch (error) {
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
variant: 'destructive',
description: handleHTTPError(error).message
})
}
}
}))
const users = lookup.rows
const options = computed(() => toOptions(users.value))
const searchUsers = async (query) => toOptions(await lookup.search(query))
const setAvailability = (agentID, status) => {
const u = users.value.find(x => x.id === agentID)
if (u) u.availability_status = status
lookup.patch(agentID, { availability_status: status })
}
return {
users,
options,
fetchUsers,
fetchUsers: lookup.fetchFirstPage,
searchUsers,
ensureUserIDs: lookup.ensureIDs,
setAvailability,
}
})
})
@@ -0,0 +1,82 @@
import { ref, computed } from 'vue'
const PAGE_SIZE = 50
// Backs a store whose list lives on the server, ids outside the current page are pinned so their labels still render.
export function createRemoteLookup ({ fetchRows, onError }) {
const byID = ref({})
const firstPageIDs = ref([])
const pinnedIDs = ref([])
let firstPage = null
const rows = computed(() => {
const seen = new Set()
const out = []
for (const id of [...firstPageIDs.value, ...pinnedIDs.value]) {
if (seen.has(id)) continue
seen.add(id)
const row = byID.value[id]
if (row) out.push(row)
}
return out
})
const merge = (fetched) => {
for (const row of fetched) byID.value[row.id] = row
return fetched
}
const load = async (params) => {
const response = await fetchRows({ page_size: PAGE_SIZE, ...params })
return merge(response?.data?.data || [])
}
const fetchFirstPage = () => {
if (firstPage) return firstPage
firstPage = load({ page: 1 })
.then((fetched) => {
firstPageIDs.value = fetched.map((row) => row.id)
return fetched
})
.catch((error) => {
firstPage = null
onError(error)
})
return firstPage
}
const search = async (query) => {
const term = (query || '').trim()
if (!term) {
await fetchFirstPage()
return firstPageIDs.value.map((id) => byID.value[id]).filter(Boolean)
}
try {
return await load({ q: term, page: 1 })
} catch (error) {
onError(error)
return []
}
}
const ensureIDs = async (ids) => {
const wanted = [...new Set((ids || []).map(Number).filter((id) => Number.isInteger(id) && id > 0))]
if (!wanted.length) return
pinnedIDs.value = [...new Set([...pinnedIDs.value, ...wanted])]
if (wanted.some((id) => !byID.value[id])) await fetchFirstPage()
const missing = wanted.filter((id) => !byID.value[id])
if (!missing.length) return
try {
await load({ ids: missing.join(',') })
} catch (error) {
onError(error)
}
}
const patch = (id, changes) => {
const row = byID.value[id]
if (row) byID.value[id] = { ...row, ...changes }
}
return { rows, fetchFirstPage, search, ensureIDs, patch }
}
@@ -0,0 +1,141 @@
import { describe, it, expect, vi } from 'vitest'
import { createRemoteLookup } from './remote-lookup'
const rows = (values) => ({ data: { data: values } })
describe('createRemoteLookup', () => {
it('seeds the list from the first page and only fetches it once', async () => {
const fetchRows = vi.fn().mockResolvedValue(rows([{ id: 1, name: 'a' }]))
const lookup = createRemoteLookup({ fetchRows, onError: vi.fn() })
await Promise.all([lookup.fetchFirstPage(), lookup.fetchFirstPage()])
expect(fetchRows).toHaveBeenCalledTimes(1)
expect(lookup.rows.value).toEqual([{ id: 1, name: 'a' }])
})
it('returns server search results without replacing the shared list', async () => {
const fetchRows = vi.fn()
.mockResolvedValueOnce(rows([{ id: 1, name: 'a' }]))
.mockResolvedValueOnce(rows([{ id: 9, name: 'zebra' }]))
const lookup = createRemoteLookup({ fetchRows, onError: vi.fn() })
await lookup.fetchFirstPage()
const found = await lookup.search('zeb')
expect(fetchRows).toHaveBeenLastCalledWith({ page_size: 50, q: 'zeb', page: 1 })
expect(found).toEqual([{ id: 9, name: 'zebra' }])
expect(lookup.rows.value).toEqual([{ id: 1, name: 'a' }])
})
it('keeps a late first page separate from search results', async () => {
let resolveFirstPage
const fetchRows = vi.fn()
.mockImplementationOnce(() => new Promise((resolve) => { resolveFirstPage = resolve }))
.mockResolvedValueOnce(rows([{ id: 2, name: 'search result' }]))
const lookup = createRemoteLookup({ fetchRows, onError: vi.fn() })
const firstPage = lookup.fetchFirstPage()
const found = await lookup.search('search')
resolveFirstPage(rows([{ id: 1, name: 'first page' }]))
await firstPage
expect(found).toEqual([{ id: 2, name: 'search result' }])
expect(lookup.rows.value).toEqual([{ id: 1, name: 'first page' }])
})
it('returns independent search results without replacing the shared list', async () => {
let resolveFirstSearch
const fetchRows = vi.fn()
.mockResolvedValueOnce(rows([{ id: 1, name: 'first page' }]))
.mockImplementationOnce(() => new Promise((resolve) => { resolveFirstSearch = resolve }))
.mockResolvedValueOnce(rows([{ id: 3, name: 'second search' }]))
const lookup = createRemoteLookup({ fetchRows, onError: vi.fn() })
await lookup.fetchFirstPage()
const firstSearch = lookup.search('first')
const secondSearch = await lookup.search('second')
resolveFirstSearch(rows([{ id: 2, name: 'first search' }]))
expect(await firstSearch).toEqual([{ id: 2, name: 'first search' }])
expect(secondSearch).toEqual([{ id: 3, name: 'second search' }])
expect(lookup.rows.value).toEqual([{ id: 1, name: 'first page' }])
})
it('keeps pinned ids in the shared list while returning search results separately', async () => {
const fetchRows = vi.fn()
.mockResolvedValueOnce(rows([{ id: 7, name: 'pinned' }]))
.mockResolvedValueOnce(rows([{ id: 1, name: 'a' }]))
const lookup = createRemoteLookup({ fetchRows, onError: vi.fn() })
await lookup.ensureIDs([7])
const found = await lookup.search('a')
expect(found).toEqual([{ id: 1, name: 'a' }])
expect(lookup.rows.value).toEqual([{ id: 7, name: 'pinned' }])
})
it('resolves ids from the first page instead of fetching them separately', async () => {
const fetchRows = vi.fn().mockResolvedValue(rows([{ id: 3, name: 'c' }]))
const lookup = createRemoteLookup({ fetchRows, onError: vi.fn() })
await lookup.ensureIDs([3])
await lookup.ensureIDs([3])
expect(fetchRows).toHaveBeenCalledTimes(1)
expect(fetchRows).toHaveBeenCalledWith({ page_size: 50, page: 1 })
})
it('fetches only the ids the first page does not hold', async () => {
const fetchRows = vi.fn()
.mockResolvedValueOnce(rows([{ id: 1, name: 'a' }]))
.mockResolvedValueOnce(rows([{ id: 9, name: 'z' }]))
const lookup = createRemoteLookup({ fetchRows, onError: vi.fn() })
await lookup.ensureIDs([1, 9])
expect(fetchRows).toHaveBeenCalledTimes(2)
expect(fetchRows).toHaveBeenLastCalledWith({ page_size: 50, ids: '9' })
expect(lookup.rows.value).toEqual([{ id: 1, name: 'a' }, { id: 9, name: 'z' }])
})
it('returns the cached first page for an empty search', async () => {
const fetchRows = vi.fn()
.mockResolvedValueOnce(rows([{ id: 1, name: 'a' }]))
.mockResolvedValueOnce(rows([{ id: 9, name: 'zebra' }]))
const lookup = createRemoteLookup({ fetchRows, onError: vi.fn() })
await lookup.fetchFirstPage()
await lookup.search('zeb')
const found = await lookup.search('')
expect(fetchRows).toHaveBeenCalledTimes(2)
expect(found).toEqual([{ id: 1, name: 'a' }])
expect(lookup.rows.value).toEqual([{ id: 1, name: 'a' }])
})
it('reports a failed first page and allows a retry', async () => {
const onError = vi.fn()
const fetchRows = vi.fn()
.mockRejectedValueOnce(new Error('boom'))
.mockResolvedValueOnce(rows([{ id: 1, name: 'a' }]))
const lookup = createRemoteLookup({ fetchRows, onError })
await lookup.fetchFirstPage()
expect(onError).toHaveBeenCalledTimes(1)
expect(lookup.rows.value).toEqual([])
await lookup.fetchFirstPage()
expect(lookup.rows.value).toEqual([{ id: 1, name: 'a' }])
})
it('patches a cached row in place', async () => {
const fetchRows = vi.fn().mockResolvedValue(rows([{ id: 1, availability_status: 'offline' }]))
const lookup = createRemoteLookup({ fetchRows, onError: vi.fn() })
await lookup.fetchFirstPage()
lookup.patch(1, { availability_status: 'online' })
expect(lookup.rows.value).toEqual([{ id: 1, availability_status: 'online' }])
})
})
@@ -50,9 +50,7 @@
<FormControl>
<Select v-bind="componentField" @update:modelValue="handleInput">
<SelectTrigger>
<SelectValue
:placeholder="t('placeholders.selectType')"
/>
<SelectValue :placeholder="t('placeholders.selectType')" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
@@ -147,7 +145,9 @@
@add-action="handleAddAction"
@remove-action="handleRemoveAction"
/>
<Button type="submit" :isLoading="isLoading">{{ isNewForm ? $t('globals.messages.create') : $t('globals.messages.save') }}</Button>
<Button type="submit" :isLoading="isLoading">{{
isNewForm ? $t('globals.messages.create') : $t('globals.messages.save')
}}</Button>
</div>
</form>
</div>
@@ -247,6 +247,7 @@ const breadcrumbPageLabel = () => {
return t('automation.newRule')
}
const isNewForm = computed(() => {
return props.id ? false : true
})
@@ -13,6 +13,14 @@
</template>
<template #help>
<p>{{ $t('admin.helpCenter.help') }}</p>
<a
href="https://docs.libredesk.io/configuration/help-center"
target="_blank"
rel="noopener noreferrer"
class="link-style"
>
{{ $t('globals.terms.learnMore') }}
</a>
</template>
</AdminSplitLayout>
@@ -114,6 +114,14 @@
<template #help>
<p>{{ t('admin.helpCenter.treeHelp') }}</p>
<a
href="https://docs.libredesk.io/configuration/help-center"
target="_blank"
rel="noopener noreferrer"
class="link-style"
>
{{ t('globals.terms.learnMore') }}
</a>
</template>
</AdminSplitLayout>
@@ -39,7 +39,7 @@ const createMacro = async (values) => {
formLoading.value = true
await api.createMacro(values)
await macroStore.loadMacros(true)
macroStore.clearCache()
emit.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.savedSuccessfully')
@@ -39,8 +39,7 @@ const updateMacro = async (payload) => {
formLoading.value = true
await api.updateMacro(macro.value.id, payload)
// Reload macros from server
await macroStore.loadMacros(true)
macroStore.clearCache()
emitter.emit(EMITTER_EVENTS.SHOW_TOAST, {
description: t('globals.messages.savedSuccessfully')
@@ -18,14 +18,14 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import DataTable from '@main/components/datatable/DataTable.vue'
import { createColumns } from '../../../features/admin/macros/dataTableColumns.js'
import { createColumns } from '@main/features/admin/macros/dataTableColumns.js'
import LoadingOverlay from '@main/components/layout/LoadingOverlay.vue'
import { useEmitter } from '../../../composables/useEmitter'
import { EMITTER_EVENTS } from '../../../constants/emitterEvents.js'
import { useEmitter } from '@main/composables/useEmitter'
import { EMITTER_EVENTS } from '@main/constants/emitterEvents.js'
import { handleHTTPError } from '@shared-ui/utils/http.js'
import { Button } from '@shared-ui/components/ui/button'
import { useI18n } from 'vue-i18n'
import api from '../../../api'
import api from '@main/api'
const { t } = useI18n()
const formLoading = ref(false)
@@ -48,7 +48,7 @@ const refreshList = (data) => {
const getMacros = async () => {
try {
formLoading.value = true
const resp = await api.getAllMacros()
const resp = await api.getMacrosCompact()
macros.value = resp.data.data
} catch (error) {
emit.emit(EMITTER_EVENTS.SHOW_TOAST, {
@@ -13,7 +13,7 @@
@layout="onLayoutChange"
>
<!-- Conversation Content Panel -->
<ResizablePanel :default-size="sidebarOpen ? panelSizes[0] : 100" :min-size="40">
<ResizablePanel :default-size="sidebarOpen ? panelSizes[0] : 100" :min-size="30">
<Conversation />
</ResizablePanel>
@@ -25,7 +25,7 @@
ref="sidebarPanelRef"
:default-size="panelSizes[1]"
:min-size="15"
:max-size="40"
:max-size="60"
:collapsible="true"
:collapsed-size="0"
@collapse="onSidebarCollapse"
+217
View File
@@ -0,0 +1,217 @@
// Pins the q and ids query params on all three lookup endpoints.
const stamp = Date.now()
const rowsOf = (body) => body.data.results || body.data
describe('API: lookup search and resolve by id', () => {
const tagAlpha = `zz-lookup-alpha-${stamp}`
const tagBeta = `zz-lookup-beta-${stamp}`
const teamAlpha = `zz-lookup-team-alpha-${stamp}`
const teamBeta = `zz-lookup-team-beta-${stamp}`
const agentEmail = `zz-lookup-agent-${stamp}@example.com`
const agentFirst = `Zzlookup${stamp}`
const created = { tags: [], teams: [], agents: [] }
before(() => {
cy.login()
for (const name of [tagAlpha, tagBeta]) {
cy.api('POST', '/api/v1/tags', { name }).then(({ body }) => created.tags.push(body.data.id))
}
for (const name of [teamAlpha, teamBeta]) {
cy.api('POST', '/api/v1/teams', {
name,
emoji: '🔎',
conversation_assignment_type: 'Round robin',
timezone: 'UTC',
max_auto_assigned_conversations: 0
}).then(({ body }) => created.teams.push(body.data.id))
}
cy.api('POST', '/api/v1/agents', {
first_name: agentFirst,
last_name: 'Searchable',
email: agentEmail,
roles: ['Agent'],
send_welcome_email: false
}).then(({ body }) => created.agents.push(body.data.id))
})
beforeEach(() => cy.login())
describe('tags', () => {
it('returns every tag when no page params are sent', () => {
cy.api('GET', '/api/v1/tags').then(({ status, body }) => {
expect(status).to.eq(200)
const rows = rowsOf(body)
expect(rows).to.be.an('array')
for (const id of created.tags) {
expect(rows.find((t) => t.id === id), `tag ${id}`).to.exist
}
})
})
it('narrows the list with q', () => {
cy.api('GET', `/api/v1/tags?q=zz-lookup-alpha-${stamp}`).then(({ status, body }) => {
expect(status).to.eq(200)
const rows = rowsOf(body)
expect(rows).to.have.length(1)
expect(rows[0].name).to.eq(tagAlpha)
})
})
it('matches q case insensitively and mid-name', () => {
cy.api('GET', `/api/v1/tags?q=LOOKUP-BETA-${stamp}`).then(({ body }) => {
const rows = rowsOf(body)
expect(rows.map((t) => t.name)).to.include(tagBeta)
})
})
it('returns nothing for a q that matches nothing', () => {
cy.api('GET', '/api/v1/tags?q=definitely-no-such-tag-anywhere').then(({ body }) => {
expect(rowsOf(body)).to.have.length(0)
})
})
it('honours page and page_size alongside q', () => {
cy.api('GET', `/api/v1/tags?q=zz-lookup-&page=1&page_size=1`).then(({ body }) => {
expect(rowsOf(body)).to.have.length(1)
})
})
it('resolves exactly the requested ids', () => {
cy.api('GET', `/api/v1/tags?ids=${created.tags.join(',')}`).then(({ status, body }) => {
expect(status).to.eq(200)
const rows = rowsOf(body)
expect(rows).to.have.length(created.tags.length)
expect(rows.map((t) => t.id).sort()).to.deep.eq([...created.tags].sort())
})
})
it('ignores ids that do not exist rather than erroring', () => {
cy.api('GET', `/api/v1/tags?ids=${created.tags[0]},99999999`).then(({ status, body }) => {
expect(status).to.eq(200)
expect(rowsOf(body)).to.have.length(1)
})
})
it('falls back to the list when every id is junk', () => {
cy.api('GET', '/api/v1/tags?ids=abc,-1,0&page_size=3').then(({ status, body }) => {
expect(status).to.eq(200)
expect(rowsOf(body).length).to.be.at.most(3)
expect(rowsOf(body).length).to.be.at.least(1)
})
})
it('lets ids win over q so a selected value always resolves', () => {
cy.api('GET', `/api/v1/tags?ids=${created.tags[0]}&q=definitely-no-such-tag-anywhere`).then(
({ body }) => {
expect(rowsOf(body)).to.have.length(1)
expect(rowsOf(body)[0].id).to.eq(created.tags[0])
}
)
})
})
describe('teams', () => {
it('returns every team when no page params are sent', () => {
cy.api('GET', '/api/v1/teams/compact').then(({ status, body }) => {
expect(status).to.eq(200)
for (const id of created.teams) {
expect(rowsOf(body).find((t) => t.id === id), `team ${id}`).to.exist
}
})
})
it('keeps the compact shape of id, name and emoji', () => {
cy.api('GET', `/api/v1/teams/compact?ids=${created.teams[0]}`).then(({ body }) => {
const row = rowsOf(body)[0]
expect(Object.keys(row).sort()).to.deep.eq(['emoji', 'id', 'name'])
})
})
it('narrows the list with q', () => {
cy.api('GET', `/api/v1/teams/compact?q=zz-lookup-team-alpha-${stamp}`).then(({ body }) => {
const rows = rowsOf(body)
expect(rows).to.have.length(1)
expect(rows[0].name).to.eq(teamAlpha)
})
})
it('returns nothing for a q that matches nothing', () => {
cy.api('GET', '/api/v1/teams/compact?q=definitely-no-such-team-anywhere').then(({ body }) => {
expect(rowsOf(body)).to.have.length(0)
})
})
it('resolves exactly the requested ids', () => {
cy.api('GET', `/api/v1/teams/compact?ids=${created.teams.join(',')}`).then(({ body }) => {
const rows = rowsOf(body)
expect(rows.map((t) => t.id).sort()).to.deep.eq([...created.teams].sort())
})
})
})
describe('agents', () => {
it('returns every agent when no page params are sent', () => {
cy.api('GET', '/api/v1/agents/compact').then(({ status, body }) => {
expect(status).to.eq(200)
expect(rowsOf(body).find((a) => a.id === created.agents[0]), 'created agent').to.exist
})
})
it('finds an agent by first name, by last name and by email', () => {
for (const q of [agentFirst, 'Searchable', agentEmail]) {
cy.api('GET', `/api/v1/agents/compact?q=${encodeURIComponent(q)}`).then(({ body }) => {
expect(
rowsOf(body).find((a) => a.id === created.agents[0]),
`agent matched by ${q}`
).to.exist
})
}
})
it('finds an agent by a full name spanning first and last', () => {
cy.api('GET', `/api/v1/agents/compact?q=${encodeURIComponent(`${agentFirst} Search`)}`).then(
({ body }) => {
expect(rowsOf(body).find((a) => a.id === created.agents[0])).to.exist
}
)
})
it('returns nothing for a q that matches nothing', () => {
cy.api('GET', '/api/v1/agents/compact?q=definitely-no-such-agent-anywhere').then(({ body }) => {
expect(rowsOf(body)).to.have.length(0)
})
})
it('caps page_size at the server maximum', () => {
cy.api('GET', '/api/v1/agents/compact?page=1&page_size=100000').then(({ status, body }) => {
expect(status).to.eq(200)
expect(rowsOf(body).length).to.be.at.most(500)
})
})
it('resolves the requested id', () => {
cy.api('GET', `/api/v1/agents/compact?ids=${created.agents[0]}`).then(({ body }) => {
const rows = rowsOf(body)
expect(rows).to.have.length(1)
expect(rows[0].id).to.eq(created.agents[0])
})
})
})
after(() => {
cy.login()
for (const id of created.tags) {
cy.api('DELETE', `/api/v1/tags/${id}`, null, { failOnStatusCode: false })
}
for (const id of created.teams) {
cy.api('DELETE', `/api/v1/teams/${id}`, null, { failOnStatusCode: false })
}
for (const id of created.agents) {
cy.api('DELETE', `/api/v1/agents/${id}`, null, { failOnStatusCode: false })
}
})
})
@@ -0,0 +1,178 @@
const stamp = Date.now()
const password = 'StrongPass!123'
const prefix = `macro-vis-${stamp}`
const teamOneName = `${prefix}-team-one`
const teamTwoName = `${prefix}-team-two`
const agentOneEmail = `macro.vis.one.${stamp}@example.com`
const agentTwoEmail = `macro.vis.two.${stamp}@example.com`
const macroName = (suffix) => `${prefix} ${suffix}`
const viewPrefix = `macro-view-${stamp}`
const views = ['replying', 'starting_conversation', 'adding_private_note']
const viewMacroName = (suffix) => `${viewPrefix} ${suffix}`
const createTeam = (name) =>
cy.api('POST', '/api/v1/teams', {
name,
emoji: '🧪',
timezone: 'UTC',
conversation_assignment_type: 'Manual'
})
const createAgent = (firstName, email, teamName) =>
cy
.api('POST', '/api/v1/agents', {
first_name: firstName,
email,
roles: ['Agent'],
send_welcome_email: false,
teams: [teamName]
})
.then(({ body }) => {
const id = body.data.id
// Create leaves the agent without a password, so set one to log in as them.
return cy
.api('PUT', `/api/v1/agents/${id}`, {
first_name: firstName,
email,
roles: ['Agent'],
enabled: true,
availability_status: 'offline',
teams: [teamName],
new_password: password
})
.then(() => id)
})
const loginAs = (email) => {
cy.session(
email,
() => {
cy.visit('/')
cy.get('#email').clear().type(email)
cy.get('#password').clear().type(password, { log: false })
cy.contains('button', 'Sign in').click()
cy.url().should('include', '/inboxes')
},
{
validate () {
cy.request('/api/v1/agents/me').its('status').should('eq', 200)
}
}
)
}
const searchNames = () =>
cy
.api('GET', `/api/v1/macros/search?q=${encodeURIComponent(prefix)}`)
.then(({ status, body }) => {
expect(status).to.eq(200)
return body.data.map((m) => m.name).sort()
})
describe('API: macro visibility scoping', () => {
const macroIds = []
let agentOneId, agentTwoId, teamOneId, teamTwoId
before(() => {
cy.login()
createTeam(teamOneName).then(({ body }) => {
teamOneId = body.data.id
})
createTeam(teamTwoName).then(({ body }) => {
teamTwoId = body.data.id
})
createAgent('MacroVisOne', agentOneEmail, teamOneName).then((id) => {
agentOneId = id
})
createAgent('MacroVisTwo', agentTwoEmail, teamTwoName).then((id) => {
agentTwoId = id
})
cy.then(() => {
const macros = [
{ suffix: 'everyone', visibility: 'all' },
{ suffix: 'agent one', visibility: 'user', user_id: String(agentOneId) },
{ suffix: 'agent two', visibility: 'user', user_id: String(agentTwoId) },
{ suffix: 'team one', visibility: 'team', team_id: String(teamOneId) },
{ suffix: 'team two', visibility: 'team', team_id: String(teamTwoId) }
]
macros.forEach(({ suffix, ...rest }) => {
cy.api('POST', '/api/v1/macros', {
name: macroName(suffix),
message_content: `<p>${suffix}</p>`,
visible_when: ['replying'],
actions: [],
...rest
}).then(({ body }) => macroIds.push(body.data.id))
})
})
views.concat('every view').forEach((suffix) => {
cy.api('POST', '/api/v1/macros', {
name: viewMacroName(suffix),
message_content: `<p>${suffix}</p>`,
visibility: 'all',
visible_when: suffix === 'every view' ? views : [suffix],
actions: []
}).then(({ body }) => macroIds.push(body.data.id))
})
})
after(() => {
cy.login()
cy.then(() => {
macroIds.forEach((id) => cy.api('DELETE', `/api/v1/macros/${id}`))
if (agentOneId) cy.api('DELETE', `/api/v1/agents/${agentOneId}`)
if (agentTwoId) cy.api('DELETE', `/api/v1/agents/${agentTwoId}`)
if (teamOneId) cy.api('DELETE', `/api/v1/teams/${teamOneId}`)
if (teamTwoId) cy.api('DELETE', `/api/v1/teams/${teamTwoId}`)
})
})
it('gives the first agent the shared macro, their own macro and their team macro', () => {
loginAs(agentOneEmail)
searchNames().should('deep.eq', [
macroName('agent one'),
macroName('everyone'),
macroName('team one')
])
})
it('gives the second agent a different set from the same macros', () => {
loginAs(agentTwoEmail)
searchNames().should('deep.eq', [
macroName('agent two'),
macroName('everyone'),
macroName('team two')
])
})
it('hides every scoped macro from an agent who owns none of them', () => {
cy.login()
searchNames().should('deep.eq', [macroName('everyone')])
})
it('lists every macro when no view is given', () => {
cy.login()
cy.api('GET', `/api/v1/macros/search?q=${encodeURIComponent(viewPrefix)}`).then(({ body }) => {
expect(body.data.map((m) => m.name).sort()).to.deep.eq(
views.concat('every view').map(viewMacroName).sort()
)
})
})
views.forEach((view) => {
it(`lists only the macros marked visible while ${view}`, () => {
cy.login()
cy.api('GET', `/api/v1/macros/search?q=${encodeURIComponent(viewPrefix)}&view=${view}`).then(
({ body }) => {
expect(body.data.map((m) => m.name).sort()).to.deep.eq(
[viewMacroName('every view'), viewMacroName(view)].sort()
)
}
)
})
})
})
@@ -0,0 +1,119 @@
const stamp = Date.now()
const contentMacroName = `Palette content macro ${stamp}`
const actionMacroName = `Palette action macro ${stamp}`
const messageBody = `Reply body from the palette spec ${stamp}`
describe('Command palette macros', () => {
let contentMacroId
let actionMacroId
before(() => {
cy.login()
cy.api('POST', '/api/v1/macros', {
name: contentMacroName,
message_content: `<p>${messageBody}</p>`,
visibility: 'all',
visible_when: ['replying', 'starting_conversation', 'adding_private_note'],
actions: []
}).then((resp) => {
contentMacroId = resp.body.data.id
})
cy.api('POST', '/api/v1/macros', {
name: actionMacroName,
message_content: '',
visibility: 'all',
visible_when: ['replying'],
actions: [{ type: 'add_tags', value: ['palette-spec-tag'], display_value: [] }]
}).then((resp) => {
actionMacroId = resp.body.data.id
})
})
beforeEach(() => {
cy.viewport(1280, 800)
cy.login()
})
after(() => {
cy.login()
if (contentMacroId) cy.api('DELETE', `/api/v1/macros/${contentMacroId}`)
if (actionMacroId) cy.api('DELETE', `/api/v1/macros/${actionMacroId}`)
})
it('serves the compact list without message content', () => {
cy.api('GET', `/api/v1/macros/compact?q=${encodeURIComponent(contentMacroName)}`).then(
({ status, body }) => {
expect(status).to.eq(200)
expect(body.data).to.have.length(1)
expect(body.data[0].id).to.eq(contentMacroId)
expect(body.data[0]).to.not.have.property('message_content')
expect(body.data[0].has_message_content).to.eq(true)
}
)
})
it('searches macros by name and view without message content', () => {
cy.api('GET', `/api/v1/macros/search?q=${encodeURIComponent(actionMacroName)}`).then(({ status, body }) => {
expect(status).to.eq(200)
expect(body.data.map((m) => m.id)).to.deep.eq([actionMacroId])
expect(body.data[0]).to.not.have.property('message_content')
expect(body.data[0].has_message_content).to.eq(false)
})
cy.api('GET', `/api/v1/macros/search?q=${encodeURIComponent(actionMacroName)}&view=adding_private_note`)
.its('body.data')
.should('have.length', 0)
})
it('keeps the legacy list response unchanged', () => {
cy.api('GET', '/api/v1/macros').then(({ status, body }) => {
expect(status).to.eq(200)
const row = body.data.find((r) => r.id === contentMacroId)
expect(row.message_content).to.contain(messageBody)
expect(row).to.not.have.property('has_message_content')
})
})
it('serves a single macro with its content to an authenticated agent', () => {
cy.api('GET', `/api/v1/macros/${contentMacroId}`).then(({ status, body }) => {
expect(status).to.eq(200)
expect(body.data.message_content).to.contain(messageBody)
})
})
it('searches macros live in the palette and fetches the preview on demand', () => {
cy.intercept('GET', '**/api/v1/macros/search*').as('macroSearch')
cy.intercept('GET', /\/api\/v1\/macros\/\d+$/).as('macroContent')
cy.visit('/inboxes/assigned')
cy.contains('My inbox').should('be.visible')
// useMagicKeys only sees Ctrl_M when the Control and m keydowns arrive in separate tasks.
cy.window().then(async (win) => {
win.dispatchEvent(new win.KeyboardEvent('keydown', { key: 'Control', code: 'ControlLeft', bubbles: true }))
await new Promise((resolve) => setTimeout(resolve, 50))
win.dispatchEvent(new win.KeyboardEvent('keydown', { key: 'm', code: 'KeyM', ctrlKey: true, bubbles: true }))
})
cy.wait('@macroSearch')
cy.get('[cmdk-input-wrapper] input').type(contentMacroName)
cy.wait('@macroSearch')
cy.contains('[role="option"]', contentMacroName)
.should('exist')
.trigger('pointerenter')
cy.wait('@macroContent').its('response.statusCode').should('eq', 200)
cy.contains(messageBody).should('be.visible')
// Auto-highlight and pointerenter both hit this macro, the cache must collapse them to one request.
cy.get('@macroContent.all').then((calls) => {
const ours = calls.filter((c) => c.request.url.endsWith(`/${contentMacroId}`))
expect(ours).to.have.length(1)
})
cy.get('[cmdk-input-wrapper] input').clear()
cy.get('[cmdk-input-wrapper] input').type(`Missing macro ${stamp}`)
cy.wait('@macroSearch')
cy.contains('No results found').should('be.visible')
})
})
@@ -34,6 +34,14 @@ describe('Conversation lifecycle', () => {
})
}
// Pickers list one server page, so a fresh row is only reachable through the search input.
const searchInPicker = (text) =>
cy
.get('[data-radix-popper-content-wrapper]:visible input')
.last()
.invoke('val', text)
.trigger('input')
// The list column also renders a status dropdown; this one is the header badge.
const statusBadge = () => cy.get('div.bg-primary.rounded-md')
@@ -130,7 +138,8 @@ describe('Conversation lifecycle', () => {
openConversation()
openActionsSection()
cy.contains('button[role="combobox"]', 'Select agent').click()
cy.get('[role="option"]').contains(agentName).click()
searchInPicker(agentLastName)
cy.contains('[role="option"]', agentName).click()
cy.wait('@assignAgent').its('response.statusCode').should('eq', 200)
cy.contains('button[role="combobox"]', agentName).should('exist')
@@ -142,7 +151,8 @@ describe('Conversation lifecycle', () => {
openConversation()
openActionsSection()
cy.contains('button[role="combobox"]', 'Select team').click()
cy.get('[role="option"]').contains(teamName).click()
searchInPicker(teamName)
cy.contains('[role="option"]', teamName).click()
cy.wait('@assignTeam').its('response.statusCode').should('eq', 200)
cy.contains('button[role="combobox"]', teamName).should('exist')
@@ -169,8 +179,8 @@ describe('Conversation lifecycle', () => {
openConversation()
openActionsSection()
cy.get('input[placeholder="Select tags"]').click()
cy.get('[role="option"]').contains(tagName).click()
cy.get('input[placeholder="Select tags"]').click().type(tagName)
cy.contains('[role="option"]', tagName).click()
cy.wait('@setTags').its('response.statusCode').should('eq', 200)
cy.api('GET', `/api/v1/conversations/${conversationUuid}`)
@@ -189,12 +199,18 @@ describe('Conversation lifecycle', () => {
openConversation()
cy.contains('button', 'Private note').click()
cy.get('.tiptap.ProseMirror').first().click().type(noteBody)
cy.get('.tiptap.ProseMirror').first().click()
cy.get('.tiptap.ProseMirror').first().type(noteBody)
cy.contains('button', /^Send$/).click()
cy.wait('@sendNote').its('response.statusCode').should('eq', 200)
// Scoped to the thread: the list column shows the same text as the preview.
cy.get('[data-message-uuid]').contains(noteBody).closest('.bg-private').should('exist')
cy.wait('@sendNote').then(({ request, response }) => {
expect(request.body.private).to.eq(true)
expect(response.statusCode).to.eq(200)
expect(response.body.data.private).to.eq(true)
cy.get(`[data-message-uuid="${response.body.data.uuid}"]`)
.find('.message-bubble')
.should('have.class', 'bg-private')
})
})
it('sends a reply that appears in the thread', () => {
@@ -205,7 +221,8 @@ describe('Conversation lifecycle', () => {
cy.get('input[placeholder="Email addresses separated by comma"]')
.first()
.should('not.have.value', '')
cy.get('.tiptap.ProseMirror').first().click().type(replyBody)
cy.get('.tiptap.ProseMirror').first().click()
cy.get('.tiptap.ProseMirror').first().type(replyBody)
cy.contains('button', /^Send$/).click() // exact: the split button next to it is "send and set status"
cy.wait('@sendReply').its('response.statusCode').should('eq', 200)
@@ -0,0 +1,381 @@
// The first page is stubbed empty, so these pass or fail on the ids call alone whatever the DB holds.
const stamp = Date.now()
const agentFirst = `Zzlate${stamp}`
const agentName = `${agentFirst} Agent`
const teamName = `zzz-late-team-${stamp}`
const viewName = `ZZ late view ${stamp}`
const sharedViewName = `ZZ late shared view ${stamp}`
const ruleName = `ZZ late rule ${stamp}`
const macroName = `ZZ late macro ${stamp}`
const tagName = `zz-late-tag-${stamp}`
const inboxName = `ZZ late inbox ${stamp}`
const contactLastName = `LateCustomer${stamp}`
const leafFilters = (agentId) => ({
logic: 'AND',
rules: [
{
logic: 'AND',
rules: [
{
model: 'conversations',
field: 'assigned_user_id',
operator: 'equals',
value: String(agentId)
}
]
}
]
})
const blankFirstPage = (resource) => {
cy.intercept({ method: 'GET', url: `**/api/v1/${resource}*`, query: { page: '1' } }, { body: { data: [] } })
}
const valueCombobox = () => cy.get('[data-cy="filter-value"] button[role="combobox"]')
const typeInPicker = (text) =>
cy
.get('[data-radix-popper-content-wrapper]:visible input')
.last()
.invoke('val', text)
.trigger('input')
describe('Saved lookup values outside the first page', () => {
const ids = {}
// radix-vue focuses a trigger it just unmounted, and Cypress fails the test on uncaught errors.
Cypress.on('uncaught:exception', (err) => !err.message.includes("reading 'focus'"))
before(() => {
cy.login()
cy.api('POST', '/api/v1/agents', {
first_name: agentFirst,
last_name: 'Agent',
email: `zz-late-agent-${stamp}@example.com`,
roles: ['Agent'],
send_welcome_email: false
}).then(({ body }) => {
ids.agent = body.data.id
cy.api('POST', '/api/v1/teams', {
name: teamName,
emoji: '🛰️',
conversation_assignment_type: 'Round robin',
timezone: 'UTC',
max_auto_assigned_conversations: 0
}).then(({ body: teamBody }) => {
ids.team = teamBody.data.id
cy.api('POST', '/api/v1/views/me', {
name: viewName,
filters: leafFilters(ids.agent)
}).then(({ body: viewBody }) => (ids.view = viewBody.data.id))
cy.api('POST', '/api/v1/shared-views', {
name: sharedViewName,
visibility: 'all',
filters: leafFilters(ids.agent)
}).then(({ body: sharedBody }) => (ids.sharedView = sharedBody.data.id))
cy.api('POST', '/api/v1/automations/rules', {
name: ruleName,
description: ruleName,
type: 'new_conversation',
enabled: false,
events: [],
rules: [
{
group_operator: 'AND',
groups: [
{
logical_op: 'AND',
rules: [
{
field: 'assigned_user',
value: String(ids.agent),
operator: 'equals',
field_type: 'conversation'
},
{
field: 'assigned_team',
value: String(ids.team),
operator: 'equals',
field_type: 'conversation'
}
]
}
],
actions: [
{ type: 'assign_user', value: [String(ids.agent)] },
{ type: 'assign_team', value: [String(ids.team)] },
{
type: 'notify',
value: [],
recipients: [`user:${ids.agent}`, `team:${ids.team}`],
subject: 'x',
message: 'y'
}
]
}
]
}).then(({ body: ruleBody }) => (ids.rule = ruleBody.data.id))
cy.api('POST', '/api/v1/macros', {
name: macroName,
message_content: 'probe',
visibility: 'all',
visible_when: ['replying'],
actions: [{ type: 'assign_user', value: [String(ids.agent)] }]
}).then(({ body: macroBody }) => (ids.macro = macroBody.data.id))
cy.api('POST', '/api/v1/tags', { name: tagName }).then(({ body: tagBody }) => {
ids.tag = tagBody.data.id
})
cy.api('POST', '/api/v1/inboxes', {
name: inboxName,
channel: 'email',
enabled: true,
from: `Late <late+${stamp}@cypress.test>`,
config: {
auth_type: 'password',
imap: [],
smtp: [
{
host: '127.0.0.1',
port: 1025,
auth_protocol: 'none',
max_conns: 2,
idle_timeout: '5s',
pool_wait_timeout: '5s',
max_msg_retries: 1,
tls_type: 'none'
}
]
}
}).then(({ body: inboxBody }) => {
ids.inbox = inboxBody.data.id
cy.api('POST', '/api/v1/conversations', {
inbox_id: ids.inbox,
agent_id: ids.agent,
team_id: ids.team,
contact_email: `zz-late-customer-${stamp}@example.com`,
first_name: 'ZZ',
last_name: contactLastName,
subject: `ZZ late conversation ${stamp}`,
content: '<p>Saved lookup values probe.</p>',
initiator: 'contact'
}).then(({ body: convBody }) => {
ids.conversation = convBody.data.uuid
cy.api('POST', `/api/v1/conversations/${ids.conversation}/tags`, {
action: 'set_tags',
tags: [tagName]
})
})
})
})
})
})
beforeEach(() => {
cy.viewport(1400, 1000)
cy.login()
})
describe('user view filters', () => {
const openEditDialog = () => {
cy.visit('/inboxes/assigned')
cy.contains('button', viewName).parent().find('button[aria-haspopup="menu"]').click()
cy.get('[role="menuitem"]').contains('Edit').click()
cy.get('[data-cy="filter-row"]').should('have.length', 1)
}
it('labels the saved agent by asking for it by id', () => {
blankFirstPage('agents/compact')
cy.intercept('GET', '**/api/v1/agents/compact?*ids=*').as('agentById')
openEditDialog()
cy.wait('@agentById').its('response.statusCode').should('eq', 200)
valueCombobox().should('contain.text', agentName)
})
// Inside a dialog, radix closes the picker on Cypress's synthetic typing after the first key,
// so this pins the search call reaching the server. Real typing lists the results.
it('searches agents on the server', () => {
cy.intercept('GET', '**/api/v1/agents/compact?*q=*').as('agentSearch')
openEditDialog()
valueCombobox().should('contain.text', agentName).click()
typeInPicker(agentFirst)
cy.wait('@agentSearch').its('request.url').should('include', 'q=')
})
})
describe('shared view filters', () => {
const editPath = () => `/admin/conversations/shared-views/${ids.sharedView}/edit`
it('labels the saved agent by asking for it by id', () => {
blankFirstPage('agents/compact')
cy.intercept('GET', '**/api/v1/agents/compact?*ids=*').as('agentById')
cy.visit(editPath())
cy.wait('@agentById').its('response.statusCode').should('eq', 200)
valueCombobox().should('contain.text', agentName)
})
it('searches agents on the server', () => {
cy.intercept('GET', '**/api/v1/agents/compact?*q=*').as('agentSearch')
cy.visit(editPath())
valueCombobox().click()
typeInPicker(agentFirst)
cy.wait('@agentSearch').its('request.url').should('include', 'q=')
cy.contains('[role="option"]', agentName).should('exist')
})
})
describe('automation rule', () => {
const editPath = () => `/admin/automations/${ids.rule}/edit`
it('labels the saved agent and team in conditions and actions', () => {
blankFirstPage('agents/compact')
blankFirstPage('teams/compact')
cy.intercept('GET', '**/api/v1/agents/compact?*ids=*').as('agentById')
cy.intercept('GET', '**/api/v1/teams/compact?*ids=*').as('teamById')
cy.visit(editPath())
cy.get('input[name="name"]').should('have.value', ruleName)
cy.wait('@agentById').its('response.statusCode').should('eq', 200)
cy.wait('@teamById').its('response.statusCode').should('eq', 200)
cy.get('button[role="combobox"]')
.filter((_, el) => el.innerText.includes(agentName))
.should('have.length.at.least', 2)
cy.get('button[role="combobox"]')
.filter((_, el) => el.innerText.includes(teamName))
.should('have.length.at.least', 2)
})
it('labels notify recipients instead of showing the raw id', () => {
blankFirstPage('agents/compact')
blankFirstPage('teams/compact')
cy.visit(editPath())
cy.get('input[name="name"]').should('have.value', ruleName)
cy.contains(`Agent: ${agentName}`).should('exist')
cy.contains(`Team: ${teamName}`).should('exist')
cy.contains(`user:${ids.agent}`).should('not.exist')
cy.contains(`team:${ids.team}`).should('not.exist')
})
it('searches agents from a condition value', () => {
cy.intercept('GET', '**/api/v1/agents/compact?*q=*').as('agentSearch')
cy.visit(editPath())
cy.get('input[name="name"]').should('have.value', ruleName)
cy.contains('button[role="combobox"]', agentName).first().click()
typeInPicker(agentFirst)
cy.wait('@agentSearch').its('request.url').should('include', 'q=')
cy.contains('[role="option"]', agentName).should('exist')
})
})
describe('macro actions', () => {
const editPath = () => `/admin/conversations/macros/${ids.macro}/edit`
it('labels the saved agent by asking for it by id', () => {
blankFirstPage('agents/compact')
cy.intercept('GET', '**/api/v1/agents/compact?*ids=*').as('agentById')
cy.visit(editPath())
cy.get('input[name="name"]').should('have.value', macroName)
cy.wait('@agentById').its('response.statusCode').should('eq', 200)
cy.contains('button[role="combobox"]', agentName).should('exist')
})
it('searches agents on the server', () => {
cy.intercept('GET', '**/api/v1/agents/compact?*q=*').as('agentSearch')
cy.visit(editPath())
cy.get('input[name="name"]').should('have.value', macroName)
cy.contains('button[role="combobox"]', agentName).click()
typeInPicker(agentFirst)
cy.wait('@agentSearch').its('request.url').should('include', 'q=')
cy.contains('[role="option"]', agentName).should('exist')
})
})
describe('conversation sidebar', () => {
const openConversation = () => {
cy.intercept('GET', '**/messages?page=*').as('loadMessages')
cy.visit(`/inboxes/all/conversation/${ids.conversation}`)
cy.wait('@loadMessages')
// The open set of sidebar sections is remembered per browser, so blind toggling closes it.
cy.contains('button', 'Actions').then(($trigger) => {
if ($trigger.attr('aria-expanded') !== 'true') cy.wrap($trigger).click()
})
}
it('labels the assigned team by asking for it by id', () => {
blankFirstPage('teams/compact')
cy.intercept('GET', '**/api/v1/teams/compact?*ids=*').as('teamById')
openConversation()
cy.wait('@teamById').its('response.statusCode').should('eq', 200)
cy.contains('button[role="combobox"]', teamName).should('exist')
})
it('labels the assigned agent by asking for it by id', () => {
blankFirstPage('agents/compact')
cy.intercept('GET', '**/api/v1/agents/compact?*ids=*').as('agentById')
openConversation()
cy.wait('@agentById').its('response.statusCode').should('eq', 200)
cy.contains('button[role="combobox"]', agentName).should('exist')
})
it('shows the assigned tag while the tag list page is empty', () => {
blankFirstPage('tags')
openConversation()
cy.contains('[data-radix-vue-collection-item]', tagName).should('exist')
})
it('searches teams on the server', () => {
cy.intercept('GET', '**/api/v1/teams/compact?*q=*').as('teamSearch')
openConversation()
cy.contains('button[role="combobox"]', teamName).click()
typeInPicker(teamName)
cy.wait('@teamSearch').its('request.url').should('include', 'q=')
cy.contains('[role="option"]', teamName).should('exist')
})
})
after(() => {
cy.login()
if (ids.view) cy.api('DELETE', `/api/v1/views/me/${ids.view}`)
if (ids.sharedView) cy.api('DELETE', `/api/v1/shared-views/${ids.sharedView}`)
if (ids.rule) cy.api('DELETE', `/api/v1/automations/rules/${ids.rule}`)
if (ids.macro) cy.api('DELETE', `/api/v1/macros/${ids.macro}`)
if (ids.team) cy.api('DELETE', `/api/v1/teams/${ids.team}`)
if (ids.agent) cy.api('DELETE', `/api/v1/agents/${ids.agent}`)
if (ids.tag) cy.api('DELETE', `/api/v1/tags/${ids.tag}`)
if (ids.inbox) cy.api('DELETE', `/api/v1/inboxes/${ids.inbox}`)
})
})
+18 -2
View File
@@ -8,11 +8,26 @@ const messageBody = `Canned reply from the macro form spec ${stamp}`
const newPath = '/admin/conversations/macros/new'
const listPath = '/admin/conversations/macros'
const filterList = (text) => cy.get('input[placeholder="Search"]').clear().type(text)
// The success toast sits over the search input until it expires, so wait it out first.
const filterList = (text) => {
cy.get('[data-sonner-toast]', { timeout: 10000 }).should('not.exist')
return cy.get('input[placeholder="Search"]').clear().type(text)
}
// Pickers list one server page, so a fresh row is only reachable through the search input.
const searchInPicker = (text) =>
cy
.get('[data-radix-popper-content-wrapper]:visible input')
.last()
.invoke('val', text)
.trigger('input')
describe('Macro form', () => {
let macroId
// radix-vue focuses a trigger it just unmounted, and Cypress fails the test on uncaught errors.
Cypress.on('uncaught:exception', (err) => !err.message.includes("reading 'focus'"))
before(() => {
cy.login()
cy.api('POST', '/api/v1/teams', {
@@ -67,7 +82,8 @@ describe('Macro form', () => {
cy.get('select[name="visibility"]').siblings('button[role="combobox"]').click()
cy.get('[role="option"]').contains('Team').click()
cy.contains('label', 'Team').parent().find('button[role="combobox"]').click()
cy.get('[role="option"]').contains(teamName).click()
searchInPicker(teamName)
cy.contains('[role="option"]', teamName).click()
cy.get('button[type="submit"]').click()
cy.wait('@updateMacro').its('response.statusCode').should('eq', 200)
@@ -0,0 +1,263 @@
describe('Private note permissions', () => {
const stamp = Date.now()
const password = 'StrongPass!123'
const contactEmail = `private-note.customer.${stamp}@example.com`
const smtpHost = Cypress.env('SMTP_HOST') || '127.0.0.1'
const smtpPort = Number(Cypress.env('SMTP_PORT') || 1025)
const commonPermissions = [
'conversations:read_all',
'conversations:read',
'messages:read',
'view:manage'
]
const combinations = [
{
key: 'both',
label: 'both permissions',
permissions: ['messages:write', 'messages:write_private'],
canReply: true,
canPrivateNote: true
},
{
key: 'public',
label: 'public-message permission only',
permissions: ['messages:write'],
canReply: true,
canPrivateNote: false
},
{
key: 'private',
label: 'private-note permission only',
permissions: ['messages:write_private'],
canReply: false,
canPrivateNote: true
},
{
key: 'neither',
label: 'neither permission',
permissions: [],
canReply: false,
canPrivateNote: false
}
].map((combination) => ({
...combination,
roleName: `Private Note ${combination.key} ${stamp}`,
email: `private-note.${combination.key}.${stamp}@example.com`
}))
let conversationUUID
let adminNoteUUID
let inboxID
const roleIDs = []
const agentIDs = []
const loginAsAgent = (combination) => {
cy.session(
['private-note-permissions', combination.email],
() => {
cy.visit('/')
cy.get('#email').clear()
cy.get('#email').type(combination.email)
cy.get('#password').clear()
cy.get('#password').type(password, { log: false })
cy.contains('button', 'Sign in').click()
cy.url().should('include', '/inboxes')
},
{
validate() {
cy.request('/api/v1/agents/me').its('status').should('eq', 200)
}
}
)
}
const sendMessage = (combination, privateNote) =>
cy.api(
'POST',
`/api/v1/conversations/${conversationUUID}/messages`,
{
sender_type: 'agent',
private: privateNote,
message: `<p>${combination.key}-${privateNote ? 'note' : 'reply'}-${stamp}</p>`,
attachments: [],
mentions: [],
to: privateNote ? [] : [contactEmail],
cc: [],
bcc: []
},
{ failOnStatusCode: false }
)
const deleteMessage = (messageUUID) =>
cy.api('DELETE', `/api/v1/conversations/${conversationUUID}/messages/${messageUUID}`, null, {
failOnStatusCode: false
})
const summarize = () =>
cy.api(
'POST',
'/api/v1/ai/summarize',
{ conversation_uuid: conversationUUID },
{ failOnStatusCode: false }
)
const expectPermissionResult = (response, allowed) => {
expect(response.status).to.eq(allowed ? 200 : 403)
if (!allowed) expect(response.body.error_type).to.eq('PermissionException')
}
const openConversation = () => {
cy.intercept('GET', '**/messages?page=*').as('loadMessages')
// Stubbed so the copilot answer renders without a configured AI provider.
cy.intercept('GET', '**/ai/copilot/messages*', {
body: { data: [{ role: 'assistant', content: '<p>copilot answer</p>' }] }
}).as('loadCopilot')
cy.visit(`/inboxes/all/conversation/${conversationUUID}`)
cy.wait('@loadMessages')
}
before(() => {
cy.login()
cy.api('POST', '/api/v1/inboxes', {
name: `Private Note Permissions ${stamp}`,
channel: 'email',
enabled: true,
from: `Private Note Permissions <private-note+${stamp}@cypress.test>`,
config: {
auth_type: 'password',
imap: [],
smtp: [
{
host: smtpHost,
port: smtpPort,
auth_protocol: 'none',
max_conns: 2,
idle_timeout: '5s',
pool_wait_timeout: '5s',
max_msg_retries: 1,
tls_type: 'none'
}
]
}
}).then(({ body }) => {
inboxID = body.data.id
cy.api('POST', '/api/v1/conversations', {
inbox_id: inboxID,
contact_email: contactEmail,
first_name: 'Private',
last_name: `Note${stamp}`,
subject: `Private note permissions ${stamp}`,
content: '<p>Permission test conversation.</p>',
initiator: 'contact'
}).then((response) => {
conversationUUID = response.body.data.uuid
cy.api('POST', `/api/v1/conversations/${conversationUUID}/messages`, {
sender_type: 'agent',
private: true,
message: `<p>admin-note-${stamp}</p>`,
attachments: [],
mentions: [],
to: [],
cc: [],
bcc: []
}).then(({ body }) => {
adminNoteUUID = body.data.uuid
})
})
})
combinations.forEach((combination) => {
cy.api('POST', '/api/v1/roles', {
name: combination.roleName,
description: `Tests ${combination.label}`,
permissions: [...commonPermissions, ...combination.permissions]
}).then(({ body }) => {
roleIDs.push(body.data.id)
})
cy.api('POST', '/api/v1/agents', {
first_name: 'Permission',
last_name: combination.key,
email: combination.email,
roles: [combination.roleName],
enabled: true,
send_welcome_email: false
}).then(({ body }) => {
agentIDs.push(body.data.id)
cy.api('PUT', `/api/v1/agents/${body.data.id}`, {
first_name: 'Permission',
last_name: combination.key,
email: combination.email,
roles: [combination.roleName],
enabled: true,
new_password: password
})
})
})
})
after(() => {
cy.login()
const drop = (path) => cy.api('DELETE', path, null, { failOnStatusCode: false })
agentIDs.forEach((id) => drop(`/api/v1/agents/${id}`))
roleIDs.forEach((id) => drop(`/api/v1/roles/${id}`))
if (inboxID) drop(`/api/v1/inboxes/${inboxID}`)
})
combinations.forEach((combination) => {
it(`enforces and renders ${combination.label}`, () => {
cy.viewport(1440, 900)
loginAsAgent(combination)
sendMessage(combination, false).then((response) => {
expectPermissionResult(response, combination.canReply)
})
sendMessage(combination, true).then((response) => {
expectPermissionResult(response, combination.canPrivateNote)
if (combination.canPrivateNote) {
deleteMessage(response.body.data.uuid).its('status').should('eq', 200)
} else {
deleteMessage(adminNoteUUID).then((deleteResponse) => {
expectPermissionResult(deleteResponse, false)
})
}
})
// AI is not configured in CI, so an allowed agent fails later than the permission gate.
summarize().then((response) => {
if (combination.canPrivateNote) expect(response.status).to.not.eq(403)
else expectPermissionResult(response, false)
})
openConversation()
cy.contains('button', /^Reply$/).should(combination.canReply ? 'exist' : 'not.exist')
cy.contains('button', /^Private note$/).should(
combination.canPrivateNote ? 'exist' : 'not.exist'
)
cy.get('.tiptap.ProseMirror').should(
combination.canReply || combination.canPrivateNote ? 'exist' : 'not.exist'
)
if (!combination.canReply && combination.canPrivateNote) {
cy.contains('button', /^Private note$/).should('have.attr', 'data-state', 'active')
}
cy.get('button.w-11.h-11.p-0').click()
cy.contains('[role="menuitem"]', 'Download transcript').should('exist')
cy.contains('[role="menuitem"]', 'Summarize with AI').should(
combination.canPrivateNote ? 'exist' : 'not.exist'
)
cy.get('body').type('{esc}')
cy.contains('button', 'Juno').click()
cy.wait('@loadCopilot')
cy.contains('copilot answer')
.closest('.flex.flex-col.gap-1')
.find('button')
.should(
'have.length',
1 + Number(combination.canReply) + Number(combination.canPrivateNote)
)
})
})
})
+3
View File
@@ -29,6 +29,7 @@ describe('Role form', () => {
cy.get('input[name="name"]').type(roleName)
cy.get('input[name="description"]').type(roleDescription)
togglePermission('View all conversations')
togglePermission('Send private notes in conversations')
togglePermission('Manage tags')
togglePermission('Manage conversation statuses')
@@ -37,6 +38,7 @@ describe('Role form', () => {
expect(response.statusCode).to.eq(200)
expect(response.body.data.permissions).to.include.members([
'conversations:read_all',
'messages:write_private',
'tags:manage',
'status:manage'
])
@@ -56,6 +58,7 @@ describe('Role form', () => {
cy.get('input[name="name"]').should('have.value', roleName)
cy.get('input[name="description"]').should('have.value', roleDescription)
permission('View all conversations').should('have.attr', 'data-state', 'checked')
permission('Send private notes in conversations').should('have.attr', 'data-state', 'checked')
permission('Manage tags').should('have.attr', 'data-state', 'checked')
permission('Manage conversation statuses').should('have.attr', 'data-state', 'checked')
permission('Manage webhooks').should('have.attr', 'data-state', 'unchecked')
@@ -0,0 +1,361 @@
// The steps run in order and share the record created by the first one.
const stamp = Date.now()
const viewName = `Cypress View ${stamp}`
const renamedView = `Cypress View ${stamp} edited`
const teamName = `Cypress View Team ${stamp}`
const listPath = '/admin/conversations/shared-views'
const newPath = `${listPath}/new`
const filterList = (text) => {
cy.get('[data-sonner-toast]', { timeout: 10000 }).should('not.exist')
return cy.get('input[placeholder="Search"]').clear().type(text)
}
const row = (groupIndex, rowIndex) =>
cy.get('[data-cy="filter-group"]').eq(groupIndex).find('[data-cy="filter-row"]').eq(rowIndex)
const pick = (groupIndex, rowIndex, part, optionText) => {
row(groupIndex, rowIndex).find(`[data-cy="filter-${part}"] button[role="combobox"]`).click()
cy.get('[role="option"]').contains(optionText).click()
}
const condition = (groupIndex, rowIndex, field, operator, value) => {
pick(groupIndex, rowIndex, 'field', field)
pick(groupIndex, rowIndex, 'operator', operator)
if (value) pick(groupIndex, rowIndex, 'value', value)
}
// The picker holds one page, so a team outside it is only reachable by typing.
const searchAndPickTeam = (name) => {
cy.contains('label', 'Team').parent().find('button[role="combobox"]').click()
cy.get('[data-radix-popper-content-wrapper] input').first().type(name)
cy.get('[role="option"]').contains(name).click()
}
describe('Shared view form', () => {
let viewId
let teamId
// radix-vue focuses the operator trigger it just unmounted, and Cypress fails on uncaught errors.
Cypress.on('uncaught:exception', (err) => !err.message.includes("reading 'focus'"))
before(() => {
cy.login()
cy.api('POST', '/api/v1/teams', {
name: teamName,
emoji: '🔭',
conversation_assignment_type: 'Round robin',
timezone: 'UTC',
max_auto_assigned_conversations: 0
}).then(({ body }) => {
teamId = body.data.id
})
})
beforeEach(() => {
cy.viewport(1400, 1000)
cy.login()
})
it('starts with one group holding one blank condition', () => {
cy.visit(newPath)
cy.get('[data-cy="filter-group"]').should('have.length', 1)
cy.get('[data-cy="filter-row"]').should('have.length', 1)
cy.contains('Match these rules').should('exist')
cy.contains('button', 'ALL').should('exist')
cy.contains('button', 'Add condition').should('exist')
cy.contains('button', 'Add group').should('exist')
cy.get('[aria-label="Remove group"]').should('not.exist')
})
it('shows the operator select only after a field is chosen', () => {
cy.visit(newPath)
row(0, 0).find('[data-cy="filter-operator"] button[role="combobox"]').should('not.exist')
pick(0, 0, 'field', /^Status$/)
row(0, 0).find('[data-cy="filter-operator"] button[role="combobox"]').should('exist')
})
it('rejects a submit with no name and no filter', () => {
cy.intercept('POST', '**/api/v1/shared-views').as('createView')
cy.visit(newPath)
cy.get('button[type="submit"]').click()
cy.get('input[name="name"]').should('have.attr', 'aria-invalid', 'true')
cy.get('@createView.all').should('have.length', 0)
cy.location('pathname').should('eq', newPath)
})
it('refuses to save a condition with a field but no operator', () => {
cy.intercept('POST', '**/api/v1/shared-views').as('createView')
cy.visit(newPath)
cy.get('input[name="name"]').type(viewName)
pick(0, 0, 'field', /^Status$/)
cy.get('button[type="submit"]').click()
cy.get('@createView.all').should('have.length', 0)
cy.location('pathname').should('eq', newPath)
})
it('refuses to save a condition with no value', () => {
cy.intercept('POST', '**/api/v1/shared-views').as('createView')
cy.visit(newPath)
cy.get('input[name="name"]').type(viewName)
pick(0, 0, 'field', /^Status$/)
pick(0, 0, 'operator', /^equals$/)
cy.get('button[type="submit"]').click()
cy.get('@createView.all').should('have.length', 0)
cy.location('pathname').should('eq', newPath)
})
it('creates a view and sends a two-level filter tree', () => {
cy.intercept('POST', '**/api/v1/shared-views').as('createView')
cy.visit(newPath)
cy.get('input[name="name"]').type(viewName)
condition(0, 0, /^Status$/, /^equals$/, 'Open')
cy.get('button[type="submit"]').click()
cy.wait('@createView').then(({ request, response }) => {
expect(response.statusCode).to.eq(200)
viewId = response.body.data.id
const filters = request.body.filters
expect(filters, 'filters is an object, not a legacy array').to.be.an('object')
expect(filters).to.not.be.an('array')
expect(filters.logic).to.eq('AND')
expect(filters.rules).to.have.length(1)
const group = filters.rules[0]
expect(group.logic).to.eq('AND')
expect(group.rules).to.have.length(1)
expect(group.rules[0]).to.deep.include({
model: 'conversations',
field: 'status_id',
operator: 'equals'
})
expect(JSON.stringify(filters), 'no UI-only keys on the wire').to.not.contain('__id')
})
cy.location('pathname').should('eq', listPath)
filterList(viewName)
cy.contains(viewName).should('exist')
})
it('loads the saved filter back into the builder', () => {
expect(viewId, 'view from the create step').to.be.a('number')
cy.visit(`${listPath}/${viewId}/edit`)
cy.get('input[name="name"]').should('have.value', viewName)
cy.get('[data-cy="filter-group"]').should('have.length', 1)
cy.get('[data-cy="filter-row"]').should('have.length', 1)
row(0, 0).find('[data-cy="filter-field"]').should('contain.text', 'Status')
row(0, 0).find('[data-cy="filter-operator"]').should('contain.text', 'equals')
row(0, 0).find('[data-cy="filter-value"]').should('contain.text', 'Open')
})
it('saves an untouched edit byte for byte', () => {
cy.intercept('PUT', `**/api/v1/shared-views/${viewId}`).as('updateView')
cy.api('GET', `/api/v1/shared-views/${viewId}`).then(({ body }) => {
const loaded = body.data.filters
cy.visit(`${listPath}/${viewId}/edit`)
cy.get('[data-cy="filter-row"]').should('have.length', 1)
cy.get('button[type="submit"]').click()
cy.wait('@updateView').then(({ request }) => {
expect(request.body.filters).to.deep.eq(loaded)
})
})
})
it('adds a second condition and keeps both in one group', () => {
cy.intercept('PUT', `**/api/v1/shared-views/${viewId}`).as('updateView')
cy.visit(`${listPath}/${viewId}/edit`)
cy.get('[data-cy="filter-row"]').should('have.length', 1)
cy.contains('button', 'Add condition').click()
condition(0, 1, /^Priority$/, /^equals$/, 'High')
cy.get('button[type="submit"]').click()
cy.wait('@updateView').then(({ request, response }) => {
expect(response.statusCode).to.eq(200)
const filters = request.body.filters
expect(filters.rules, 'still one group').to.have.length(1)
expect(filters.rules[0].rules).to.have.length(2)
expect(filters.rules[0].rules.map((r) => r.field)).to.deep.eq(['status_id', 'priority_id'])
})
})
it('switches a group to ANY and persists OR', () => {
cy.intercept('PUT', `**/api/v1/shared-views/${viewId}`).as('updateView')
cy.visit(`${listPath}/${viewId}/edit`)
cy.get('[data-cy="filter-row"]').should('have.length', 2)
cy.contains('button', 'ALL').click()
cy.contains('button', 'ANY').should('exist')
cy.get('button[type="submit"]').click()
cy.wait('@updateView').then(({ request }) => {
expect(request.body.filters.rules[0].logic).to.eq('OR')
})
cy.visit(`${listPath}/${viewId}/edit`)
cy.contains('button', 'ANY').should('exist')
})
it('adds a second group and keeps the groups separate', () => {
cy.intercept('PUT', `**/api/v1/shared-views/${viewId}`).as('updateView')
cy.visit(`${listPath}/${viewId}/edit`)
cy.get('[data-cy="filter-group"]').should('have.length', 1)
cy.contains('button', 'Add group').click()
cy.get('[data-cy="filter-group"]').should('have.length', 2)
condition(1, 0, /^Assign team$/, /^set$/)
cy.get('button[type="submit"]').click()
cy.wait('@updateView').then(({ request, response }) => {
expect(response.statusCode).to.eq(200)
const filters = request.body.filters
expect(filters.rules, 'two groups').to.have.length(2)
expect(filters.rules[0].rules.map((r) => r.field)).to.deep.eq(['status_id', 'priority_id'])
expect(filters.rules[0].logic, 'first group keeps its ANY').to.eq('OR')
expect(filters.rules[1].rules).to.have.length(1)
expect(filters.rules[1].rules[0]).to.deep.include({
field: 'assigned_team_id',
operator: 'set'
})
})
cy.visit(`${listPath}/${viewId}/edit`)
cy.get('[data-cy="filter-group"]').should('have.length', 2)
cy.get('[aria-label="Remove group"]').should('have.length', 2)
})
it('reloads two groups and re-saves them identically', () => {
cy.intercept('PUT', `**/api/v1/shared-views/${viewId}`).as('updateView')
cy.api('GET', `/api/v1/shared-views/${viewId}`).then(({ body }) => {
const loaded = body.data.filters
cy.visit(`${listPath}/${viewId}/edit`)
cy.get('[data-cy="filter-group"]').should('have.length', 2)
cy.get('button[type="submit"]').click()
cy.wait('@updateView').then(({ request }) => {
expect(request.body.filters, 'round trip is stable').to.deep.eq(loaded)
})
})
})
it('removes a group and drops only that group', () => {
cy.intercept('PUT', `**/api/v1/shared-views/${viewId}`).as('updateView')
cy.visit(`${listPath}/${viewId}/edit`)
cy.get('[data-cy="filter-group"]').should('have.length', 2)
cy.get('[aria-label="Remove group"]').last().click()
cy.get('[data-cy="filter-group"]').should('have.length', 1)
cy.get('button[type="submit"]').click()
cy.wait('@updateView').then(({ request }) => {
const filters = request.body.filters
expect(filters.rules).to.have.length(1)
expect(filters.rules[0].rules.map((r) => r.field)).to.deep.eq(['status_id', 'priority_id'])
})
})
it('persists a rename and a team-scoped visibility', () => {
cy.intercept('PUT', `**/api/v1/shared-views/${viewId}`).as('updateView')
cy.visit(`${listPath}/${viewId}/edit`)
cy.get('input[name="name"]').should('have.value', viewName).clear().type(renamedView)
cy.get('select[name="visibility"]').siblings('button[role="combobox"]').click()
cy.get('[role="option"]').contains('Team').click()
searchAndPickTeam(teamName)
cy.get('button[type="submit"]').click()
cy.wait('@updateView').then(({ request, response }) => {
expect(response.statusCode).to.eq(200)
expect(request.body.team_id, 'team id goes out as a number').to.be.a('number')
})
cy.visit(`${listPath}/${viewId}/edit`)
cy.get('input[name="name"]').should('have.value', renamedView)
cy.get('select[name="visibility"]').should('have.value', 'team')
cy.contains('label', 'Team')
.parent()
.find('button[role="combobox"]')
.should('contain.text', teamName)
})
it('resolves the saved team by id even though it sits outside the first page', () => {
cy.intercept(
{ method: 'GET', url: '**/api/v1/teams/compact*', query: { page: '1' } },
{ body: { data: [] } }
)
cy.intercept('GET', '**/api/v1/teams/compact?*ids=*').as('teamById')
cy.visit(`${listPath}/${viewId}/edit`)
cy.wait('@teamById').its('response.statusCode').should('eq', 200)
cy.contains('label', 'Team')
.parent()
.find('button[role="combobox"]')
.should('contain.text', teamName)
})
it('searches the team picker on the server instead of holding every team', () => {
cy.intercept('GET', '**/api/v1/teams/compact?*q=*').as('teamLookup')
cy.visit(`${listPath}/${viewId}/edit`)
cy.contains('label', 'Team').parent().find('button[role="combobox"]').click()
cy.get('[data-radix-popper-content-wrapper] input').first().type(teamName)
cy.wait('@teamLookup').its('request.url').should('include', 'q=')
cy.get('[role="option"]').contains(teamName).should('exist')
})
it('clears team_id when visibility goes back to all agents', () => {
cy.intercept('PUT', `**/api/v1/shared-views/${viewId}`).as('updateView')
cy.visit(`${listPath}/${viewId}/edit`)
cy.get('select[name="visibility"]').siblings('button[role="combobox"]').click()
cy.get('[role="option"]').contains('All agents').click()
cy.get('button[type="submit"]').click()
cy.wait('@updateView').then(({ request, response }) => {
expect(response.statusCode).to.eq(200)
expect(request.body.team_id).to.eq(null)
})
})
it('deletes the view', () => {
cy.intercept('DELETE', `**/api/v1/shared-views/${viewId}`).as('deleteView')
cy.visit(listPath)
filterList(renamedView)
cy.contains('tr', renamedView).find('button[aria-haspopup="menu"]').click()
cy.get('[role="menuitem"]').contains('Delete').click()
cy.get('[role="alertdialog"]').contains('button', 'Delete').click()
cy.wait('@deleteView').its('response.statusCode').should('eq', 200)
cy.contains(renamedView).should('not.exist')
})
after(() => {
cy.login()
if (teamId) cy.api('DELETE', `/api/v1/teams/${teamId}`)
})
})
+306
View File
@@ -0,0 +1,306 @@
// The steps run in order and share the view created by the first one.
const stamp = Date.now()
const viewName = `Cypress user view ${stamp}`
const renamedView = `Cypress user view ${stamp} edited`
const dialog = () => cy.get('[role="dialog"]')
const row = (groupIndex, rowIndex) =>
dialog()
.find('[data-cy="filter-group"]')
.eq(groupIndex)
.find('[data-cy="filter-row"]')
.eq(rowIndex)
const pick = (groupIndex, rowIndex, part, optionText) => {
row(groupIndex, rowIndex).find(`[data-cy="filter-${part}"] button[role="combobox"]`).click()
cy.get('[role="option"]').contains(optionText).click()
}
const condition = (groupIndex, rowIndex, field, operator, value) => {
pick(groupIndex, rowIndex, 'field', field)
pick(groupIndex, rowIndex, 'operator', operator)
if (value) pick(groupIndex, rowIndex, 'value', value)
}
const awaitFocusedName = () => {
dialog().should('be.visible')
dialog().find('input[name="name"]').should('be.focused')
}
const openCreateDialog = () => {
cy.visit('/inboxes/assigned')
cy.contains('My inbox').should('be.visible')
// The add button is a bare icon that only appears on hover, so reach it through the section label.
cy.contains('.sidebar-section-label', /^Views$/)
.siblings('div')
.find('svg')
.click({ force: true })
awaitFocusedName()
}
const openEditDialog = (name) => {
cy.visit('/inboxes/assigned')
cy.contains('button', name).parent().find('button[aria-haspopup="menu"]').click()
cy.get('[role="menuitem"]').contains('Edit').click()
awaitFocusedName()
}
// The dialog re-renders the name field just after opening and drops whatever was typed until then.
const setName = (value, attempt = 0) => {
dialog().find('input[name="name"]').type(`{selectall}${value}`)
dialog()
.find('input[name="name"]')
.then(($el) => {
if ($el.val() !== value && attempt < 5) setName(value, attempt + 1)
})
}
const save = () => dialog().find('button[type="submit"]').click()
describe('User view form', () => {
let viewId
// radix-vue focuses the operator trigger it just unmounted, and Cypress fails on uncaught errors.
Cypress.on('uncaught:exception', (err) => !err.message.includes("reading 'focus'"))
beforeEach(() => {
cy.viewport(1400, 1000)
cy.login()
})
after(() => {
cy.login()
cy.then(() => {
if (viewId) cy.api('DELETE', `/api/v1/views/me/${viewId}`, null, { failOnStatusCode: false })
})
})
it('starts with one group holding one blank condition', () => {
openCreateDialog()
dialog().find('[data-cy="filter-group"]').should('have.length', 1)
dialog().find('[data-cy="filter-row"]').should('have.length', 1)
dialog().contains('Match these rules').should('exist')
dialog().contains('button', 'ALL').should('exist')
dialog().contains('button', 'Add condition').should('exist')
dialog().contains('button', 'Add group').should('exist')
dialog().find('[aria-label="Remove group"]').should('not.exist')
})
it('rejects a submit with no name and no filter', () => {
cy.intercept('POST', '**/api/v1/views/me').as('createView')
openCreateDialog()
save()
dialog().find('input[name="name"]').should('have.attr', 'aria-invalid', 'true')
cy.get('@createView.all').should('have.length', 0)
dialog().should('be.visible')
})
it('refuses to save a condition with a field but no operator', () => {
cy.intercept('POST', '**/api/v1/views/me').as('createView')
openCreateDialog()
setName(viewName)
pick(0, 0, 'field', /^Status$/)
save()
cy.get('@createView.all').should('have.length', 0)
dialog().should('be.visible')
})
it('creates a view from one group holding one condition', () => {
cy.intercept('POST', '**/api/v1/views/me').as('createView')
openCreateDialog()
setName(viewName)
condition(0, 0, /^Status$/, /^equals$/, 'Open')
save()
cy.wait('@createView').then(({ request, response }) => {
expect(response.statusCode).to.eq(200)
viewId = response.body.data.id
const filters = request.body.filters
expect(filters, 'filters is an object, not a legacy array').to.be.an('object')
expect(filters.logic).to.eq('AND')
expect(filters.rules).to.have.length(1)
expect(filters.rules[0].logic).to.eq('AND')
expect(filters.rules[0].rules).to.have.length(1)
expect(filters.rules[0].rules[0]).to.deep.include({
model: 'conversations',
field: 'status_id',
operator: 'equals'
})
expect(JSON.stringify(filters), 'no UI-only keys on the wire').to.not.contain('__id')
})
dialog().should('not.exist')
cy.contains('button', viewName).should('exist')
})
it('loads the saved filter back into the builder', () => {
openEditDialog(viewName)
dialog().find('input[name="name"]').should('have.value', viewName)
dialog().find('[data-cy="filter-group"]').should('have.length', 1)
dialog().find('[data-cy="filter-row"]').should('have.length', 1)
row(0, 0).find('[data-cy="filter-field"]').should('contain.text', 'Status')
row(0, 0).find('[data-cy="filter-operator"]').should('contain.text', 'equals')
row(0, 0).find('[data-cy="filter-value"]').should('contain.text', 'Open')
})
it('holds three conditions in one group joined by ALL', () => {
cy.intercept('PUT', `**/api/v1/views/me/${viewId}`).as('updateView')
openEditDialog(viewName)
dialog().contains('button', 'Add condition').click()
condition(0, 1, /^Priority$/, /^equals$/, 'High')
dialog().contains('button', 'Add condition').click()
condition(0, 2, /^Assign team$/, /^set$/)
save()
cy.wait('@updateView').then(({ request, response }) => {
expect(response.statusCode).to.eq(200)
const filters = request.body.filters
expect(filters.rules, 'still one group').to.have.length(1)
expect(filters.rules[0].logic).to.eq('AND')
expect(filters.rules[0].rules.map((r) => r.field)).to.deep.eq([
'status_id',
'priority_id',
'assigned_team_id'
])
})
})
it('switches that group to ANY and persists OR', () => {
cy.intercept('PUT', `**/api/v1/views/me/${viewId}`).as('updateView')
openEditDialog(viewName)
dialog().find('[data-cy="filter-row"]').should('have.length', 3)
dialog().contains('button', 'ALL').click()
dialog().contains('button', 'ANY').should('exist')
save()
cy.wait('@updateView').then(({ request }) => {
expect(request.body.filters.rules[0].logic).to.eq('OR')
})
openEditDialog(viewName)
dialog().contains('button', 'ANY').should('exist')
})
it('adds a second group and keeps each group its own logic', () => {
cy.intercept('PUT', `**/api/v1/views/me/${viewId}`).as('updateView')
openEditDialog(viewName)
dialog().contains('button', 'Add group').click()
dialog().find('[data-cy="filter-group"]').should('have.length', 2)
condition(1, 0, /^Priority$/, /^equals$/, 'Low')
dialog().find('[data-cy="filter-group"]').eq(1).contains('button', 'Add condition').click()
condition(1, 1, /^Status$/, /^equals$/, 'Closed')
save()
cy.wait('@updateView').then(({ request, response }) => {
expect(response.statusCode).to.eq(200)
const filters = request.body.filters
expect(filters.rules, 'two groups').to.have.length(2)
expect(filters.logic, 'groups joined by AND until toggled').to.eq('AND')
expect(filters.rules[0].logic, 'first group keeps its ANY').to.eq('OR')
expect(filters.rules[0].rules).to.have.length(3)
expect(filters.rules[1].logic).to.eq('AND')
expect(filters.rules[1].rules.map((r) => r.field)).to.deep.eq(['priority_id', 'status_id'])
})
})
it('switches the connector between the two groups to OR', () => {
cy.intercept('PUT', `**/api/v1/views/me/${viewId}`).as('updateView')
openEditDialog(viewName)
dialog().find('[data-cy="filter-group"]').should('have.length', 2)
dialog().contains('button', 'AND').click()
dialog().contains('button', 'OR').should('exist')
save()
cy.wait('@updateView').then(({ request }) => {
const filters = request.body.filters
expect(filters.logic, 'groups now joined by OR').to.eq('OR')
expect(filters.rules[0].logic, 'inner logic untouched').to.eq('OR')
expect(filters.rules[1].logic).to.eq('AND')
})
openEditDialog(viewName)
dialog().contains('button', 'OR').should('exist')
})
it('re-saves a two-group tree byte for byte', () => {
cy.intercept('PUT', `**/api/v1/views/me/${viewId}`).as('updateView')
cy.api('GET', '/api/v1/views/me').then(({ body }) => {
const loaded = body.data.find((v) => v.id === viewId).filters
openEditDialog(viewName)
dialog().find('[data-cy="filter-group"]').should('have.length', 2)
save()
cy.wait('@updateView').then(({ request }) => {
expect(request.body.filters, 'round trip is stable').to.deep.eq(loaded)
})
})
})
it('removes a group and drops only that group', () => {
cy.intercept('PUT', `**/api/v1/views/me/${viewId}`).as('updateView')
openEditDialog(viewName)
dialog().find('[aria-label="Remove group"]').should('have.length', 2).last().click()
dialog().find('[data-cy="filter-group"]').should('have.length', 1)
save()
cy.wait('@updateView').then(({ request }) => {
const filters = request.body.filters
expect(filters.rules).to.have.length(1)
expect(filters.rules[0].rules.map((r) => r.field)).to.deep.eq([
'status_id',
'priority_id',
'assigned_team_id'
])
})
})
it('persists a rename', () => {
cy.intercept('PUT', `**/api/v1/views/me/${viewId}`).as('updateView')
openEditDialog(viewName)
setName(renamedView)
dialog().find('input[name="name"]').should('have.value', renamedView)
save()
cy.wait('@updateView').then(({ request, response }) => {
expect(response.statusCode).to.eq(200)
expect(request.body.name, 'name on the wire').to.eq(renamedView)
})
cy.api('GET', '/api/v1/views/me').then(({ body }) => {
expect(body.data.find((v) => v.id === viewId).name, 'stored name').to.eq(renamedView)
})
cy.contains('button', renamedView).should('exist')
})
it('deletes the view from the sidebar', () => {
cy.intercept('DELETE', `**/api/v1/views/me/${viewId}`).as('deleteView')
cy.visit('/inboxes/assigned')
cy.contains('button', renamedView).parent().find('button[aria-haspopup="menu"]').click()
cy.get('[role="menuitem"]').contains('Delete').click()
cy.get('[role="alertdialog"]').contains('button', 'Delete').click()
cy.wait('@deleteView').its('response.statusCode').should('eq', 200)
cy.contains('button', renamedView).should('not.exist')
cy.then(() => {
viewId = null
})
})
})
@@ -19,8 +19,8 @@
</PopoverTrigger>
<PopoverContent class="p-0" :align="align">
<Command v-model:search-term="searchTerm" :filter-function="passThroughFilter">
<CommandInput class="h-9" :placeholder="placeholder" />
<CommandEmpty>{{ $t('globals.messages.notFound') }}</CommandEmpty>
<CommandInput class="h-9" :placeholder="placeholder" :loading="searching" />
<CommandEmpty v-if="!searching">{{ $t('globals.messages.notFound') }}</CommandEmpty>
<CommandList>
<CommandGroup>
<CommandItem
@@ -44,11 +44,12 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, watch, onUnmounted } from 'vue'
import { CaretSortIcon, CheckIcon } from '@radix-icons/vue'
import { cn } from '../../../lib/utils'
import { Button } from '../button'
import { Popover, PopoverContent, PopoverTrigger } from '../popover'
import { cn } from '@shared-ui/lib/utils'
import { useRemoteSearch } from '@shared-ui/composables/useRemoteSearch'
import { Button } from '@shared-ui/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@shared-ui/components/ui/popover'
import {
CommandEmpty,
CommandGroup,
@@ -56,9 +57,10 @@ import {
Command,
CommandItem,
CommandList
} from '../command'
} from '@shared-ui/components/ui/command'
const RENDER_CAP = 300
const SEARCH_DEBOUNCE_MS = 250
const props = defineProps({
items: {
@@ -74,6 +76,11 @@ const props = defineProps({
align: {
type: String,
default: 'center'
},
// When set, typing queries the server instead of filtering `items` locally.
search: {
type: Function,
default: null
}
})
@@ -84,10 +91,34 @@ const searchTerm = ref('')
const passThroughFilter = (items) => items
const {
results: remoteItems,
searching,
update: updateSearch,
dispose: disposeSearch
} = useRemoteSearch((term) => props.search(term), SEARCH_DEBOUNCE_MS)
watch(searchTerm, (term) => {
if (props.search) updateSearch(term)
})
watch(open, (isOpen) => {
if (!isOpen && searchTerm.value) searchTerm.value = ''
})
onUnmounted(() => {
disposeSearch()
})
const displayedItems = computed(() =>
props.search && remoteItems.value !== null ? remoteItems.value : props.items
)
const filteredItems = computed(() => {
if (props.search) return displayedItems.value
const term = searchTerm.value?.trim().toLowerCase()
if (!term) return props.items
return props.items.filter((item) =>
if (!term) return displayedItems.value
return displayedItems.value.filter((item) =>
[item.label, item.calling_code]
.filter(Boolean)
.some((field) => String(field).toLowerCase().includes(term))
@@ -96,13 +127,31 @@ const filteredItems = computed(() => {
const visibleItems = computed(() => filteredItems.value.slice(0, RENDER_CAP))
const selectedItem = computed(() => props.items.find((i) => i.value === value.value))
// Reloading the list can drop the picked row from `items`, leaving the trigger with no label.
const pickedItem = ref(null)
const selectedItem = computed(
() =>
props.items.find((i) => i.value === value.value) ||
(pickedItem.value?.value === value.value ? pickedItem.value : null)
)
const selectedLabel = computed(() => selectedItem.value?.label || props.defaultLabel)
watch(
[value, () => props.items],
([currentValue, items]) => {
const selected = items.find((item) => item.value === currentValue)
if (selected) pickedItem.value = selected
else if (pickedItem.value?.value !== currentValue) pickedItem.value = null
},
{ immediate: true, deep: true }
)
const handleSelect = (ev) => {
if (typeof ev.detail.value === 'string') {
try {
const selected = JSON.parse(ev.detail.value)
pickedItem.value = selected
value.value = selected.value
open.value = false
emit('select', selected)
@@ -1,4 +1,5 @@
<script setup>
import { computed } from 'vue'
import { useForwardPropsEmits } from 'radix-vue'
import Command from './Command.vue'
import { Dialog, DialogContent } from '../dialog'
@@ -9,11 +10,19 @@ const props = defineProps({
modal: { type: Boolean, required: false },
searchTerm: { type: String, required: false },
filterFunction: { type: Function, required: false },
class: { type: String, required: false }
class: { type: String, required: false },
commandClass: { type: String, required: false }
})
const emits = defineEmits(['update:open', 'update:searchTerm'])
const forwarded = useForwardPropsEmits(props, emits)
const delegatedProps = computed(() => {
const delegated = { ...props }
delete delegated.class
delete delegated.commandClass
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
@@ -23,7 +32,10 @@ const forwarded = useForwardPropsEmits(props, emits)
:search-term="props.searchTerm"
:filter-function="props.filterFunction"
@update:search-term="$emit('update:searchTerm', $event)"
class="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-2 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
:class="[
'[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-2 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5',
props.commandClass
]"
>
<slot />
</Command>
@@ -3,6 +3,7 @@ import { computed } from 'vue'
import { MagnifyingGlassIcon } from '@radix-icons/vue'
import { ComboboxInput, useForwardProps } from 'radix-vue'
import { cn } from '../../../lib/utils'
import Spinner from '@shared-ui/components/ui/spinner/Spinner.vue'
defineOptions({
inheritAttrs: false
@@ -14,11 +15,12 @@ const props = defineProps({
autoFocus: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false }
class: { type: null, required: false },
loading: { type: Boolean, required: false, default: false }
})
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
const { class: _, loading: __, ...delegated } = props
return delegated
})
@@ -28,7 +30,14 @@ const forwardedProps = useForwardProps(delegatedProps)
<template>
<div class="flex items-center border-b px-3" cmdk-input-wrapper>
<MagnifyingGlassIcon class="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Spinner
v-if="loading"
size="xs"
variant="muted"
:absolute="false"
class="mr-2 h-4 w-4 shrink-0"
/>
<MagnifyingGlassIcon v-else class="mr-2 h-4 w-4 shrink-0 opacity-50" />
<ComboboxInput
v-bind="{ ...forwardedProps, ...$attrs }"
auto-focus
@@ -17,7 +17,7 @@ const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
hideCloseButton: { type: Boolean, required: false },
hideCloseButton: { type: Boolean, required: false }
})
const emits = defineEmits([
'escapeKeyDown',
@@ -29,7 +29,9 @@ const emits = defineEmits([
])
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
const delegated = { ...props }
delete delegated.class
delete delegated.hideCloseButton
return delegated
})
@@ -40,7 +42,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
class="fixed inset-0 z-50 bg-background/70 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/>
<DialogContent
v-bind="forwarded"
@@ -18,16 +18,25 @@
class="w-full"
>
<ComboboxAnchor as-child>
<ComboboxInput :placeholder="placeholder" as-child>
<TagsInputInput
class="w-full px-3"
:class="tags.length > 0 ? 'mt-2' : ''"
@keydown.enter.prevent
@blur="handleBlur"
@click="open = true"
@input.stop
<div class="flex w-full items-center">
<ComboboxInput :placeholder="placeholder" as-child>
<TagsInputInput
class="w-full px-3"
:class="tags.length > 0 ? 'mt-2' : ''"
@keydown.enter.prevent
@blur="handleBlur"
@click="open = true"
@input.stop
/>
</ComboboxInput>
<Spinner
v-if="searching"
size="xs"
variant="muted"
:absolute="false"
class="mr-3 h-4 w-4 shrink-0"
/>
</ComboboxInput>
</div>
</ComboboxAnchor>
<ComboboxPortal>
<ComboboxContent>
@@ -35,7 +44,7 @@
position="popper"
class="w-[--radix-popper-anchor-width] rounded-md mt-2 border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
>
<CommandEmpty>{{ $t('globals.messages.noResultsFound') }}</CommandEmpty>
<CommandEmpty v-if="!searching">{{ $t('globals.messages.noResultsFound') }}</CommandEmpty>
<CommandGroup>
<CommandItem
v-for="item in visibleOptions"
@@ -54,14 +63,14 @@
</template>
<script setup>
import { CommandEmpty, CommandGroup, CommandItem, CommandList } from '../command'
import { CommandEmpty, CommandGroup, CommandItem, CommandList } from '@shared-ui/components/ui/command'
import {
TagsInput,
TagsInputInput,
TagsInputItem,
TagsInputItemDelete,
TagsInputItemText
} from '../tags-input'
} from '@shared-ui/components/ui/tags-input'
import {
ComboboxAnchor,
ComboboxContent,
@@ -69,10 +78,13 @@ import {
ComboboxPortal,
ComboboxRoot
} from 'radix-vue'
import { computed, ref } from 'vue'
import { computed, ref, watch, onUnmounted } from 'vue'
import { useField } from 'vee-validate'
import Spinner from '@shared-ui/components/ui/spinner/Spinner.vue'
import { useRemoteSearch } from '@shared-ui/composables/useRemoteSearch'
const RENDER_CAP = 200
const SEARCH_DEBOUNCE_MS = 250
const tags = defineModel({
required: false,
@@ -93,6 +105,11 @@ const props = defineProps({
type: Array,
required: true,
validator: (value) => value.every((item) => 'label' in item && 'value' in item)
},
// When set, typing queries the server instead of filtering `items` locally.
search: {
type: Function,
default: null
}
})
@@ -103,10 +120,33 @@ const { handleBlur } = useField(() => props.name, undefined, {
const open = ref(false)
const searchTerm = ref('')
const filteredOptions = computed(() => {
const available = props.items.filter((item) => !tags.value.includes(item.value))
const {
results: remoteItems,
searching,
update: updateSearch,
dispose: disposeSearch
} = useRemoteSearch((term) => props.search(term), SEARCH_DEBOUNCE_MS)
if (!searchTerm.value) return available
watch(searchTerm, (term) => {
if (props.search) updateSearch(term)
})
watch(open, (isOpen) => {
if (!isOpen && searchTerm.value) searchTerm.value = ''
})
onUnmounted(() => {
disposeSearch()
})
const displayedItems = computed(() =>
props.search && remoteItems.value !== null ? remoteItems.value : props.items
)
const filteredOptions = computed(() => {
const available = displayedItems.value.filter((item) => !tags.value.includes(item.value))
if (props.search || !searchTerm.value) return available
return available.filter((item) =>
item.label.toLowerCase().includes(searchTerm.value.toLowerCase())
@@ -115,9 +155,20 @@ const filteredOptions = computed(() => {
const visibleOptions = computed(() => filteredOptions.value.slice(0, RENDER_CAP))
// Reloading the list can drop a chosen row from `items`, which would leave its chip showing a raw id.
const seenLabels = new Map()
watch(
displayedItems,
(items) => {
for (const item of items) seenLabels.set(item.value, item.label)
},
{ immediate: true, deep: true }
)
const getLabel = (value) => {
const item = props.items.find((item) => item.value === value)
return item?.label || value
const item = displayedItems.value.find((item) => item.value === value)
return item?.label || seenLabels.get(value) || value
}
const handleSelect = (event) => {
@@ -133,7 +184,8 @@ const handleSelect = (event) => {
}
const filterFunc = (remainingItemValues, term) => {
const remainingItems = props.items.filter((item) => remainingItemValues.includes(item.value))
const remainingItems = displayedItems.value.filter((item) => remainingItemValues.includes(item.value))
if (props.search) return remainingItems.map((item) => item.value)
return remainingItems
.filter((item) => item.label.toLowerCase().includes(term.toLowerCase()))
.map((item) => item.value)
@@ -0,0 +1,54 @@
import { ref } from 'vue'
import { useTimeoutFn } from '@vueuse/core'
export function useRemoteSearch (search, delay) {
const results = ref(null)
const searching = ref(false)
let version = 0
let disposed = false
let pendingSearch = null
const runSearch = async () => {
const { query, currentVersion } = pendingSearch
try {
const found = await search(query)
if (!disposed && currentVersion === version) results.value = found || []
} catch {
if (!disposed && currentVersion === version) results.value = []
} finally {
if (!disposed && currentVersion === version) searching.value = false
}
}
const { start: startSearch, stop: stopSearch } = useTimeoutFn(runSearch, delay, {
immediate: false
})
const update = (term) => {
const query = (term || '').trim()
const currentVersion = ++version
stopSearch()
if (!query) {
results.value = null
searching.value = false
return
}
searching.value = true
pendingSearch = { query, currentVersion }
startSearch()
}
const dispose = () => {
disposed = true
version++
stopSearch()
searching.value = false
}
return {
results,
searching,
update,
dispose
}
}
@@ -0,0 +1,50 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { useRemoteSearch } from './useRemoteSearch'
describe('useRemoteSearch', () => {
afterEach(() => {
vi.useRealTimers()
})
it('keeps picker results independent when another picker is cleared', async () => {
vi.useFakeTimers()
const first = useRemoteSearch(vi.fn().mockResolvedValue([{ value: '1', label: 'one' }]), 10)
const second = useRemoteSearch(vi.fn().mockResolvedValue([{ value: '2', label: 'two' }]), 10)
first.update('one')
second.update('two')
await vi.runAllTimersAsync()
first.update('')
expect(first.results.value).toBeNull()
expect(second.results.value).toEqual([{ value: '2', label: 'two' }])
})
it('ignores a pending response after the picker is cleared', async () => {
vi.useFakeTimers()
let resolveSearch
const search = vi.fn().mockReturnValue(new Promise((resolve) => { resolveSearch = resolve }))
const lookup = useRemoteSearch(search, 10)
lookup.update('old')
await vi.advanceTimersByTimeAsync(10)
lookup.update('')
resolveSearch([{ value: '1', label: 'old' }])
await Promise.resolve()
expect(lookup.results.value).toBeNull()
expect(lookup.searching.value).toBe(false)
})
it('cancels a debounced search when the picker is cleared', async () => {
vi.useFakeTimers()
const search = vi.fn()
const lookup = useRemoteSearch(search, 10)
lookup.update('old')
lookup.update('')
await vi.runAllTimersAsync()
expect(search).not.toHaveBeenCalled()
})
})
+3
View File
@@ -488,6 +488,7 @@
"admin.role.messages.read": "View conversation messages",
"admin.role.messages.write": "Send messages in conversations",
"admin.role.messages.writeAsContact": "Send messages as contact",
"admin.role.messages.writePrivate": "Send private notes in conversations",
"admin.role.notificationSettings.manage": "Manage notification settings",
"admin.role.oidc.manage": "Manage SSO configuration",
"admin.role.reports.manage": "Manage reports",
@@ -1075,6 +1076,7 @@
"globals.terms.provider": "Provider | Providers",
"globals.terms.providerURL": "Provider URL",
"globals.terms.published": "Published",
"globals.terms.read": "Read",
"globals.terms.reconnect": "Reconnect",
"globals.terms.referenceNumber": "Reference number",
"globals.terms.refresh": "Refresh",
@@ -1095,6 +1097,7 @@
"globals.terms.security": "Security",
"globals.terms.select": "Select",
"globals.terms.sendWelcomeEmail": "Send welcome email? | Send welcome emails?",
"globals.terms.sent": "Sent",
"globals.terms.setPassword": "Set password | Set passwords",
"globals.terms.sharedView": "Shared view | Shared views",
"globals.terms.sla": "SLA | SLAs",
+46 -22
View File
@@ -44,7 +44,7 @@ type Provider struct {
ID int
Provider string
ProviderURL string
RedirectURL string
RedirectURL func() (string, error)
ClientID string
ClientSecret string
}
@@ -61,21 +61,23 @@ const defaultSessionLifetime = 9 * time.Hour
// Auth is the auth service it manages OIDC authentication and sessions
type Auth struct {
mu sync.RWMutex
cfg Config
i18n *i18n.I18n
oauthCfgs map[int]oauth2.Config
verifiers map[int]*oidc.IDTokenVerifier
sess *simplesessions.Manager
logger *logf.Logger
rd *redis.Client
oidcClient *http.Client
mu sync.RWMutex
cfg Config
i18n *i18n.I18n
oauthCfgs map[int]oauth2.Config
verifiers map[int]*oidc.IDTokenVerifier
redirectURLs map[int]func() (string, error)
sess *simplesessions.Manager
logger *logf.Logger
rd *redis.Client
oidcClient *http.Client
}
// New creates an Auth service with configured OIDC providers.
func New(cfg Config, i18n *i18n.I18n, rd *redis.Client, logger *logf.Logger, dialControl ssrf.Control) (*Auth, error) {
oauthCfgs := make(map[int]oauth2.Config)
verifiers := make(map[int]*oidc.IDTokenVerifier)
redirectURLs := make(map[int]func() (string, error))
oidcClient := newOIDCClient(dialControl)
@@ -90,7 +92,6 @@ func New(cfg Config, i18n *i18n.I18n, rd *redis.Client, logger *logf.Logger, dia
ClientID: provider.ClientID,
ClientSecret: provider.ClientSecret,
Endpoint: oidcProv.Endpoint(),
RedirectURL: provider.RedirectURL,
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
@@ -98,6 +99,7 @@ func New(cfg Config, i18n *i18n.I18n, rd *redis.Client, logger *logf.Logger, dia
oauthCfgs[provider.ID] = oauthCfg
verifiers[provider.ID] = verifier
redirectURLs[provider.ID] = provider.RedirectURL
}
lifetime := cfg.SessionLifetime
@@ -123,14 +125,15 @@ func New(cfg Config, i18n *i18n.I18n, rd *redis.Client, logger *logf.Logger, dia
sess.SetCookieHooks(simpleSessGetCookieCB, simpleSessSetCookieCB)
return &Auth{
cfg: cfg,
i18n: i18n,
oauthCfgs: oauthCfgs,
verifiers: verifiers,
sess: sess,
logger: logger,
rd: rd,
oidcClient: oidcClient,
cfg: cfg,
i18n: i18n,
oauthCfgs: oauthCfgs,
verifiers: verifiers,
redirectURLs: redirectURLs,
sess: sess,
logger: logger,
rd: rd,
oidcClient: oidcClient,
}, nil
}
@@ -162,6 +165,7 @@ func (a *Auth) Reload(cfg Config) error {
oauthCfgs := make(map[int]oauth2.Config)
verifiers := make(map[int]*oidc.IDTokenVerifier)
redirectURLs := make(map[int]func() (string, error))
for _, provider := range cfg.Providers {
oidcProv, err := oidc.NewProvider(oidc.ClientContext(context.Background(), a.oidcClient), provider.ProviderURL)
@@ -174,7 +178,6 @@ func (a *Auth) Reload(cfg Config) error {
ClientID: provider.ClientID,
ClientSecret: provider.ClientSecret,
Endpoint: oidcProv.Endpoint(),
RedirectURL: provider.RedirectURL,
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
@@ -182,11 +185,13 @@ func (a *Auth) Reload(cfg Config) error {
oauthCfgs[provider.ID] = oauthCfg
verifiers[provider.ID] = verifier
redirectURLs[provider.ID] = provider.RedirectURL
}
a.cfg = cfg
a.oauthCfgs = oauthCfgs
a.verifiers = verifiers
a.redirectURLs = redirectURLs
return nil
}
@@ -199,10 +204,14 @@ func (a *Auth) LoginURL(providerID int, state string) (string, error) {
if !ok {
return "", envelope.NewError(envelope.InputError, a.i18n.T("validation.notFoundProvider"), nil)
}
redirectURL, err := a.resolveRedirectURL(providerID)
if err != nil {
a.logger.Error("error resolving oidc redirect url", "provider_id", providerID, "error", err)
return "", envelope.NewError(envelope.GeneralError, a.i18n.T("globals.messages.somethingWentWrong"), nil)
}
oauthCfg.RedirectURL = redirectURL
return oauthCfg.AuthCodeURL(state), nil
}
// ExchangeOIDCToken takes an OIDC authorization code, validates it, and returns an OIDC token for subsequent auth.
func (a *Auth) ExchangeOIDCToken(ctx context.Context, providerID int, code string) (string, OIDCclaim, error) {
a.mu.RLock()
defer a.mu.RUnlock()
@@ -212,6 +221,12 @@ func (a *Auth) ExchangeOIDCToken(ctx context.Context, providerID int, code strin
a.logger.Error("oidc provider not configured, it may have failed to initialize at startup", "provider_id", providerID)
return "", OIDCclaim{}, fmt.Errorf("invalid provider ID: %d", providerID)
}
redirectURL, err := a.resolveRedirectURL(providerID)
if err != nil {
a.logger.Error("error resolving oidc redirect url", "provider_id", providerID, "error", err)
return "", OIDCclaim{}, err
}
oauthCfg.RedirectURL = redirectURL
verifier, ok := a.verifiers[providerID]
if !ok {
@@ -389,6 +404,15 @@ func (a *Auth) DestroySession(r *fastglue.Request) error {
return nil
}
// resolveRedirectURL reads the provider's redirect URL from the current root URL setting.
func (a *Auth) resolveRedirectURL(providerID int) (string, error) {
fn, ok := a.redirectURLs[providerID]
if !ok || fn == nil {
return "", fmt.Errorf("no redirect URL resolver for provider: %d", providerID)
}
return fn()
}
// generateCSRFToken creates a random base64 encoded str.
func generateCSRFToken() (string, error) {
b, err := stringutil.RandomAlphanumeric(32)
+2
View File
@@ -16,6 +16,7 @@ const (
PermConversationWrite = "conversations:write"
PermMessagesRead = "messages:read"
PermMessagesWrite = "messages:write"
PermMessagesWritePrivate = "messages:write_private"
PermMessagesWriteAsContact = "messages:write_as_contact"
// View
@@ -114,6 +115,7 @@ var validPermissions = map[string]struct{}{
PermConversationWrite: {},
PermMessagesRead: {},
PermMessagesWrite: {},
PermMessagesWritePrivate: {},
PermMessagesWriteAsContact: {},
PermViewManage: {},
PermSharedViewsManage: {},
+32 -15
View File
@@ -36,6 +36,8 @@ const (
NewConversation TaskType = "new"
UpdateConversation TaskType = "update"
TimeTrigger TaskType = "time-trigger"
timeTriggerBatchSize = 1000
)
// ConversationTask represents a unit of work for processing conversations.
@@ -72,7 +74,7 @@ type Opts struct {
type conversationStore interface {
ApplyAction(action models.RuleAction, conversation cmodels.Conversation, user umodels.User) error
GetConversation(teamID int, uuid, refNum string) (cmodels.Conversation, error)
GetConversationsCreatedAfter(time.Time) ([]cmodels.Conversation, error)
GetConversationsCreatedAfter(after time.Time, afterID, limit int) ([]cmodels.ConversationRef, error)
}
type queries struct {
@@ -364,28 +366,43 @@ func (e *Engine) handleUpdateConversation(conversation cmodels.Conversation, eve
// handleTimeTrigger handles time trigger events.
func (e *Engine) handleTimeTrigger() {
e.lo.Info("running time trigger evaluation for automation rules")
thirtyDaysAgo := time.Now().Add(-30 * 24 * time.Hour)
conversations, err := e.conversationStore.GetConversationsCreatedAfter(thirtyDaysAgo)
if err != nil {
e.lo.Error("error fetching conversations for time trigger", "error", err)
return
}
rules := e.filterRulesByType(models.RuleTypeTimeTrigger, "")
if len(rules) == 0 {
e.lo.Info("no rules to evaluate for time trigger")
return
}
e.lo.Info("fetched conversations for evaluating time triggers", "conversations_count", len(conversations), "rules_count", len(rules))
for _, c := range conversations {
// Fetch entire conversation.
conversation, err := e.conversationStore.GetConversation(0, c.UUID, "")
var (
thirtyDaysAgo = time.Now().Add(-30 * 24 * time.Hour)
afterID = 0
total = 0
batch = 0
)
for {
refs, err := e.conversationStore.GetConversationsCreatedAfter(thirtyDaysAgo, afterID, timeTriggerBatchSize)
if err != nil {
e.lo.Error("error fetching conversation for time trigger", "uuid", c.UUID, "error", err)
continue
e.lo.Error("error fetching conversations for time trigger", "after_id", afterID, "batch", batch, "error", err)
return
}
batch++
e.lo.Info("fetched conversation batch for time trigger", "batch", batch, "after_id", afterID, "count", len(refs))
if len(refs) == 0 {
break
}
for _, ref := range refs {
conversation, err := e.conversationStore.GetConversation(0, ref.UUID, "")
if err != nil {
e.lo.Error("error fetching conversation for time trigger", "uuid", ref.UUID, "error", err)
continue
}
e.evalConversationRules(rules, conversation, nil)
}
afterID = refs[len(refs)-1].ID
total += len(refs)
if len(refs) < timeTriggerBatchSize {
break
}
e.evalConversationRules(rules, conversation, nil)
}
e.lo.Info("evaluated conversations for time triggers", "conversations_count", total, "batches", batch, "rules_count", len(rules))
}
// suppress marks a conversation as having automation actions in flight.
+3 -3
View File
@@ -33,9 +33,9 @@ func (m *mockConversationStore) GetConversation(teamID int, uuid, refNum string)
return args.Get(0).(cmodels.Conversation), args.Error(1)
}
func (m *mockConversationStore) GetConversationsCreatedAfter(t time.Time) ([]cmodels.Conversation, error) {
args := m.Called(t)
return args.Get(0).([]cmodels.Conversation), args.Error(1)
func (m *mockConversationStore) GetConversationsCreatedAfter(t time.Time, afterID, limit int) ([]cmodels.ConversationRef, error) {
args := m.Called(t, afterID, limit)
return args.Get(0).([]cmodels.ConversationRef), args.Error(1)
}
// Test Helpers
+1 -1
View File
@@ -88,7 +88,7 @@ var ActionPermissions = map[string]string{
ActionAssignUser: authzModels.PermConversationsUpdateUserAssignee,
ActionSetStatus: authzModels.PermConversationsUpdateStatus,
ActionSetPriority: authzModels.PermConversationsUpdatePriority,
ActionSendPrivateNote: authzModels.PermMessagesWrite,
ActionSendPrivateNote: authzModels.PermMessagesWritePrivate,
ActionReply: authzModels.PermMessagesWrite,
ActionAddTags: authzModels.PermConversationsUpdateTags,
ActionSetTags: authzModels.PermConversationsUpdateTags,
+88
View File
@@ -0,0 +1,88 @@
package automation
import (
"errors"
"fmt"
"testing"
"github.com/abhinavxd/libredesk/internal/automation/models"
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
"github.com/stretchr/testify/mock"
)
func makeRefs(fromID, n int) []cmodels.ConversationRef {
refs := make([]cmodels.ConversationRef, 0, n)
for i := 0; i < n; i++ {
id := fromID + i
refs = append(refs, cmodels.ConversationRef{ID: id, UUID: fmt.Sprintf("uuid-%d", id)})
}
return refs
}
func timeTriggerEngine(store *mockConversationStore) *Engine {
e := createTestEngine(store)
e.rules = []models.Rule{{Type: models.RuleTypeTimeTrigger}}
return e
}
func TestTimeTrigger_NoRules_SkipsFetch(t *testing.T) {
store := &mockConversationStore{}
engine := createTestEngine(store)
engine.handleTimeTrigger()
store.AssertNotCalled(t, "GetConversationsCreatedAfter", mock.Anything, mock.Anything, mock.Anything)
}
func TestTimeTrigger_PagesUntilShortBatch(t *testing.T) {
store := &mockConversationStore{}
engine := timeTriggerEngine(store)
batch1 := makeRefs(1, timeTriggerBatchSize)
batch2 := makeRefs(timeTriggerBatchSize+1, 3)
store.On("GetConversationsCreatedAfter", mock.Anything, 0, timeTriggerBatchSize).Return(batch1, nil).Once()
store.On("GetConversationsCreatedAfter", mock.Anything, timeTriggerBatchSize, timeTriggerBatchSize).Return(batch2, nil).Once()
store.On("GetConversation", 0, mock.Anything, "").Return(cmodels.Conversation{}, nil)
engine.handleTimeTrigger()
store.AssertExpectations(t)
store.AssertNumberOfCalls(t, "GetConversation", timeTriggerBatchSize+3)
}
func TestTimeTrigger_EmptyBatch_StopsWithoutFetchingConversations(t *testing.T) {
store := &mockConversationStore{}
engine := timeTriggerEngine(store)
store.On("GetConversationsCreatedAfter", mock.Anything, 0, timeTriggerBatchSize).Return([]cmodels.ConversationRef{}, nil).Once()
engine.handleTimeTrigger()
store.AssertExpectations(t)
store.AssertNotCalled(t, "GetConversation", mock.Anything, mock.Anything, mock.Anything)
}
func TestTimeTrigger_FetchError_Stops(t *testing.T) {
store := &mockConversationStore{}
engine := timeTriggerEngine(store)
store.On("GetConversationsCreatedAfter", mock.Anything, 0, timeTriggerBatchSize).Return([]cmodels.ConversationRef{}, errors.New("db down")).Once()
engine.handleTimeTrigger()
store.AssertExpectations(t)
store.AssertNotCalled(t, "GetConversation", mock.Anything, mock.Anything, mock.Anything)
}
func TestTimeTrigger_ConversationFetchError_ContinuesBatch(t *testing.T) {
store := &mockConversationStore{}
engine := timeTriggerEngine(store)
store.On("GetConversationsCreatedAfter", mock.Anything, 0, timeTriggerBatchSize).Return(makeRefs(1, 2), nil).Once()
store.On("GetConversation", 0, "uuid-1", "").Return(cmodels.Conversation{}, errors.New("gone")).Once()
store.On("GetConversation", 0, "uuid-2", "").Return(cmodels.Conversation{}, nil).Once()
engine.handleTimeTrigger()
store.AssertExpectations(t)
}
+19 -20
View File
@@ -312,7 +312,7 @@ type queries struct {
GetUserActiveConversationsCount *sqlx.Stmt `query:"get-user-active-conversations-count"`
GetSidebarStandardCounts *sqlx.Stmt `query:"get-sidebar-standard-counts"`
GetConversationsCountBase string `query:"get-conversations-count-base"`
UpdateConversationWaitingSince *sqlx.Stmt `query:"update-conversation-waiting-since"`
StartConversationWaitingSince *sqlx.Stmt `query:"start-conversation-waiting-since"`
UpdateConversationReplyTimestamps *sqlx.Stmt `query:"update-conversation-reply-timestamps"`
UpdateConversationContactLastSeen *sqlx.Stmt `query:"update-conversation-contact-last-seen"`
UpsertUserLastSeen *sqlx.Stmt `query:"upsert-user-last-seen"`
@@ -525,14 +525,14 @@ func (c *Manager) SignAvatarURL(avatarURL *null.String) {
}
}
// GetConversationsCreatedAfter retrieves conversations created after the specified time.
func (c *Manager) GetConversationsCreatedAfter(time time.Time) ([]models.Conversation, error) {
var conversations = make([]models.Conversation, 0)
if err := c.q.GetConversationsCreatedAfter.Select(&conversations, time); err != nil {
c.lo.Error("error fetching conversation", "error", err)
return conversations, err
// GetConversationsCreatedAfter retrieves a batch of conversation refs created after the given time, keyset-paged by id.
func (c *Manager) GetConversationsCreatedAfter(after time.Time, afterID, limit int) ([]models.ConversationRef, error) {
var refs = make([]models.ConversationRef, 0, limit)
if err := c.q.GetConversationsCreatedAfter.Select(&refs, after, afterID, limit); err != nil {
c.lo.Error("error fetching conversation refs", "error", err)
return refs, err
}
return conversations, nil
return refs, nil
}
// UpdateUserLastSeen updates the last seen timestamp for a specific user on a conversation.
@@ -555,13 +555,17 @@ func (c *Manager) MarkAsUnread(uuid string, userID int) error {
// UpdateContactLastSeen updates the last seen timestamp of the contact in the conversation.
func (c *Manager) UpdateConversationContactLastSeen(uuid string) error {
if _, err := c.q.UpdateConversationContactLastSeen.Exec(uuid); err != nil {
var lastSeenAt time.Time
if err := c.q.UpdateConversationContactLastSeen.Get(&lastSeenAt, uuid); err != nil {
if err == sql.ErrNoRows {
return nil
}
c.lo.Error("error updating contact last seen timestamp", "conversation_id", uuid, "error", err)
return envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil)
}
// Broadcast the property update to all subscribers.
c.BroadcastConversationUpdate(uuid, map[string]any{"contact_last_seen_at": time.Now().Format(time.RFC3339)})
c.BroadcastConversationUpdate(uuid, map[string]any{"contact_last_seen_at": lastSeenAt.Format(time.RFC3339Nano)})
return nil
}
@@ -722,21 +726,16 @@ func (c *Manager) UpdateConversationLastMessage(conversation int, conversationUU
return nil
}
// UpdateConversationWaitingSince updates the waiting since timestamp for a conversation.
func (c *Manager) UpdateConversationWaitingSince(conversationUUID string, at *time.Time) error {
res, err := c.q.UpdateConversationWaitingSince.Exec(conversationUUID, at)
// StartConversationWaitingSince stamps the waiting since timestamp only if the conversation isn't already waiting.
func (c *Manager) StartConversationWaitingSince(conversationUUID string, at time.Time) error {
res, err := c.q.StartConversationWaitingSince.Exec(conversationUUID, at)
if err != nil {
c.lo.Error("error updating conversation waiting since", "error", err)
return err
}
rows, _ := res.RowsAffected()
if rows > 0 {
if at != nil {
c.BroadcastConversationUpdate(conversationUUID, map[string]any{"waiting_since": at.Format(time.RFC3339)})
} else {
c.BroadcastConversationUpdate(conversationUUID, map[string]any{"waiting_since": nil})
}
if rows, _ := res.RowsAffected(); rows > 0 {
c.BroadcastConversationUpdate(conversationUUID, map[string]any{"waiting_since": at.Format(time.RFC3339)})
}
return nil
}
+5 -3
View File
@@ -476,7 +476,9 @@ func (m *Manager) SendPrivateNote(media []mmodels.Media, senderID int, conversat
}
// CreateContactMessage creates a contact message in a conversation.
func (m *Manager) CreateContactMessage(media []mmodels.Media, contactID int, conversationUUID, content, contentType string, isNewConversation bool) (models.Message, error) {
// sourceID is the bare RFC 5322 Message-ID of the inbound message; it is normalized and stored on the message so replies thread on it, mirroring the IMAP ingestion path. Empty leaves the column NULL.
func (m *Manager) CreateContactMessage(media []mmodels.Media, contactID int, conversationUUID, content, contentType string, isNewConversation bool, sourceID string) (models.Message, error) {
sourceID = stringutil.NormalizeMessageID(sourceID)
message := models.Message{
ConversationUUID: conversationUUID,
SenderID: contactID,
@@ -487,6 +489,7 @@ func (m *Manager) CreateContactMessage(media []mmodels.Media, contactID int, con
ContentType: contentType,
Private: false,
Media: media,
SourceID: null.NewString(sourceID, sourceID != ""),
}
if err := m.InsertMessage(&message); err != nil {
return models.Message{}, err
@@ -1368,8 +1371,7 @@ func (m *Manager) uploadThumbnailForMedia(media mmodels.Media, content []byte) e
// function to trigger the necessary hooks.
func (m *Manager) ProcessIncomingMessageHooks(conversationUUID string, isNewConversation bool) error {
// Start waiting since clock, cleared when agent replies.
now := time.Now()
m.UpdateConversationWaitingSince(conversationUUID, &now)
m.StartConversationWaitingSince(conversationUUID, time.Now())
// Handle new conversation events.
if isNewConversation {
+7
View File
@@ -157,6 +157,12 @@ type ConversationListContact struct {
AvatarURL null.String `db:"avatar_url" json:"avatar_url"`
}
// ConversationRef is a lightweight (id, uuid) reference to a conversation.
type ConversationRef struct {
ID int `db:"id" json:"id"`
UUID string `db:"uuid" json:"uuid"`
}
type Conversation struct {
ID int `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
@@ -166,6 +172,7 @@ type Conversation struct {
InboxID int `db:"inbox_id" json:"inbox_id"`
ClosedAt null.Time `db:"closed_at" json:"closed_at"`
ResolvedAt null.Time `db:"resolved_at" json:"resolved_at"`
ContactLastSeenAt null.Time `db:"contact_last_seen_at" json:"contact_last_seen_at"`
ReferenceNumber string `db:"reference_number" json:"reference_number"`
Priority null.String `db:"priority" json:"priority"`
PriorityID null.Int `db:"priority_id" json:"priority_id"`
+8 -4
View File
@@ -186,6 +186,7 @@ SELECT
c.updated_at,
c.closed_at,
c.resolved_at,
c.contact_last_seen_at,
c.inbox_id,
inb.name as inbox_name,
COALESCE(inb.from, '') as inbox_mail,
@@ -291,7 +292,9 @@ SELECT
c.id,
c.uuid
FROM conversations c
WHERE c.created_at > $1;
WHERE c.created_at > $1 AND c.id > $2
ORDER BY c.id
LIMIT $3;
-- name: get-contact-previous-conversations
SELECT
@@ -443,7 +446,8 @@ WHERE uuid = $1 AND assigned_user_id IS NULL AND assigned_team_id = $3;
UPDATE conversations
SET contact_last_seen_at = NOW(),
updated_at = NOW()
WHERE uuid = $1;
WHERE uuid = $1
RETURNING contact_last_seen_at;
-- name: update-conversation-assigned-team
UPDATE conversations
@@ -609,11 +613,11 @@ SET custom_attributes = $2,
updated_at = NOW()
WHERE uuid = $1;
-- name: update-conversation-waiting-since
-- name: start-conversation-waiting-since
UPDATE conversations
SET waiting_since = $2,
updated_at = NOW()
WHERE uuid = $1;
WHERE uuid = $1 AND waiting_since IS NULL;
-- name: update-conversation-reply-timestamps
WITH old AS (

Some files were not shown because too many files have changed in this diff Show More