diff --git a/cmd/custom_attributes.go b/cmd/custom_attributes.go index ee36bb6d..f6d435d3 100644 --- a/cmd/custom_attributes.go +++ b/cmd/custom_attributes.go @@ -1,11 +1,13 @@ package main import ( + "encoding/json" "slices" "strconv" cmodels "github.com/abhinavxd/libredesk/internal/custom_attribute/models" "github.com/abhinavxd/libredesk/internal/envelope" + "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat" "github.com/valyala/fasthttp" "github.com/zerodha/fastglue" ) @@ -112,6 +114,9 @@ func handleDeleteCustomAttribute(r *fastglue.Request) error { if err = app.customAttribute.Delete(id); err != nil { return sendErrorEnvelope(r, err) } + if err := removeAttributeFromPreChatForms(app, id); err != nil { + return sendErrorEnvelope(r, err) + } return r.SendEnvelope(true) } @@ -137,3 +142,88 @@ func validateCustomAttribute(app *App, attribute cmodels.CustomAttribute) error } return nil } + +func removeAttributeFromPreChatForms(app *App, attributeID int) error { + inboxes, err := app.inbox.GetAll() + if err != nil { + return err + } + for _, inb := range inboxes { + if inb.Channel != livechat.ChannelLiveChat { + continue + } + config, changed, err := stripPreChatFormAttribute(inb.Config, attributeID) + if err != nil { + app.lo.Error("error parsing live chat config", "id", inb.ID, "error", err) + return envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil) + } + if !changed { + continue + } + if err := app.inbox.UpdateConfig(inb.ID, config); err != nil { + return envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil) + } + if err := reloadInbox(app, inb.ID); err != nil { + app.lo.Error("error reloading inbox", "id", inb.ID, "error", err) + } + } + return nil +} + +func stripPreChatFormAttribute(raw json.RawMessage, attributeID int) (json.RawMessage, bool, error) { + var config map[string]any + if len(raw) == 0 { + return raw, false, nil + } + if err := json.Unmarshal(raw, &config); err != nil { + return raw, false, err + } + form, ok := config["prechat_form"].(map[string]any) + if !ok { + return raw, false, nil + } + var changed bool + for _, holder := range []map[string]any{form, asObject(form["visitors"]), asObject(form["users"])} { + if holder == nil { + continue + } + fields, ok := holder["fields"].([]any) + if !ok { + continue + } + kept := make([]any, 0, len(fields)) + for _, field := range fields { + if attributeIDOf(field) == attributeID { + changed = true + continue + } + kept = append(kept, field) + } + holder["fields"] = kept + } + if !changed { + return raw, false, nil + } + updated, err := json.Marshal(config) + if err != nil { + return raw, false, err + } + return updated, true, nil +} + +func asObject(value any) map[string]any { + object, _ := value.(map[string]any) + return object +} + +func attributeIDOf(field any) int { + object, ok := field.(map[string]any) + if !ok { + return 0 + } + id, ok := object["custom_attribute_id"].(float64) + if !ok { + return 0 + } + return int(id) +} diff --git a/cmd/custom_attributes_test.go b/cmd/custom_attributes_test.go new file mode 100644 index 00000000..5a5981e9 --- /dev/null +++ b/cmd/custom_attributes_test.go @@ -0,0 +1,60 @@ +package main + +import ( + "encoding/json" + "testing" +) + +func TestStripPreChatFormAttribute(t *testing.T) { + raw := json.RawMessage(`{ + "brand_name":"Acme", + "prechat_form":{ + "fields":[{"key":"name"},{"key":"plan","custom_attribute_id":7}], + "visitors":{"fields":[{"key":"plan","custom_attribute_id":7},{"key":"email"}]}, + "users":{"fields":[{"key":"email"}]} + } + }`) + + updated, changed, err := stripPreChatFormAttribute(raw, 7) + if err != nil { + t.Fatal(err) + } + if !changed { + t.Fatal("expected config to change") + } + + var config struct { + BrandName string `json:"brand_name"` + PreChatForm struct { + Fields []map[string]any `json:"fields"` + Visitors struct { + Fields []map[string]any `json:"fields"` + } `json:"visitors"` + Users struct { + Fields []map[string]any `json:"fields"` + } `json:"users"` + } `json:"prechat_form"` + } + if err := json.Unmarshal(updated, &config); err != nil { + t.Fatal(err) + } + if config.BrandName != "Acme" { + t.Fatalf("brand name = %q", config.BrandName) + } + if len(config.PreChatForm.Fields) != 1 || config.PreChatForm.Fields[0]["key"] != "name" { + t.Fatalf("fields = %v", config.PreChatForm.Fields) + } + if len(config.PreChatForm.Visitors.Fields) != 1 || config.PreChatForm.Visitors.Fields[0]["key"] != "email" { + t.Fatalf("visitor fields = %v", config.PreChatForm.Visitors.Fields) + } + if len(config.PreChatForm.Users.Fields) != 1 { + t.Fatalf("user fields = %v", config.PreChatForm.Users.Fields) + } + + if _, changed, err := stripPreChatFormAttribute(raw, 8); err != nil || changed { + t.Fatalf("unused attribute changed = %v, err = %v", changed, err) + } + if _, changed, err := stripPreChatFormAttribute(json.RawMessage(`{}`), 7); err != nil || changed { + t.Fatalf("config without a pre-chat form changed = %v, err = %v", changed, err) + } +} diff --git a/frontend/apps/main/src/features/admin/inbox/LivechatInboxForm.vue b/frontend/apps/main/src/features/admin/inbox/LivechatInboxForm.vue index a9982a92..147c23e1 100644 --- a/frontend/apps/main/src/features/admin/inbox/LivechatInboxForm.vue +++ b/frontend/apps/main/src/features/admin/inbox/LivechatInboxForm.vue @@ -1735,7 +1735,7 @@ const submitLabel = computed(() => { }) const theme = computed(() => form.values.config?.theme || 'light') -const editingTheme = ref('light') +const editingTheme = inject('livechatPreviewTheme', ref('light')) const brandingPath = computed(() => `config.branding.${editingTheme.value}`) const activeBranding = computed(() => form.values.config?.branding?.[editingTheme.value]) const activeBackgroundType = computed(() => activeBranding.value?.home_screen?.background?.type) @@ -1807,13 +1807,12 @@ const updateCampaignCooldown = (duration) => form.setFieldValue('config.campaign_cooldown', duration, hasCampaignErrors.value) // home_apps in form.values only syncs on change events, so pull the live ref for the preview. -const previewTheme = inject('livechatPreviewTheme', ref('light')) const previewConfig = computed(() => { - const branding = form.values.config?.branding?.[previewTheme.value] || {} + const branding = form.values.config?.branding?.[editingTheme.value] || {} return { ...form.values.config, ...branding, - dark: previewTheme.value === 'dark', + dark: editingTheme.value === 'dark', launcher: { ...form.values.config?.launcher, ...branding.launcher }, home_apps: homeApps.value, help_tree: helpTree.value, diff --git a/frontend/apps/widget/src/store/help.js b/frontend/apps/widget/src/store/help.js index 72587c25..4b698c86 100644 --- a/frontend/apps/widget/src/store/help.js +++ b/frontend/apps/widget/src/store/help.js @@ -28,13 +28,13 @@ export const useHelpStore = defineStore('help', () => { return collect(data.value?.tree) }) const available = computed(() => !!data.value && articles.value.length > 0) - const featured = computed(() => - data.value?.featured_ids?.length - ? data.value.featured_ids - .map((id) => articles.value.find((article) => article.id === id)) - .filter(Boolean) - : data.value?.popular || [] - ) + const featured = computed(() => { + const picked = (data.value?.featured_ids || []) + .map((id) => articles.value.find((article) => article.id === id)) + .filter(Boolean) + // Featured articles outside the visitor's locale are missing from the tree. + return picked.length ? picked : data.value?.popular || [] + }) const reset = () => { generation++ identity.value++ diff --git a/frontend/cypress/e2e/ui/livechatFormBranches.cy.js b/frontend/cypress/e2e/ui/livechatFormBranches.cy.js new file mode 100644 index 00000000..49b31f75 --- /dev/null +++ b/frontend/cypress/e2e/ui/livechatFormBranches.cy.js @@ -0,0 +1,611 @@ +// The steps run in order and share the inbox created by the first one. + +const stamp = Date.now() +const inboxName = `Cypress Branches ${stamp}` +const brandName = `Cypress Branches Brand ${stamp}` +const emailInboxName = `Cypress Branch Email ${stamp}` +const attributeName = `Cypress Branch Attr ${stamp}` +const attributeKey = `cypress_branch_attr_${stamp}` +const campaignName = `Cypress Branch Campaign ${stamp}` +const listPath = '/admin/inboxes' + +const selectOption = (trigger, option) => { + cy.get(trigger).click() + cy.get('[role="option"]').contains(option).click() + // The listbox keeps a focus guard over the form until it has finished closing. + cy.get('[role="option"]').should('not.exist') +} + +const editInbox = (inboxId) => cy.visit(`${listPath}/${inboxId}/edit`) + +const pickFromLabelledSelect = (label, optionIndex) => { + cy.contains('label', label).parent().find('button').click() + cy.get('[role="option"]').eq(optionIndex).click() + cy.get('[role="option"]').should('not.exist') +} + +const linkEmailInbox = (name) => { + cy.inboxSection('Conversation continuity email inbox').find('button[role="combobox"]').click() + cy.get('[role="option"]').contains(name).click() + cy.get('[role="option"]').should('not.exist') +} + +// The accordion's open animation makes ':visible' flaky, so these scope by section instead. +const audienceEnabledSwitch = () => + cy + .inboxSection('Pre-chat form') + .contains('p', 'Show form to this audience') + .parent() + .find('button[role="switch"]') + +const enablePreChatForm = () => + cy.switchField('Enable pre-chat form').then(($switch) => { + if ($switch.attr('data-state') !== 'checked') cy.wrap($switch).click() + }) + +const savedConfig = (alias) => cy.wait(alias).then(({ response }) => response.body.data.config) + +describe('Live chat inbox form: branches the other specs skip', () => { + let inboxId + let emailInboxId + let attributeId + + before(() => { + cy.login() + cy.api('POST', '/api/v1/custom-attributes', { + name: attributeName, + key: attributeKey, + description: 'Cypress branch coverage', + applies_to: 'contact', + data_type: 'text', + values: [], + regex: '', + regex_hint: '' + }).then(({ body }) => { + attributeId = body.data.id + }) + cy.api('POST', '/api/v1/inboxes', { + name: emailInboxName, + channel: 'email', + enabled: true, + from: `Branch ${stamp} `, + config: { + auth_type: 'password', + from: `Branch ${stamp} `, + smtp: [ + { + host: '127.0.0.1', + port: Number(Cypress.env('SMTP_PORT') || 1025), + username: '', + password: '', + auth_protocol: 'none', + tls_type: 'none', + max_conns: 2, + max_msg_retries: 1, + idle_timeout: '5s', + pool_wait_timeout: '5s' + } + ], + imap: [] + } + }).then(({ body }) => { + emailInboxId = body.data.id + }) + }) + + beforeEach(() => { + // radix-vue's select focuses a node that is already gone in headless runs. + Cypress.on('uncaught:exception', (err) => !err.message.includes("reading 'focus'")) + cy.viewport(1280, 900) + cy.login() + }) + + after(() => { + if (inboxId) cy.api('DELETE', `/api/v1/inboxes/${inboxId}`, null, { failOnStatusCode: false }) + if (emailInboxId) { + cy.api('DELETE', `/api/v1/inboxes/${emailInboxId}`, null, { failOnStatusCode: false }) + } + if (attributeId) { + cy.api('DELETE', `/api/v1/custom-attributes/${attributeId}`, null, { + failOnStatusCode: false + }) + } + }) + + it('creates the inbox the other steps edit', () => { + cy.intercept('POST', '**/api/v1/inboxes').as('createInbox') + + cy.visit(`${listPath}/new`) + cy.contains('Create a live chat inbox').click() + cy.openInboxSection('General') + cy.get('input[name="name"]').type(inboxName) + cy.get('input[name="config.brand_name"]').type(brandName) + cy.get('button[type="submit"]').click() + + cy.wait('@createInbox').then(({ response }) => { + expect(response.statusCode).to.eq(200) + inboxId = response.body.data.id + }) + }) + + it('rejects an image background with no url, then saves one', () => { + cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox') + const imageUrl = `https://cypress.test/${stamp}/background.jpg` + + editInbox(inboxId) + cy.openInboxSection('Branding') + cy.get('#bg-image').click() + cy.get('button[type="submit"]').click() + cy.get('@updateInbox.all').should('have.length', 0) + cy.inboxSection('Branding').should('have.attr', 'data-state', 'open') + + cy.get('input[name="config.branding.light.home_screen.background.image_url"]').type(imageUrl) + cy.get('button[type="submit"]').click() + savedConfig('@updateInbox').then((config) => { + expect(config.branding.light.home_screen.background.type).to.eq('image') + expect(config.branding.light.home_screen.background.image_url).to.eq(imageUrl) + }) + }) + + it('rejects an enabled notice banner with no text', () => { + cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox') + + editInbox(inboxId) + cy.openInboxSection('Notice banner') + cy.switchField('Enable notice banner').click() + cy.get('textarea[name="config.notice_banner.text"]').clear() + cy.get('button[type="submit"]').click() + + cy.get('@updateInbox.all').should('have.length', 0) + cy.inboxSection('Notice banner').should('have.attr', 'data-state', 'open') + }) + + it('requires continuity values once an email inbox is linked', () => { + cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox') + + editInbox(inboxId) + cy.openInboxSection('Conversation continuity email inbox') + linkEmailInbox(emailInboxName) + cy.get('input[name="config.continuity.offline_threshold"]').clear() + cy.get('button[type="submit"]').click() + cy.get('@updateInbox.all').should('have.length', 0) + + cy.get('input[name="config.continuity.offline_threshold"]').type('20m') + cy.get('input[name="config.continuity.min_email_interval"]').clear() + cy.get('input[name="config.continuity.min_email_interval"]').type('30m') + cy.get('input[name="config.continuity.max_messages_per_email"]').clear() + cy.get('input[name="config.continuity.max_messages_per_email"]').type('5') + cy.get('button[type="submit"]').click() + savedConfig('@updateInbox').then((config) => { + expect(config.continuity.offline_threshold).to.eq('20m') + expect(config.continuity.min_email_interval).to.eq('30m') + expect(config.continuity.max_messages_per_email).to.eq(5) + }) + }) + + it('rejects a continuity message count outside its range', () => { + cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox') + + editInbox(inboxId) + cy.openInboxSection('Conversation continuity email inbox') + cy.get('input[name="config.continuity.max_messages_per_email"]').clear() + cy.get('input[name="config.continuity.max_messages_per_email"]').type('200') + cy.get('button[type="submit"]').click() + + cy.get('@updateInbox.all').should('have.length', 0) + cy.inboxSection('Conversation continuity email inbox').should( + 'have.attr', + 'data-state', + 'open' + ) + }) + + it('saves the launch-directly toggle separately for each audience', () => { + cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox') + + editInbox(inboxId) + cy.openInboxSection('Pre-chat form') + enablePreChatForm() + + cy.openInboxSection('Users') + cy.switchFieldIn('Users', 'Launch directly into conversation').click() + + cy.get('button[type="submit"]').click() + savedConfig('@updateInbox').then((config) => { + expect(config.prechat_form.enabled, 'pre-chat on').to.eq(true) + expect(config.visitors.direct_to_conversation, 'visitors launch direct').to.eq(true) + expect(config.users.direct_to_conversation, 'users launch direct').to.eq(false) + }) + }) + + it('adds a pre-chat custom attribute field and removes it again', () => { + cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox') + + editInbox(inboxId) + cy.openInboxSection('Pre-chat form') + enablePreChatForm() + cy.inboxSection('Pre-chat form').contains('div.font-medium', attributeName).click() + cy.get('button[type="submit"]').click() + savedConfig('@updateInbox').then((config) => { + const keys = config.prechat_form.visitors.fields.map((field) => field.key) + expect(keys).to.include(attributeKey) + }) + + editInbox(inboxId) + cy.openInboxSection('Pre-chat form') + cy.inboxSection('Pre-chat form') + .contains('div.font-medium', attributeName) + .closest('div.border') + .find('button[aria-label="Remove"]') + .click() + cy.get('button[type="submit"]').click() + savedConfig('@updateInbox').then((config) => { + const keys = config.prechat_form.visitors.fields.map((field) => field.key) + expect(keys).to.not.include(attributeKey) + }) + }) + + it('rejects a business-hours-limited proactive message with no schedule picked', () => { + cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox') + + editInbox(inboxId) + cy.openInboxSection('Proactive messages') + cy.contains('button', 'Add proactive message').click() + cy.get('#campaign-name').type(campaignName) + cy.get('#campaign-message').type('Need a hand?') + selectOption('#campaign-business_hours', 'During business hours') + cy.get('button[type="submit"]').click() + + cy.get('@updateInbox.all').should('have.length', 0) + cy.get('#campaign-hours').should('be.visible') + + selectOption('#campaign-business_hours', 'Any time') + cy.get('button[type="submit"]').click() + savedConfig('@updateInbox').then((config) => { + expect(config.campaigns).to.have.length(1) + expect(config.campaigns[0].business_hours).to.eq('any') + }) + }) + + it('saves a proactive message condition and removes it again', () => { + cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox') + + editInbox(inboxId) + cy.openInboxSection('Proactive messages') + cy.contains('button', campaignName).click() + cy.contains('button', 'Add condition').click() + cy.contains('button[role="combobox"]', 'Select field').click() + cy.get('[role="option"]').contains(attributeName).click() + cy.contains('button[role="combobox"]', 'Select operator').click() + cy.get('[role="option"]').contains('equals').click() + cy.get('input[placeholder="Set value"]').type('gold') + cy.inboxSection('Proactive messages').find('[role="radio"][value="OR"]').click() + cy.get('button[type="submit"]').click() + + savedConfig('@updateInbox').then((config) => { + const conditions = config.campaigns[0].conditions + expect(conditions.logical_op).to.eq('OR') + expect(conditions.rules).to.have.length(1) + expect(conditions.rules[0].field).to.eq(attributeKey) + expect(conditions.rules[0].value).to.eq('gold') + }) + + editInbox(inboxId) + cy.openInboxSection('Proactive messages') + cy.contains('button', campaignName).click() + cy.inboxSection('Proactive messages').contains('button[role="combobox"]', attributeName) + cy.inboxSection('Proactive messages').find('button[aria-label="Close"]').first().click() + cy.get('button[type="submit"]').click() + savedConfig('@updateInbox').then((config) => { + expect(config.campaigns[0].conditions.rules).to.have.length(0) + }) + }) + + it('saves a different pre-chat form for visitors and for users', () => { + cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox') + + editInbox(inboxId) + cy.openInboxSection('Pre-chat form') + enablePreChatForm() + + cy.audienceTab('Pre-chat form', 'Visitors').click() + audienceEnabledSwitch().should('have.attr', 'data-state', 'checked') + cy.inboxSection('Pre-chat form') + .find('input[placeholder="Tell us about yourself"]') + .clear() + cy.inboxSection('Pre-chat form') + .find('input[placeholder="Tell us about yourself"]') + .type('Visitor questions') + + cy.audienceTab('Pre-chat form', 'Users').click() + audienceEnabledSwitch().then(($switch) => { + if ($switch.attr('data-state') !== 'checked') cy.wrap($switch).click() + }) + cy.inboxSection('Pre-chat form') + .find('input[placeholder="Tell us about yourself"]') + .clear() + cy.inboxSection('Pre-chat form') + .find('input[placeholder="Tell us about yourself"]') + .type('Signed-in questions') + + cy.get('button[type="submit"]').click() + savedConfig('@updateInbox').then((config) => { + expect(config.prechat_form.visitors.enabled, 'visitors on').to.eq(true) + expect(config.prechat_form.users.enabled, 'users on').to.eq(true) + expect(config.prechat_form.visitors.title).to.eq('Visitor questions') + expect(config.prechat_form.users.title).to.eq('Signed-in questions') + }) + + editInbox(inboxId) + cy.openInboxSection('Pre-chat form') + cy.inboxSection('Pre-chat form') + .find('input[placeholder="Tell us about yourself"]') + .should('have.value', 'Visitor questions') + cy.audienceTab('Pre-chat form', 'Users').click() + cy.inboxSection('Pre-chat form') + .find('input[placeholder="Tell us about yourself"]') + .should('have.value', 'Signed-in questions') + }) + + it('turns the pre-chat form off for visitors while it stays on for users', () => { + cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox') + + editInbox(inboxId) + cy.openInboxSection('Pre-chat form') + cy.audienceTab('Pre-chat form', 'Visitors').click() + audienceEnabledSwitch().click() + cy.get('button[type="submit"]').click() + + savedConfig('@updateInbox').then((config) => { + expect(config.prechat_form.enabled, 'form still on').to.eq(true) + expect(config.prechat_form.visitors.enabled, 'visitors off').to.eq(false) + expect(config.prechat_form.users.enabled, 'users on').to.eq(true) + }) + + editInbox(inboxId) + cy.openInboxSection('Pre-chat form') + cy.audienceTab('Pre-chat form', 'Visitors').click() + audienceEnabledSwitch().should('have.attr', 'data-state', 'unchecked') + cy.audienceTab('Pre-chat form', 'Users').click() + audienceEnabledSwitch().should('have.attr', 'data-state', 'checked') + }) + + it('keeps the pre-chat fields of one audience out of the other', () => { + cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox') + + editInbox(inboxId) + cy.openInboxSection('Pre-chat form') + cy.audienceTab('Pre-chat form', 'Users').click() + cy.inboxSection('Pre-chat form').contains('div.font-medium', attributeName).click() + cy.get('button[type="submit"]').click() + + savedConfig('@updateInbox').then((config) => { + const userKeys = config.prechat_form.users.fields.map((field) => field.key) + const visitorKeys = config.prechat_form.visitors.fields.map((field) => field.key) + expect(userKeys, 'users carry the attribute').to.include(attributeKey) + expect(visitorKeys, 'visitors do not').to.not.include(attributeKey) + }) + }) + + it('saves branding for each theme and keeps the halves apart', () => { + cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox') + + editInbox(inboxId) + cy.openInboxSection('Theme') + cy.get('#theme-system').click() + + cy.openInboxSection('Branding') + cy.editBrandingTheme('Light') + cy.get('#bg-solid').click() + cy.get('input[name="config.branding.light.colors.primary"]').invoke( + 'val', + '#0f766e' + ).trigger('input') + cy.get('#text-black').click() + + cy.editBrandingTheme('Dark') + cy.get('#bg-solid').click() + cy.get('input[name="config.branding.dark.colors.primary"]').invoke( + 'val', + '#f59e0b' + ).trigger('input') + cy.get('#text-white').click() + + cy.get('button[type="submit"]').click() + savedConfig('@updateInbox').then((config) => { + expect(config.theme).to.eq('system') + expect(config.branding.light.colors.primary).to.eq('#0f766e') + expect(config.branding.dark.colors.primary).to.eq('#f59e0b') + expect(config.branding.light.home_screen.header_text_color).to.eq('black') + expect(config.branding.dark.home_screen.header_text_color).to.eq('white') + }) + + editInbox(inboxId) + cy.openInboxSection('Branding') + cy.editBrandingTheme('Light') + cy.get('input[name="config.branding.light.colors.primary"]').should('have.value', '#0f766e') + cy.editBrandingTheme('Dark') + cy.get('input[name="config.branding.dark.colors.primary"]').should('have.value', '#f59e0b') + }) + + it('edits the dark half when the theme is forced to dark', () => { + cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox') + + editInbox(inboxId) + cy.openInboxSection('Theme') + cy.get('#theme-dark').click() + + cy.openInboxSection('Branding') + cy.inboxSection('Branding') + .find('[role="tab"][data-state="active"]') + .should('contain.text', 'Dark') + cy.get('input[name="config.branding.dark.logo_url"]').clear() + cy.get('input[name="config.branding.dark.logo_url"]').type('https://cypress.test/dark.png') + + cy.get('button[type="submit"]').click() + savedConfig('@updateInbox').then((config) => { + expect(config.theme).to.eq('dark') + expect(config.branding.dark.logo_url).to.eq('https://cypress.test/dark.png') + expect(config.branding.light.colors.primary, 'light half untouched').to.eq('#0f766e') + }) + + editInbox(inboxId) + cy.openInboxSection('Theme') + cy.get('#theme-light').click() + cy.openInboxSection('Branding') + cy.inboxSection('Branding') + .find('[role="tab"][data-state="active"]') + .should('contain.text', 'Light') + cy.get('button[type="submit"]').click() + savedConfig('@updateInbox').then((config) => { + expect(config.theme).to.eq('light') + expect(config.branding.dark.logo_url, 'dark half kept').to.eq('https://cypress.test/dark.png') + }) + }) + + it('saves both gradient colours of the home screen background', () => { + cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox') + + editInbox(inboxId) + cy.openInboxSection('Branding') + cy.editBrandingTheme('Light') + cy.get('#bg-gradient').click() + cy.get('input[name="config.branding.light.home_screen.background.gradient_start"]') + .invoke('val', '#0f766e') + .trigger('input') + cy.get('input[name="config.branding.light.home_screen.background.gradient_end"]') + .invoke('val', '#99f6e4') + .trigger('input') + cy.switchFieldIn('Branding', 'Fade background').click() + + cy.get('button[type="submit"]').click() + savedConfig('@updateInbox').then((config) => { + const background = config.branding.light.home_screen.background + expect(background.type).to.eq('gradient') + expect(background.gradient_start).to.eq('#0f766e') + expect(background.gradient_end).to.eq('#99f6e4') + expect(config.branding.light.home_screen.fade_background).to.eq(true) + }) + + editInbox(inboxId) + cy.openInboxSection('Branding') + cy.editBrandingTheme('Light') + cy.get('input[name="config.branding.light.home_screen.background.gradient_start"]').should( + 'have.value', + '#0f766e' + ) + cy.get('input[name="config.branding.light.home_screen.background.gradient_end"]').should( + 'have.value', + '#99f6e4' + ) + }) + + it('saves the proactive message sender, team, audience and cooldown', () => { + cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox') + + editInbox(inboxId) + cy.openInboxSection('Proactive messages') + cy.get('#campaign-cooldown').clear() + cy.get('#campaign-cooldown').type('8h') + cy.contains('button', campaignName).click() + + // SelectAgentCombobox and SelectTeamCombobox drop the id they are given. + pickFromLabelledSelect('Sender', 1) + pickFromLabelledSelect('Route replies to team', 1) + selectOption('#campaign-audience', 'Signed-in users') + + cy.get('button[type="submit"]').click() + savedConfig('@updateInbox').then((config) => { + expect(config.campaign_cooldown).to.eq('8h') + expect(config.campaigns[0].sender_id, 'sender picked').to.be.greaterThan(0) + expect(config.campaigns[0].team_id, 'team picked').to.be.greaterThan(0) + expect(config.campaigns[0].audience).to.eq('users') + }) + + editInbox(inboxId) + cy.openInboxSection('Proactive messages') + cy.get('#campaign-cooldown').should('have.value', '8h') + cy.contains('button', campaignName).click() + cy.get('#campaign-audience').should('contain.text', 'Signed-in users') + }) + + it('rejects a campaign cooldown that is not a duration', () => { + cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox') + + editInbox(inboxId) + cy.openInboxSection('Proactive messages') + cy.get('#campaign-cooldown').clear() + cy.get('#campaign-cooldown').type('soon') + cy.get('button[type="submit"]').click() + + cy.get('@updateInbox.all').should('have.length', 0) + cy.inboxSection('Proactive messages').should('have.attr', 'data-state', 'open') + }) + + it('duplicates a proactive message', () => { + cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox') + + editInbox(inboxId) + cy.openInboxSection('Proactive messages') + cy.contains('button', campaignName).click() + cy.contains('button', 'Duplicate').click() + cy.get('button[type="submit"]').click() + + savedConfig('@updateInbox').then((config) => { + expect(config.campaigns).to.have.length(2) + const ids = config.campaigns.map((campaign) => campaign.id) + expect(new Set(ids).size, 'each copy keeps its own id').to.eq(2) + }) + }) + + it('rejects a home screen app with a malformed link', () => { + cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox') + + editInbox(inboxId) + cy.openInboxSection('Home screen apps') + cy.contains('button', 'Add external link').click() + cy.get('[data-section="homeApps"] input[placeholder="Link Text"]').type('Status') + cy.get('[data-section="homeApps"] input[placeholder="https://example.com"]').type('not-a-url') + cy.get('button[type="submit"]').click() + + cy.get('@updateInbox.all').should('have.length', 0) + cy.inboxSection('Home screen apps').should('have.attr', 'data-state', 'open') + }) + + it('rejects a quick reply longer than the limit', () => { + cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox') + + editInbox(inboxId) + cy.openInboxSection('Users') + cy.get('textarea[name="config.visitors.quick_replies"]').clear() + cy.get('textarea[name="config.visitors.quick_replies"]').type('x'.repeat(121), { delay: 0 }) + cy.get('button[type="submit"]').click() + + cy.get('@updateInbox.all').should('have.length', 0) + cy.inboxSection('Users').should('have.attr', 'data-state', 'open') + }) + + it('saves the auto language option', () => { + cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox') + + editInbox(inboxId) + cy.openInboxSection('General') + cy.inboxSection('General').contains('label', 'Language').parent().find('button').click() + cy.get('[role="option"]').contains('Auto').click() + cy.get('[role="option"]').should('not.exist') + cy.get('button[type="submit"]').click() + + savedConfig('@updateInbox').then((config) => { + expect(config.language).to.eq('auto') + }) + }) + + it('keeps the help articles card out until a help center is linked', () => { + editInbox(inboxId) + cy.openInboxSection('Home screen apps') + cy.contains('button', 'Add help articles').should('be.disabled') + cy.contains('Choose a help center under Help center before adding this card.').should( + 'be.visible' + ) + }) +}) diff --git a/frontend/cypress/e2e/ui/livechatFormLayout.cy.js b/frontend/cypress/e2e/ui/livechatFormLayout.cy.js index 5b5843f8..78b6452f 100644 --- a/frontend/cypress/e2e/ui/livechatFormLayout.cy.js +++ b/frontend/cypress/e2e/ui/livechatFormLayout.cy.js @@ -173,16 +173,18 @@ describe('Live chat inbox form layout', () => { }) }) - it('renders the preview with its own theme toggle', () => { + it('keeps the preview and the branding tabs on the same theme', () => { cy.visit(`${listPath}/${inboxId}/edit`) cy.get('[data-preview-theme="light"]').should('be.visible') cy.openInboxSection('Branding') cy.editBrandingTheme('Dark') - - cy.get('[data-preview-theme="light"]').should('have.attr', 'data-state', 'active') - cy.get('[data-preview-theme="dark"]').click() cy.get('[data-preview-theme="dark"]').should('have.attr', 'data-state', 'active') + + cy.get('[data-preview-theme="light"]').click() + cy.inboxSection('Branding') + .find('[role="tab"][data-state="active"]') + .should('contain.text', 'Light') }) it('rejects more than six quick replies', () => { diff --git a/frontend/cypress/e2e/ui/livechatInboxForm.cy.js b/frontend/cypress/e2e/ui/livechatInboxForm.cy.js index 48bba2e6..5968d8f3 100644 --- a/frontend/cypress/e2e/ui/livechatInboxForm.cy.js +++ b/frontend/cypress/e2e/ui/livechatInboxForm.cy.js @@ -2,7 +2,6 @@ const stamp = Date.now() const inboxName = `Cypress Livechat ${stamp}` -const renamedInbox = `Cypress Livechat ${stamp} edited` const brandName = `Cypress Brand ${stamp}` const websiteUrl = `https://cypress.test/${stamp}` const greeting = `Hello from Cypress ${stamp}` @@ -12,7 +11,6 @@ const editedBrandName = `Cypress Brand ${stamp} edited` const homeAppTitle = `Cypress Announcement ${stamp}` const editedHomeAppTitle = `Cypress Announcement ${stamp} edited` const secret = `${stamp}`.padEnd(32, 'c').slice(0, 32) -const maskedSecret = '••••••••••' const newPath = '/admin/inboxes/new' const listPath = '/admin/inboxes' @@ -71,52 +69,6 @@ describe('Live chat inbox form', () => { cy.contains(inboxName).should('exist') }) - it('loads the saved values back into every tab of the edit form', () => { - expect(inboxId, 'inbox from the create step').to.be.a('number') - - cy.visit(`${listPath}/${inboxId}/edit`) - cy.openInboxSection('General') - - cy.get('input[name="name"]').should('have.value', inboxName) - cy.get('input[name="config.brand_name"]').should('have.value', brandName) - cy.get('input[name="config.website_url"]').should('have.value', websiteUrl) - - cy.openInboxSection('Launcher position') - cy.get('input[name="config.launcher.spacing.side"]').should('have.value', '35') - cy.get('input[name="config.launcher.spacing.bottom"]').should('have.value', '45') - cy.get('select[name="config.launcher.position"]').should('have.value', 'left') - - cy.openInboxSection('Messages') - cy.get('textarea[name="config.greeting_message"]').should('have.value', greeting) - cy.get('textarea[name="config.chat_introduction"]').should('have.value', chatIntroduction) - - cy.openInboxSection('Security') - cy.get('input[name="config.session_duration"]').should('have.value', '4h') - cy.get('textarea[name="config.trusted_domains"]').should('have.value', trustedDomain) - // The signing secret comes back masked, never as the real value. - cy.get('input[name="secret"]').should('have.value', maskedSecret) - }) - - it('persists a changed name and greeting message', () => { - cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox') - - cy.visit(`${listPath}/${inboxId}/edit`) - cy.openInboxSection('General') - cy.get('input[name="name"]').should('have.value', inboxName).clear().type(renamedInbox) - - cy.openInboxSection('Messages') - cy.get('textarea[name="config.greeting_message"]').clear().type(`${greeting} v2`) - - cy.get('button[type="submit"]').click() - cy.wait('@updateInbox').its('response.statusCode').should('eq', 200) - - cy.visit(`${listPath}/${inboxId}/edit`) - cy.openInboxSection('General') - cy.get('input[name="name"]').should('have.value', renamedInbox) - cy.openInboxSection('Messages') - cy.get('textarea[name="config.greeting_message"]').should('have.value', `${greeting} v2`) - }) - it('rejects a submit with no name and no brand name', () => { cy.intercept('POST', '**/api/v1/inboxes').as('createInbox') @@ -128,24 +80,6 @@ describe('Live chat inbox form', () => { cy.location('pathname').should('eq', newPath) }) - it('rejects a malformed website url and jumps back to its tab', () => { - cy.intercept('POST', '**/api/v1/inboxes').as('createInbox') - - openNewForm() - cy.get('input[name="name"]').type(inboxName) - cy.get('input[name="config.brand_name"]').type(brandName) - cy.get('input[name="config.website_url"]').type('not-a-url') - - cy.openInboxSection('Security') - cy.get('button[type="submit"]').click() - - cy.contains('Invalid URL').scrollIntoView().should('be.visible') - // The offending field is on another tab, so the form has to switch back to it. - cy.get('input[name="config.website_url"]').scrollIntoView().should('be.visible') - cy.get('[role="tab"][data-state="active"]').should('not.contain', 'Setup') - cy.get('@createInbox.all').should('have.length', 0) - }) - it('persists a brand name edit made alongside a home screen app edit', () => { cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox') @@ -178,12 +112,12 @@ describe('Live chat inbox form', () => { cy.intercept('DELETE', `**/api/v1/inboxes/${inboxId}`).as('deleteInbox') cy.visit(listPath) - filterList(renamedInbox) - cy.contains('tr', renamedInbox).find('button[aria-haspopup="menu"]').click() + filterList(inboxName) + cy.contains('tr', inboxName).find('button[aria-haspopup="menu"]').click() cy.get('[role="menuitem"]').contains('Delete').click() cy.get('[role="alertdialog"]').contains('button', 'Delete').click() cy.wait('@deleteInbox').its('response.statusCode').should('eq', 200) - cy.contains(renamedInbox).should('not.exist') + cy.contains(inboxName).should('not.exist') }) }) diff --git a/frontend/cypress/e2e/ui/livechatThemeBranding.cy.js b/frontend/cypress/e2e/ui/livechatThemeBranding.cy.js index 0a7e3ed9..8671ef7d 100644 --- a/frontend/cypress/e2e/ui/livechatThemeBranding.cy.js +++ b/frontend/cypress/e2e/ui/livechatThemeBranding.cy.js @@ -3,7 +3,6 @@ const stamp = Date.now() const inboxName = `Cypress Theme ${stamp}` const brandName = `Cypress Theme Brand ${stamp}` -const listPath = '/admin/inboxes' const newPath = '/admin/inboxes/new' const lightPrimary = '#123456' @@ -24,6 +23,10 @@ describe('Live chat theme and branding', () => { cy.login() }) + after(() => { + if (inboxId) cy.api('DELETE', `/api/v1/inboxes/${inboxId}`, null, { failOnStatusCode: false }) + }) + it('saves a separate primary color for each theme', () => { cy.intercept('POST', '**/api/v1/inboxes').as('createInbox') @@ -56,19 +59,6 @@ describe('Live chat theme and branding', () => { }) }) - it('loads each theme back into its own tab', () => { - expect(inboxId, 'inbox from the create step').to.be.a('number') - - cy.visit(`${listPath}/${inboxId}/edit`) - cy.openInboxSection('Branding') - - cy.editBrandingTheme('Light') - cy.get('input[name="config.branding.light.colors.primary"]').should('have.value', lightPrimary) - - cy.editBrandingTheme('Dark') - cy.get('input[name="config.branding.dark.colors.primary"]').should('have.value', darkPrimary) - }) - it('seeds a dark background when the theme is switched away from light', () => { cy.intercept('POST', '**/api/v1/inboxes').as('createSeeded') @@ -97,33 +87,4 @@ describe('Live chat theme and branding', () => { }) }) - it('rejects a launcher icon scale outside its range', () => { - cy.visit(`${listPath}/${inboxId}/edit`) - cy.openInboxSection('Launcher position') - - cy.get('input[name="config.launcher.icon_scale"]').clear().type('10') - cy.get('button[type="submit"]').click() - cy.contains('40').should('exist') - }) - - it('persists the launcher icon scale', () => { - cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox') - - cy.visit(`${listPath}/${inboxId}/edit`) - cy.openInboxSection('Launcher position') - cy.get('input[name="config.launcher.icon_scale"]').clear().type('60') - - cy.get('button[type="submit"]').click() - cy.wait('@updateInbox').then(({ response }) => { - expect(response.statusCode).to.eq(200) - expect(response.body.data.config.launcher.icon_scale, 'icon scale').to.eq(60) - }) - }) - - after(() => { - if (inboxId) { - cy.login() - cy.api('DELETE', `/api/v1/inboxes/${inboxId}`, null, { failOnStatusCode: false }) - } - }) }) diff --git a/frontend/cypress/e2e/ui/livechatWidgetTabs.cy.js b/frontend/cypress/e2e/ui/livechatWidgetTabs.cy.js index c2e4526c..df8aa185 100644 --- a/frontend/cypress/e2e/ui/livechatWidgetTabs.cy.js +++ b/frontend/cypress/e2e/ui/livechatWidgetTabs.cy.js @@ -129,9 +129,7 @@ describe('Live chat inbox form: proactive messages and help center tabs', () => cy.get('#campaign-exclude_urls').clear().type('/checkout/*') cy.get('#campaign-delay_seconds').type('{selectall}25') cy.get('#campaign-event').clear().type('pricing_viewed') - cy.contains( - 'Show this message only after your website tracks this event. Leave this field blank to show it without an event.' - ).should('be.visible') + cy.contains('Show this message only after your website tracks this event.').should('be.visible') cy.contains('code', 'window.Libredesk.trackEvent("pricing_viewed")').should('be.visible') selectOption('#campaign-repeat', 'After an interval') cy.get('#campaign-repeat_hours').type('{selectall}12') diff --git a/i18n/en-US.json b/i18n/en-US.json index cd24fda6..613e43f2 100644 --- a/i18n/en-US.json +++ b/i18n/en-US.json @@ -406,7 +406,7 @@ "admin.inbox.livechat.preview.sampleAgentReply": "Happy to help, could you share a few details?", "admin.inbox.livechat.preview.sampleUserMessage": "Hi! How do I get started?", "admin.inbox.livechat.quickReplies": "Quick replies", - "admin.inbox.livechat.quickReplies.description": "One reply per line, up to 6. Shown when someone starts a new conversation. Tapping one sends it as the first message.", + "admin.inbox.livechat.quickReplies.description": "One reply per line, up to 6. Shown when someone starts a new conversation.", "admin.inbox.livechat.quickReplies.limit": "Add up to 6 quick replies.", "admin.inbox.livechat.quickReplies.placeholder": "I have a question about billing\nHow do I reset my password?", "admin.inbox.livechat.secretKey": "Secret key", @@ -1275,7 +1275,7 @@ "helpCenter.styling.brand": "Brand", "helpCenter.styling.darkLogoUrl": "Logo URL (dark mode)", "helpCenter.styling.showThemeToggle": "Show light and dark mode toggle", - "helpCenter.styling.themeToggleHint": "Visitors start in light mode and can switch to dark. Turn this off to keep the help center in light mode.", + "helpCenter.styling.themeToggleHint": "Visitors start in light mode and can switch to dark.", "helpCenter.styling.customCode": "Custom code", "helpCenter.customJS": "Custom JS", "helpCenter.deleteArticleConfirmation": "This will permanently delete the article.", @@ -1328,7 +1328,7 @@ "helpCenter.translationsHint": "Write or link this article in other languages here. Readers who switch language land on the linked version, instead of the help center home page.", "helpCenter.linkExistingArticle": "Link an existing article", "helpCenter.linkExistingArticleHint": "Pick an article written in another language to join it with this one as a translation.", - "helpCenter.noLinkableArticles": "No articles in the other languages are free to link. Articles already linked as a translation are left out. Write the translation instead.", + "helpCenter.noLinkableArticles": "No articles in the other languages are free to link. Write the translation instead.", "helpCenter.articleAlreadyTranslated": "That article is already part of another set of translations. Unlink it there first.", "helpCenter.localeAlreadyTranslated": "This article already has a translation in that language.", "helpCenter.cannotLinkArticleToItself": "An article cannot be its own translation.", @@ -1704,9 +1704,9 @@ "widget.campaignContactAttributesEmpty": "Visibility conditions use contact custom attributes. None are available.", "widget.campaignCooldown": "Minimum time between proactive messages", "widget.campaignDeviceRequired": "Select at least one device. This proactive message cannot appear until desktop or mobile is enabled.", - "widget.campaignEventHint": "Show this message only after your website tracks this event. Leave this field blank to show it without an event.", + "widget.campaignEventHint": "Show this message only after your website tracks this event.", "widget.campaignLimitReached": "You have reached the limit of 50 proactive messages. Delete one to add another.", - "widget.campaignPriorityHint": "Messages are checked from top to bottom. The first matching message is shown.", + "widget.campaignPriorityHint": "Messages are checked from top to bottom. The first match is shown.", "widget.campaignsHint": "Show a message on matching pages before the chat is opened. A conversation is created only when someone replies to it.", "widget.campaignStats": "Proactive message results", "widget.campaignUrlLimit": "Add up to 20 page patterns.", @@ -1717,10 +1717,10 @@ "widget.featuredArticles": "Featured articles", "widget.featuredArticlesHint": "Choose up to 10 articles. Leave empty to show popular articles.", "widget.helpFeedbackError": "Feedback could not be saved. Try again.", - "widget.helpHomeAppHint": "Shows a search button and the featured articles set in the Help center tab.", - "widget.helpHomeAppRequiresCenter": "Choose a help center in the Help center tab before adding this card.", + "widget.helpHomeAppHint": "Shows a search button and the featured articles set under Help center.", + "widget.helpHomeAppRequiresCenter": "Choose a help center under Help center before adding this card.", "widget.helpLoadError": "Help could not be loaded. Try again or send us a message.", - "widget.helpOnHomeHint": "To show help on the Home screen, add the help articles card under Appearance, Home screen apps.", + "widget.helpOnHomeHint": "To show help on the Home screen, add the help articles card under Home screen apps.", "widget.helpPlacement": "Where help appears", "widget.invitationExpired": "This invitation is no longer available. Start a new chat to contact us.", "widget.ipBlocked": "Access denied", diff --git a/internal/inbox/channel/livechat/livechat.go b/internal/inbox/channel/livechat/livechat.go index e6b8e9de..0308deef 100644 --- a/internal/inbox/channel/livechat/livechat.go +++ b/internal/inbox/channel/livechat/livechat.go @@ -9,6 +9,7 @@ import ( "sync" "sync/atomic" + amodels "github.com/abhinavxd/libredesk/internal/automation/models" "github.com/abhinavxd/libredesk/internal/conversation/models" "github.com/abhinavxd/libredesk/internal/inbox" "github.com/abhinavxd/libredesk/internal/inbox/channel/livechat/proactive" @@ -33,6 +34,13 @@ const ( ThemeDark = "dark" DefaultLauncherIconScale = 100 + DefaultCampaignCooldown = "24h" + DefaultLauncherColor = "#000000" + BackgroundSolid = "solid" + // Matches the locale the widget falls back to when this is unset. + DefaultLanguage = "en-US" + // Matches the session TTL the chat handlers fall back to when this is unset. + DefaultSessionDuration = "4320h" ) type Colors struct { @@ -287,9 +295,82 @@ func (c *Config) UnmarshalJSON(data []byte) error { if c.Launcher.IconScale == 0 { c.Launcher.IconScale = DefaultLauncherIconScale } + if c.CampaignCooldown == "" { + c.CampaignCooldown = DefaultCampaignCooldown + } + if c.SessionDuration == "" { + c.SessionDuration = DefaultSessionDuration + } + if c.Language == "" { + c.Language = DefaultLanguage + } + if c.FallbackLanguage == "" { + c.FallbackLanguage = DefaultLanguage + } + fillBrandingDefaults(&c.Branding.Light, ThemeLight) + fillBrandingDefaults(&c.Branding.Dark, ThemeDark) + c.fillEmptyLists() return nil } +func fillBrandingDefaults(branding *Branding, theme string) { + if branding.Launcher.Color == "" { + branding.Launcher.Color = DefaultLauncherColor + } + if branding.HomeScreen.Background.Type == "" { + branding.HomeScreen.Background.Type = BackgroundSolid + } + if branding.HomeScreen.HeaderTextColor == "" { + if theme == ThemeDark { + branding.HomeScreen.HeaderTextColor = "white" + } else { + branding.HomeScreen.HeaderTextColor = "black" + } + } +} + +// A null list fails the admin form's validation. +func (c *Config) fillEmptyLists() { + if c.Campaigns == nil { + c.Campaigns = []proactive.Campaign{} + } + for i := range c.Campaigns { + if c.Campaigns[i].IncludeURLs == nil { + c.Campaigns[i].IncludeURLs = []string{} + } + if c.Campaigns[i].ExcludeURLs == nil { + c.Campaigns[i].ExcludeURLs = []string{} + } + if c.Campaigns[i].Conditions.Rules == nil { + c.Campaigns[i].Conditions.Rules = []amodels.RuleDetail{} + } + } + if c.Help.FeaturedIDs == nil { + c.Help.FeaturedIDs = []int{} + } + if c.HomeApps == nil { + c.HomeApps = []HomeApp{} + } + if c.TrustedDomains == nil { + c.TrustedDomains = []string{} + } + if c.BlockedIPs == nil { + c.BlockedIPs = []string{} + } + if c.QuickReplies == nil { + c.QuickReplies = []string{} + } + if c.Users.QuickReplies == nil { + c.Users.QuickReplies = []string{} + } + if c.Visitors.QuickReplies == nil { + c.Visitors.QuickReplies = []string{} + } + if c.PreChatForm.Fields == nil { + c.PreChatForm.Fields = []PreChatFormField{} + } +} + func (c Config) ResolvePreChatForm(isVisitor bool) Config { audience := c.PreChatForm.Users if isVisitor { diff --git a/internal/inbox/channel/livechat/livechat_test.go b/internal/inbox/channel/livechat/livechat_test.go index 2589945f..fd9a4147 100644 --- a/internal/inbox/channel/livechat/livechat_test.go +++ b/internal/inbox/channel/livechat/livechat_test.go @@ -138,3 +138,64 @@ func TestUnmarshalKeepsExplicitBranding(t *testing.T) { t.Fatalf("branding = %+v", config.Branding) } } + +func TestUnmarshalFillsEmptyListsAndCooldown(t *testing.T) { + var config Config + if err := json.Unmarshal([]byte(`{"brand_name":"Acme"}`), &config); err != nil { + t.Fatal(err) + } + + encoded, err := json.Marshal(config) + if err != nil { + t.Fatal(err) + } + var decoded map[string]any + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatal(err) + } + for _, key := range []string{"campaigns", "home_apps", "trusted_domains", "blocked_ips", "quick_replies"} { + if decoded[key] == nil { + t.Fatalf("%s encoded as null", key) + } + } + if config.Help.FeaturedIDs == nil || config.PreChatForm.Fields == nil { + t.Fatalf("help featured ids = %v, prechat fields = %v", config.Help.FeaturedIDs, config.PreChatForm.Fields) + } + if config.Users.QuickReplies == nil || config.Visitors.QuickReplies == nil { + t.Fatalf("audience quick replies = %v, %v", config.Users.QuickReplies, config.Visitors.QuickReplies) + } + if config.CampaignCooldown != DefaultCampaignCooldown { + t.Fatalf("campaign cooldown = %q", config.CampaignCooldown) + } +} + +func TestUnmarshalFillsValuesAConfigWrittenOutsideTheFormLacks(t *testing.T) { + var config Config + if err := json.Unmarshal([]byte(`{"brand_name":"Acme","colors":{"primary":"#112233"},"launcher":{"position":"right"}}`), &config); err != nil { + t.Fatal(err) + } + + if config.SessionDuration != DefaultSessionDuration { + t.Fatalf("session duration = %q", config.SessionDuration) + } + if config.Language != DefaultLanguage || config.FallbackLanguage != DefaultLanguage { + t.Fatalf("language = %q, fallback = %q", config.Language, config.FallbackLanguage) + } + for theme, branding := range map[string]Branding{ThemeLight: config.Branding.Light, ThemeDark: config.Branding.Dark} { + if branding.Launcher.Color != DefaultLauncherColor { + t.Fatalf("%s launcher color = %q", theme, branding.Launcher.Color) + } + if branding.HomeScreen.Background.Type != BackgroundSolid { + t.Fatalf("%s background type = %q", theme, branding.HomeScreen.Background.Type) + } + if branding.Colors.Primary != "#112233" { + t.Fatalf("%s primary = %q", theme, branding.Colors.Primary) + } + } + if config.Branding.Light.HomeScreen.HeaderTextColor != "black" { + t.Fatalf("light header text = %q", config.Branding.Light.HomeScreen.HeaderTextColor) + } + if config.Branding.Dark.HomeScreen.HeaderTextColor != "white" { + t.Fatalf("dark header text = %q", config.Branding.Dark.HomeScreen.HeaderTextColor) + } +}