Merge branch 'develop' into chore/vuelidate-v2-migration

This commit is contained in:
Pranav
2024-07-25 16:29:38 -07:00
committed by GitHub
25 changed files with 347 additions and 178 deletions
+2
View File
@@ -257,3 +257,5 @@ AZURE_APP_SECRET=
# Set to true if you want to remove stale contact inboxes
# contact_inboxes with no conversation older than 90 days will be removed
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
# CAPTAIN_API_URL=http://localhost:3001/api
@@ -10,7 +10,7 @@ class Api::V1::Accounts::Integrations::AppsController < Api::V1::Accounts::BaseC
private
def fetch_apps
@apps = Integrations::App.all.select(&:active?)
@apps = Integrations::App.all.select { |app| app.active?(Current.account) }
end
def fetch_app
@@ -1,6 +1,10 @@
table {
@apply border-spacing-0 text-sm w-full;
}
.woot-table {
thead {
th {
@apply font-semibold tracking-[1px] text-left px-2.5 uppercase text-slate-900 dark:text-slate-200;
@@ -16,9 +20,7 @@ table {
@apply p-2.5 text-slate-700 dark:text-slate-100;
}
}
}
.woot-table {
tr {
.show-if-hover {
transition: opacity 0.2s $swift-ease-out-function;
@@ -1,34 +1,40 @@
<script setup>
import router from '../../routes/index';
const props = defineProps({
backUrl: {
type: [String, Object],
default: '',
},
buttonLabel: {
type: String,
default: '',
},
compact: {
type: Boolean,
default: false,
},
});
const goBack = () => {
if (props.backUrl !== '') {
router.push(props.backUrl);
} else {
router.go(-1);
}
};
const buttonStyleClass = props.compact
? 'text-sm text-slate-600 dark:text-slate-300'
: 'text-base text-woot-500 dark:text-woot-500';
</script>
<template>
<button
class="header-section flex items-center text-base font-normal mr-4 ml-2 p-0 cursor-pointer text-woot-500 dark:text-woot-500"
class="flex items-center font-normal p-0 cursor-pointer"
:class="buttonStyleClass"
@click.capture="goBack"
>
<fluent-icon icon="chevron-left" />
<fluent-icon icon="chevron-left" class="-ml-1" />
{{ buttonLabel || $t('GENERAL_SETTINGS.BACK') }}
</button>
</template>
<script>
import router from '../../routes/index';
export default {
props: {
backUrl: {
type: [String, Object],
default: '',
},
buttonLabel: {
type: String,
default: '',
},
},
methods: {
goBack() {
if (this.backUrl !== '') {
router.push(this.backUrl);
} else {
router.go(-1);
}
},
},
};
</script>
@@ -1,4 +1,20 @@
const FEATURE_HELP_URLS = {
channel_email: 'https://chwt.app/hc/email',
channel_facebook: 'https://chwt.app/hc/fb',
help_center: 'https://chwt.app/hc/help-center',
agent_bots: 'https://chwt.app/hc/agent-bots',
team_management: 'https://chwt.app/hc/teams',
labels: 'https://chwt.app/hc/labels',
custom_attributes: 'https://chwt.app/hc/custom-attributes',
canned_responses: 'https://chwt.app/hc/canned',
integrations: 'https://chwt.app/hc/integrations',
campaigns: 'https://chwt.app/hc/campaigns',
reports: 'https://chwt.app/hc/reports',
message_reply_to: 'https://chwt.app/hc/reply-to',
sla: 'https://chwt.app/hc/sla',
dashboard_apps: 'https://chwt.app/hc/dashboard-apps',
};
export function getHelpUrlForFeature(featureName) {
const { helpUrls } = window.chatwootConfig;
return helpUrls[featureName];
return FEATURE_HELP_URLS[featureName];
}
@@ -172,6 +172,7 @@
"HEADER_BTN_TXT": "Add a new dashboard app",
"SIDEBAR_TXT": "<p><b>Dashboard Apps</b></p><p>Dashboard Apps allow organizations to embed an application inside the Chatwoot dashboard to provide the context for customer support agents. This feature allows you to create an application independently and embed that inside the dashboard to provide user information, their orders, or their previous payment history.</p><p>When you embed your application using the dashboard in Chatwoot, your application will get the context of the conversation and contact as a window event. Implement a listener for the message event on your page to receive the context.</p><p>To add a new dashboard app, click on the button 'Add a new dashboard app'.</p>",
"DESCRIPTION": "Dashboard Apps allow organizations to embed an application inside the dashboard to provide the context for customer support agents. This feature allows you to create an application independently and embed that to provide user information, their orders, or their previous payment history.",
"LEARN_MORE": "Learn more about Dashboard Apps",
"LIST": {
"404": "There are no dashboard apps configured on this account yet",
"LOADING": "Fetching dashboard apps...",
@@ -10,6 +10,7 @@
v-if="showBackButton"
:button-label="backButtonLabel"
:back-url="backUrl"
class="ml-2 mr-4"
/>
<fluent-icon
v-if="icon"
@@ -9,7 +9,7 @@ defineProps({
<template>
<div
class="flex flex-col w-full h-full m-0 px-8 lg:px-16 py-8 overflow-auto bg-white dark:bg-slate-900"
class="flex flex-col w-full h-full m-0 p-6 sm:py-8 lg:px-16 overflow-auto bg-white dark:bg-slate-900 font-inter"
>
<div class="flex items-start w-full max-w-6xl mx-auto">
<keep-alive v-if="keepAlive">
@@ -1,6 +1,7 @@
<script setup>
import CustomBrandPolicyWrapper from 'dashboard/components/CustomBrandPolicyWrapper.vue';
import { getHelpUrlForFeature } from '../../../../helper/featureHelper';
import BackButton from '../../../../components/widgets/BackButton.vue';
const props = defineProps({
title: {
type: String,
@@ -22,6 +23,10 @@ const props = defineProps({
type: String,
default: '',
},
backButtonLabel: {
type: String,
default: '',
},
});
const helpURL = getHelpUrlForFeature(props.featureName);
@@ -33,7 +38,12 @@ const openInNewTab = url => {
</script>
<template>
<div class="flex flex-col items-start w-full gap-3 pt-4">
<div class="flex flex-col items-start w-full gap-2 pt-4">
<BackButton
v-if="backButtonLabel"
compact
:button-label="backButtonLabel"
/>
<div class="flex items-center justify-between w-full gap-4">
<div class="flex items-center gap-3">
<div
@@ -64,7 +74,7 @@ const openInNewTab = url => {
</div>
<div class="flex flex-col gap-3 text-slate-600 dark:text-slate-300 w-full">
<p
class="mb-0 text-base font-normal line-clamp-5 sm:line-clamp-none max-w-3xl"
class="mb-0 text-base font-normal line-clamp-5 sm:line-clamp-none max-w-3xl tracking-[-0.1px]"
>
<slot name="description">{{ description }}</slot>
</p>
@@ -74,7 +84,7 @@ const openInNewTab = url => {
:href="helpURL"
target="_blank"
rel="noopener noreferrer"
class="sm:inline-flex hidden tracking-[-0.6%] gap-1 w-fit items-center text-woot-500 dark:text-woot-500 text-sm font-medium tracking=[-0.6%] hover:underline"
class="sm:inline-flex hidden gap-1 w-fit items-center text-woot-500 dark:text-woot-500 text-sm font-medium hover:underline"
>
{{ linkText }}
<fluent-icon
@@ -86,14 +96,16 @@ const openInNewTab = url => {
</a>
</CustomBrandPolicyWrapper>
</div>
<div class="flex items-start justify-start w-full gap-3 sm:hidden">
<div
class="flex items-start justify-start w-full gap-3 sm:hidden flex-wrap"
>
<slot name="actions" />
<CustomBrandPolicyWrapper :show-on-custom-branded-instance="false">
<woot-button
v-if="helpURL && linkText"
color-scheme="secondary"
icon="arrow-outwards"
class="flex-row-reverse rounded-xl min-w-0 !bg-slate-50 !text-slate-900 dark:!text-white dark:!bg-slate-800"
class="flex-row-reverse rounded-md min-w-0 !bg-slate-50 !text-slate-900 dark:!text-white dark:!bg-slate-800"
@click="openInNewTab(helpURL)"
>
{{ linkText }}
@@ -1,12 +1,26 @@
<script setup>
defineProps({
app: {
type: Object,
default: () => ({}),
},
});
defineEmits(['edit', 'delete']);
</script>
<template>
<tr>
<td class="w-40 max-w-[10rem] truncate" :title="app.title">
<tr class="py-1 max-w-full">
<td
class="py-4 pr-4 text-sm w-40 max-w-[10rem] truncate"
:title="app.title"
>
{{ app.title }}
</td>
<td class="max-w-xs truncate" :title="app.content[0].url">
<td class="py-4 pr-4 text-sm max-w-lg truncate" :title="app.content[0].url">
{{ app.content[0].url }}
</td>
<td class="flex justify-end gap-1">
<td class="py-4 pr-4 text-sm flex gap-2 sm:pr-0">
<woot-button
v-tooltip.top="
$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.LIST.EDIT_TOOLTIP')
@@ -32,30 +46,3 @@
</td>
</tr>
</template>
<script>
export default {
loading: {
type: Boolean,
default: false,
},
props: {
app: {
type: Object,
default: () => ({}),
},
},
};
</script>
<style lang="scss" scoped>
.dashboard-app-label-url {
@apply relative w-full;
&:before {
@apply invisible content-['&nbsp'];
}
span {
@apply absolute left-0 right-0;
}
}
</style>
@@ -1,95 +1,14 @@
<template>
<div class="flex-1 p-4 overflow-auto">
<woot-button
color-scheme="success"
class-names="button--fixed-top"
icon="add-circle"
@click="openCreatePopup"
>
{{ $t('INTEGRATION_SETTINGS.DASHBOARD_APPS.HEADER_BTN_TXT') }}
</woot-button>
<div class="flex flex-row gap-4">
<div class="w-full lg:w-3/5">
<p
v-if="!uiFlags.isFetching && !records.length"
class="flex flex-col items-center justify-center h-full"
>
{{ $t('INTEGRATION_SETTINGS.DASHBOARD_APPS.LIST.404') }}
</p>
<woot-loading-state
v-if="uiFlags.isFetching"
:message="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.LIST.LOADING')"
/>
<table v-if="!uiFlags.isFetching && records.length" class="woot-table">
<thead>
<th
v-for="thHeader in $t(
'INTEGRATION_SETTINGS.DASHBOARD_APPS.LIST.TABLE_HEADER'
)"
:key="thHeader"
>
{{ thHeader }}
</th>
</thead>
<tbody>
<dashboard-apps-row
v-for="(dashboardAppItem, index) in records"
:key="dashboardAppItem.id"
:index="index"
:app="dashboardAppItem"
@edit="editApp"
@delete="openDeletePopup"
/>
</tbody>
</table>
</div>
<div class="hidden w-1/3 lg:block">
<span
v-dompurify-html="
useInstallationName(
$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.SIDEBAR_TXT'),
globalConfig.installationName
)
"
/>
</div>
</div>
<dashboard-app-modal
v-if="showDashboardAppPopup"
:show="showDashboardAppPopup"
:mode="mode"
:selected-app-data="selectedApp"
@close="toggleDashboardAppPopup"
/>
<woot-delete-modal
:show.sync="showDeleteConfirmationPopup"
:on-close="closeDeletePopup"
:on-confirm="confirmDeletion"
:title="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.DELETE.TITLE')"
:message="
$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.DELETE.MESSAGE', {
appName: selectedApp.title,
})
"
:confirm-text="
$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.DELETE.CONFIRM_YES')
"
:reject-text="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.DELETE.CONFIRM_NO')"
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import DashboardAppModal from './DashboardAppModal.vue';
import DashboardAppsRow from './DashboardAppsRow.vue';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
export default {
components: {
BaseSettingsHeader,
DashboardAppModal,
DashboardAppsRow,
},
@@ -156,3 +75,87 @@ export default {
},
};
</script>
<template>
<div class="flex-1 overflow-auto flex gap-8 flex-col">
<BaseSettingsHeader
:title="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.TITLE')"
:description="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.DESCRIPTION')"
:link-text="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.LEARN_MORE')"
feature-name="dashboard_apps"
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
>
<template #actions>
<woot-button
class="button nice rounded-md"
icon="add-circle"
@click="openCreatePopup"
>
{{ $t('INTEGRATION_SETTINGS.DASHBOARD_APPS.HEADER_BTN_TXT') }}
</woot-button>
</template>
</BaseSettingsHeader>
<div class="w-full text-slate-700 dark:text-slate-200 overflow-x-auto">
<p
v-if="!uiFlags.isFetching && !records.length"
class="flex flex-col items-center justify-center h-full"
>
{{ $t('INTEGRATION_SETTINGS.DASHBOARD_APPS.LIST.404') }}
</p>
<woot-loading-state
v-if="uiFlags.isFetching"
:message="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.LIST.LOADING')"
/>
<table
v-if="!uiFlags.isFetching && records.length"
class="min-w-full divide-y divide-slate-75 dark:divide-slate-700"
>
<thead>
<th
v-for="thHeader in $t(
'INTEGRATION_SETTINGS.DASHBOARD_APPS.LIST.TABLE_HEADER'
)"
:key="thHeader"
class="py-4 pr-4 text-left font-semibold text-slate-700 dark:text-slate-300"
>
{{ thHeader }}
</th>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800">
<dashboard-apps-row
v-for="(dashboardAppItem, index) in records"
:key="dashboardAppItem.id"
:index="index"
:app="dashboardAppItem"
@edit="editApp"
@delete="openDeletePopup"
/>
</tbody>
</table>
</div>
<dashboard-app-modal
v-if="showDashboardAppPopup"
:show="showDashboardAppPopup"
:mode="mode"
:selected-app-data="selectedApp"
@close="toggleDashboardAppPopup"
/>
<woot-delete-modal
:show.sync="showDeleteConfirmationPopup"
:on-close="closeDeletePopup"
:on-confirm="confirmDeletion"
:title="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.DELETE.TITLE')"
:message="
$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.DELETE.MESSAGE', {
appName: selectedApp.title,
})
"
:confirm-text="
$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.DELETE.CONFIRM_YES')
"
:reject-text="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.DELETE.CONFIRM_NO')"
/>
</div>
</template>
@@ -33,7 +33,7 @@ onMounted(() => {
/>
</template>
<template #body>
<div class="flex-grow flex-shrink overflow-auto font-inter">
<div class="flex-grow flex-shrink overflow-auto">
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<integration-item
v-for="item in integrationList"
@@ -22,9 +22,16 @@ export default {
permissions: ['administrator'],
},
},
{
path: 'dashboard_apps',
component: DashboardApps,
name: 'settings_integrations_dashboard_apps',
meta: {
permissions: ['administrator'],
},
},
],
},
{
path: frontendURL('accounts/:accountId/settings/integrations'),
component: SettingsContent,
@@ -50,14 +57,6 @@ export default {
permissions: ['administrator'],
},
},
{
path: 'dashboard_apps',
component: DashboardApps,
name: 'settings_integrations_dashboard_apps',
meta: {
permissions: ['administrator'],
},
},
{
path: 'slack',
name: 'settings_integrations_slack',
+8
View File
@@ -9,6 +9,8 @@ class HookJob < ApplicationJob
process_slack_integration(hook, event_name, event_data)
when 'dialogflow'
process_dialogflow_integration(hook, event_name, event_data)
when 'captain'
process_captain_integration(hook, event_name, event_data)
when 'google_translate'
google_translate_integration(hook, event_name, event_data)
end
@@ -35,6 +37,12 @@ class HookJob < ApplicationJob
Integrations::Dialogflow::ProcessorService.new(event_name: event_name, hook: hook, event_data: event_data).perform
end
def process_captain_integration(hook, event_name, event_data)
return unless ['message.created'].include?(event_name)
Integrations::Captain::ProcessorService.new(event_name: event_data, hook: hook, event_data: event_data).perform
end
def google_translate_integration(hook, event_name, event_data)
return unless ['message.created'].include?(event_name)
+10 -1
View File
@@ -129,7 +129,16 @@ class Inbox < ApplicationRecord
end
def active_bot?
agent_bot_inbox&.active? || hooks.where(app_id: 'dialogflow', status: 'enabled').count.positive?
agent_bot_inbox&.active? || hooks.where(app_id: %w[dialogflow],
status: 'enabled').count.positive? || captain_enabled?
end
def captain_enabled?
captain_hook = account.hooks.where(
app_id: %w[captain], status: 'enabled'
).first
captain_hook.present? && captain_hook.settings['inbox_ids'].split(',').include?(id.to_s)
end
def inbox_type
+4 -2
View File
@@ -34,12 +34,14 @@ class Integrations::App
end
end
def active?
def active?(account)
case params[:id]
when 'slack'
ENV['SLACK_CLIENT_SECRET'].present?
when 'linear'
Current.account.feature_enabled?('linear_integration')
account.feature_enabled?('linear_integration')
when 'captain'
account.feature_enabled?('captain_integration') && ENV['CAPTAIN_API_URL'].present?
else
true
end
+2
View File
@@ -87,3 +87,5 @@
premium: true
- name: linear_integration
enabled: false
- name: captain_integration
enabled: false
+52 -1
View File
@@ -8,7 +8,58 @@
# settings_json_schema: the json schema used to validate the settings hash (https://json-schema.org/)
# settings_form_schema: the formulate schema used in frontend to render settings form (https://vueformulate.com/)
########################################################
captain:
id: captain
logo: captain.png
i18n_key: captain
action: /captain
hook_type: account
allow_multiple_hooks: false
settings_json_schema: {
"type": "object",
"properties": {
"access_token": { "type": "string" },
"account_id": { "type": "string" },
"account_email": { "type": "string" },
"assistant_id": { "type": "string" },
"inbox_ids": { "type": "strings" },
},
"required": ["access_token", "account_id", "account_email", "assistant_id"],
"additionalProperties": false,
}
settings_form_schema: [
{
"label": "Access Token",
"type": "text",
"name": "access_token",
"validation": "required",
},
{
"label": "Account ID",
"type": "text",
"name": "account_id",
"validation": "required",
},
{
"label": "Account Email",
"type": "text",
"name": "account_email",
"validation": "required",
},
{
"label": "Assistant Id",
"type": "text",
"name": "assistant_id",
"validation": "required",
},
{
"label": "Inbox Ids",
"type": "text",
"name": "inbox_ids",
"validation": "",
},
]
visible_properties: []
webhooks:
id: webhook
logo: webhooks.png
+3
View File
@@ -227,6 +227,9 @@ en:
linear:
name: "Linear"
description: "Create issues in Linear directly from your conversation window. Alternatively, link existing Linear issues for a more streamlined and efficient issue tracking process."
captain:
name: "Captain"
description: "Captain is a native AI assistant built for your product and trained on your company's knowledge base. It responds like a human and resolves customer queries effectively. Configure it to your inboxes easily."
public_portal:
search:
search_placeholder: Search for article by title or body...
@@ -0,0 +1,65 @@
class Integrations::Captain::ProcessorService < Integrations::BotProcessorService
pattr_initialize [:event_name!, :hook!, :event_data!]
private
def get_response(_session_id, message_content)
call_captain(message_content)
end
def process_response(message, response)
if response == 'conversation_handoff'
message.conversation.bot_handoff!
else
create_conversation(message, { content: response })
end
end
def create_conversation(message, content_params)
return if content_params.blank?
conversation = message.conversation
conversation.messages.create!(
content_params.merge(
{
message_type: :outgoing,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id
}
)
)
end
def call_captain(message_content)
url = "#{ENV.fetch('CAPTAIN_API_URL', nil)}/accounts/#{hook.settings['account_id']}/assistants/#{hook.settings['assistant_id']}/chat"
headers = {
'X-USER-EMAIL' => hook.settings['account_email'],
'X-USER-TOKEN' => hook.settings['access_token'],
'Content-Type' => 'application/json'
}
body = {
message: message_content,
previous_messages: previous_messages
}
response = HTTParty.post(url, headers: headers, body: body.to_json)
response.parsed_response['message']
end
def previous_messages
previous_messages = []
conversation.messages.where(message_type: [:outgoing, :incoming]).where(private: false).offset(1).find_each do |message|
next if message.content_type != 'text'
role = determine_role(message)
previous_messages << { message: message.content, type: role }
end
previous_messages
end
def determine_role(message)
message.message_type == 'incoming' ? 'User' : 'Bot'
end
end
@@ -14,13 +14,13 @@ class Integrations::Dialogflow::ProcessorService < Integrations::BotProcessorSer
message.content
end
def get_response(session_id, message)
def get_response(session_id, message_content)
if hook.settings['credentials'].blank?
Rails.logger.warn "Account: #{hook.try(:account_id)} Hook: #{hook.id} credentials are not present." && return
end
configure_dialogflow_client_defaults
detect_intent(session_id, message)
detect_intent(session_id, message_content)
rescue Google::Cloud::PermissionDeniedError => e
Rails.logger.warn "DialogFlow Error: (account-#{hook.try(:account_id)}, hook-#{hook.id}) #{e.message}"
hook.prompt_reauthorization!
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -16,7 +16,7 @@ RSpec.describe 'Integration Apps API', type: :request do
let(:admin) { create(:user, account: account, role: :administrator) }
it 'returns all active apps without sensitive information if the user is an agent' do
first_app = Integrations::App.all.find(&:active?)
first_app = Integrations::App.all.find { |app| app.active?(account) }
get api_v1_account_integrations_apps_url(account),
headers: agent.create_new_auth_token,
as: :json
@@ -41,7 +41,7 @@ RSpec.describe 'Integration Apps API', type: :request do
end
it 'returns all active apps with sensitive information if user is an admin' do
first_app = Integrations::App.all.find(&:active?)
first_app = Integrations::App.all.find { |app| app.active?(account) }
get api_v1_account_integrations_apps_url(account),
headers: admin.create_new_auth_token,
as: :json
+8 -8
View File
@@ -5,10 +5,6 @@ RSpec.describe Integrations::App do
let(:app) { apps.find(id: app_name) }
let(:account) { create(:account) }
before do
allow(Current).to receive(:account).and_return(account)
end
describe '#name' do
let(:app_name) { 'slack' }
@@ -28,6 +24,10 @@ RSpec.describe Integrations::App do
describe '#action' do
let(:app_name) { 'slack' }
before do
allow(Current).to receive(:account).and_return(account)
end
context 'when the app is slack' do
it 'returns the action URL with client_id and redirect_uri' do
with_modified_env SLACK_CLIENT_ID: 'dummy_client_id' do
@@ -46,7 +46,7 @@ RSpec.describe Integrations::App do
context 'when the app is slack' do
it 'returns true if SLACK_CLIENT_SECRET is present' do
with_modified_env SLACK_CLIENT_SECRET: 'random_secret' do
expect(app.active?).to be true
expect(app.active?(account)).to be true
end
end
end
@@ -55,14 +55,14 @@ RSpec.describe Integrations::App do
let(:app_name) { 'linear' }
it 'returns true if the linear integration feature is disabled' do
expect(app.active?).to be false
expect(app.active?(account)).to be false
end
it 'returns false if the linear integration feature is enabled' do
account.enable_features('linear_integration')
account.save!
expect(app.active?).to be true
expect(app.active?(account)).to be true
end
end
@@ -70,7 +70,7 @@ RSpec.describe Integrations::App do
let(:app_name) { 'webhook' }
it 'returns true' do
expect(app.active?).to be true
expect(app.active?(account)).to be true
end
end
end