feat: webhook inbox (#5)

* chore: exclude inbox_id from webhook update parameters

* feat: display inbox label in webhook settings

* feat: add inbox selection to webhook form

* feat: prevent updating inbox_id in webhook update

* feat: integrate MultiselectDropdown for inbox selection in webhook form

* feat: add inbox matching logic to webhook event delivery

* feat: remove unused inbox input placeholder from webhook form

* fix: MultiselectDropdown component submiting form

* feat: refine webhook parameters for create and update actions

* feat: disable URL input field when editing webhook

* chore: remove unnecessary parentheses

* chore: update webhook controller spec to ignore inbox_id and url updates

* fix: clean up JSON formatting

* fix: standardize inbox_id to inboxId in WebhookForm component

* refactor: replace LabelItem with InboxName component in WebhookRow

* chore: enhance MultiselectDropdown with button variant prop and update styling in WebhookForm

* chore: simplify MultiselectDropdown wrapper in WebhookForm component

* chore: update selectedInbox initialization to null and reorder form fields

* refactor: simplify InboxName

* chore: add dark variant styling for buttons in SCSS

* test: add inbox filtering for webhook event triggers in WebhookListener

* test: refactor webhook controller spec to use a variable for URL and improve update expectations

* feat(webhook): all inboxes option

* chore: remove dark variant styling for buttons in SCSS

* fix: bad interaction multiselectdropdown inside label

* chore: invert if

* chore: rename to assignedInbox and drop inboxId

* refactor(WebhookForm): restore div separating fields from buttons

* test: improve description

---------

Co-authored-by: gabrieljablonski <contact@gabrieljablonski.com>
This commit is contained in:
Cayo P. R. Oliveira 2025-02-28 18:27:14 -03:00 committed by gabrieljablonski
parent 52a55827c3
commit 083bc27b1a
10 changed files with 122 additions and 8 deletions

View File

@ -7,12 +7,12 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
end
def create
@webhook = Current.account.webhooks.new(webhook_params)
@webhook = Current.account.webhooks.new(webhook_create_params)
@webhook.save!
end
def update
@webhook.update!(webhook_params)
@webhook.update!(webhook_update_params)
end
def destroy
@ -22,10 +22,14 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
private
def webhook_params
def webhook_create_params
params.require(:webhook).permit(:inbox_id, :name, :url, subscriptions: [])
end
def webhook_update_params
params.require(:webhook).permit(:name, subscriptions: [])
end
def fetch_webhook
@webhook = Current.account.webhooks.find(params[:id])
end

View File

@ -48,6 +48,13 @@
"LABEL": "Webhook Name",
"PLACEHOLDER": "Enter the name of the webhook"
},
"INBOX": {
"LABEL": "Inbox",
"TITLE": "Select the inbox",
"PLACEHOLDER": "All Inboxes",
"NO_RESULTS": "No inboxes found",
"INPUT_PLACEHOLDER": "Search inbox"
},
"END_POINT": {
"LABEL": "Webhook URL",
"PLACEHOLDER": "Example: {webhookExampleURL}",

View File

@ -53,6 +53,7 @@ export default {
:value="value"
:is-submitting="uiFlags.updatingItem"
:submit-label="$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.EDIT_SUBMIT')"
is-editing
@submit="onSubmit"
@cancel="onClose"
/>

View File

@ -4,6 +4,8 @@ import { required, url, minLength } from '@vuelidate/validators';
import wootConstants from 'dashboard/constants/globals';
import { getI18nKey } from 'dashboard/routes/dashboard/settings/helper/settingsHelper';
import NextButton from 'dashboard/components-next/button/Button.vue';
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
import { useMapGetter } from 'dashboard/composables/store';
const { EXAMPLE_WEBHOOK_URL } = wootConstants;
@ -21,6 +23,7 @@ const SUPPORTED_WEBHOOK_EVENTS = [
export default {
components: {
NextButton,
MultiselectDropdown,
},
props: {
value: {
@ -35,10 +38,17 @@ export default {
type: String,
required: true,
},
isEditing: {
type: Boolean,
default: false,
},
},
emits: ['submit', 'cancel'],
setup() {
return { v$: useVuelidate() };
return {
v$: useVuelidate(),
inboxes: useMapGetter('inboxes/getInboxes'),
};
},
validations: {
url: {
@ -53,12 +63,27 @@ export default {
data() {
return {
url: this.value.url || '',
assignedInbox: this.value.inbox || null,
name: this.value.name || '',
subscriptions: this.value.subscriptions || [],
supportedWebhookEvents: SUPPORTED_WEBHOOK_EVENTS,
};
},
computed: {
inboxesList() {
if (this.assignedInbox?.id) {
return [
{
id: 0,
name: this.$t(
'INTEGRATION_SETTINGS.WEBHOOK.FORM.INBOX.PLACEHOLDER'
),
},
...this.inboxes,
];
}
return this.inboxes;
},
webhookURLInputPlaceholder() {
return this.$t(
'INTEGRATION_SETTINGS.WEBHOOK.FORM.END_POINT.PLACEHOLDER',
@ -75,10 +100,14 @@ export default {
onSubmit() {
this.$emit('submit', {
url: this.url,
inbox_id: this.assignedInbox?.id || null,
name: this.name,
subscriptions: this.subscriptions,
});
},
onClickAssignInbox(inbox) {
this.assignedInbox = inbox;
},
getI18nKey,
},
};
@ -93,6 +122,7 @@ export default {
v-model="url"
type="text"
name="url"
:disabled="isEditing"
:placeholder="webhookURLInputPlaceholder"
@input="v$.url.$touch"
/>
@ -100,6 +130,29 @@ export default {
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.END_POINT.ERROR') }}
</span>
</label>
<label>
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.INBOX.LABEL') }}
<div class="multiselect-wrap--small">
<MultiselectDropdown
:options="inboxesList"
:selected-item="assignedInbox"
:multiselector-title="
$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.INBOX.TITLE')
"
:multiselector-placeholder="
$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.INBOX.PLACEHOLDER')
"
:no-search-result="
$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.INBOX.NO_RESULTS')
"
:input-placeholder="
$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.INBOX.INPUT_PLACEHOLDER')
"
:disabled="isEditing"
@select="onClickAssignInbox"
/>
</div>
</label>
<label>
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.NAME.LABEL') }}
<input
@ -139,7 +192,6 @@ export default {
</div>
</div>
</div>
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<NextButton
faded

View File

@ -3,6 +3,7 @@ import { computed } from 'vue';
import { getI18nKey } from 'dashboard/routes/dashboard/settings/helper/settingsHelper';
import ShowMore from 'dashboard/components/widgets/ShowMore.vue';
import { useI18n } from 'vue-i18n';
import InboxName from 'components/widgets/InboxName.vue';
import Button from 'dashboard/components-next/button/Button.vue';
@ -37,6 +38,7 @@ const subscribedEvents = computed(() => {
<template>
<tr>
<td class="py-4 ltr:pr-4 rtl:pl-4">
<InboxName v-if="webhook.inbox" class="!mx-0" :inbox="webhook.inbox" />
<div
class="flex gap-2 font-medium break-words text-slate-700 dark:text-slate-100"
>

View File

@ -36,6 +36,10 @@ const props = defineProps({
type: String,
default: 'Search',
},
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select']);
@ -66,6 +70,8 @@ const hasValue = computed(() => {
showSearchDropdown ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'
"
class="w-full !px-2"
type="button"
:disabled="disabled"
@click="
() => toggleDropdown() // ensure that the event is not passed to the button
"
@ -93,6 +99,10 @@ const hasValue = computed(() => {
<div
:class="{ 'dropdown-pane--open': showSearchDropdown }"
class="dropdown-pane"
@click="
// NOTE: Without this, the dropdown does not behave as expected when used inside a <label> tag.
event => event.preventDefault()
"
>
<div class="flex items-center justify-between mb-1">
<h4

View File

@ -88,6 +88,7 @@ class WebhookListener < BaseListener
def deliver_account_webhooks(payload, account)
account.webhooks.account_type.each do |webhook|
next unless webhook.subscriptions.include?(payload[:event])
next if payload[:inbox].present? && webhook.inbox_id.present? && webhook.inbox_id != payload[:inbox][:id]
WebhookJob.perform_later(webhook.url, payload)
end

View File

@ -7,5 +7,6 @@ if webhook.inbox
json.inbox do
json.id webhook.inbox.id
json.name webhook.inbox.name
json.channel_type webhook.inbox.channel_type
end
end

View File

@ -1,9 +1,10 @@
require 'rails_helper'
RSpec.describe 'Webhooks API', type: :request do
let(:url) { 'https://hello.com' }
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:webhook) { create(:webhook, account: account, inbox: inbox, url: 'https://hello.com', name: 'My Webhook') }
let(:webhook) { create(:webhook, account: account, inbox: inbox, url: url, name: 'My Webhook') }
let(:administrator) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
@ -113,13 +114,24 @@ RSpec.describe 'Webhooks API', type: :request do
context 'when it is an authenticated admin user' do
it 'updates webhook' do
put "/api/v1/accounts/#{account.id}/webhooks/#{webhook.id}",
params: { url: 'https://hello.com', name: 'Another Webhook' },
params: { name: 'Another Webhook' },
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['payload']['webhook']['url']).to eql 'https://hello.com'
expect(response.parsed_body['payload']['webhook']['name']).to eql 'Another Webhook'
end
it 'ignores trying to update inbox_id and url' do
new_inbox = create(:inbox, account: account)
put "/api/v1/accounts/#{account.id}/webhooks/#{webhook.id}",
params: { url: 'https://other.url.com', inbox_id: new_inbox.id },
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['payload']['webhook']['url']).to eql url
expect(response.parsed_body['payload']['webhook']['inbox']['id']).to eql inbox.id
end
end
end

View File

@ -15,6 +15,30 @@ describe WebhookListener do
let!(:conversation_created_event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation) }
let!(:contact_event) { Events::Base.new(event_name, Time.zone.now, contact: contact) }
describe 'filter events by inbox' do
let(:event_name) { :'message.created' }
context 'when webhook has an inbox and it matches the event inbox' do
it 'triggers the webhook event' do
webhook = create(:webhook, account: account, inbox: inbox)
expect(WebhookJob).to receive(:perform_later)
.with(webhook.url, message.webhook_data.merge(event: 'message_created')).once
listener.message_created(message_created_event)
end
end
context 'when webhook has an inbox and it does not match the event inbox' do
it 'does not trigger webhook' do
another_inbox = create(:inbox, account: account)
create(:webhook, account: account, inbox: another_inbox)
expect(WebhookJob).to receive(:perform_later).exactly(0).times
listener.message_created(message_created_event)
end
end
end
describe '#message_created' do
let(:event_name) { :'message.created' }