mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-11 13:28:57 +00:00
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:
+2
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
])
|
||||
|
||||
@@ -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">↑↓</kbd>
|
||||
<kbd
|
||||
:class="KBD_CLASS"
|
||||
>↑↓</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
|
||||
})
|
||||
|
||||
|
||||
+12
-39
@@ -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(
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}`)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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}`)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user