feat: dashboard apps on sidebar (#146)

* feat: dashboard apps on sidebar

* fix: handle dashboard app not found

* chore: minor refactoring
This commit is contained in:
Gabriel Jablonski 2025-11-19 14:44:18 -03:00 committed by GitHub
parent 14f43a6bc5
commit 7f0748460e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 232 additions and 18 deletions

View File

@ -34,6 +34,7 @@ class Api::V1::Accounts::DashboardAppsController < Api::V1::Accounts::BaseContro
def permitted_payload def permitted_payload
params.require(:dashboard_app).permit( params.require(:dashboard_app).permit(
:title, :title,
:show_on_sidebar,
content: [:url, :type] content: [:url, :type]
) )
end end

View File

@ -66,6 +66,7 @@ provideSidebarContext({
const inboxes = useMapGetter('inboxes/getInboxes'); const inboxes = useMapGetter('inboxes/getInboxes');
const labels = useMapGetter('labels/getLabelsOnSidebar'); const labels = useMapGetter('labels/getLabelsOnSidebar');
const dashboardApps = useMapGetter('dashboardApps/getAppsOnSidebar');
const teams = useMapGetter('teams/getMyTeams'); const teams = useMapGetter('teams/getMyTeams');
const contactCustomViews = useMapGetter('customViews/getContactCustomViews'); const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
const conversationCustomViews = useMapGetter( const conversationCustomViews = useMapGetter(
@ -80,6 +81,7 @@ onMounted(() => {
store.dispatch('attributes/get'); store.dispatch('attributes/get');
store.dispatch('customViews/get', 'conversation'); store.dispatch('customViews/get', 'conversation');
store.dispatch('customViews/get', 'contact'); store.dispatch('customViews/get', 'contact');
store.dispatch('dashboardApps/get');
}); });
const sortedInboxes = computed(() => const sortedInboxes = computed(() =>
@ -120,7 +122,7 @@ const newReportRoutes = () => [
const reportRoutes = computed(() => newReportRoutes()); const reportRoutes = computed(() => newReportRoutes());
const menuItems = computed(() => { const menuItems = computed(() => {
return [ const items = [
{ {
name: 'Inbox', name: 'Inbox',
label: t('SIDEBAR.INBOX'), label: t('SIDEBAR.INBOX'),
@ -514,6 +516,23 @@ const menuItems = computed(() => {
], ],
}, },
]; ];
if (dashboardApps.value.length > 0) {
const settingsIndex = items.findIndex(item => item.name === 'Settings');
items.splice(settingsIndex, 0, {
name: 'Apps',
label: t('SIDEBAR.APPS'),
icon: 'i-lucide-layout-grid',
children: dashboardApps.value.map(app => ({
name: `app-${app.id}`,
label: app.title,
to: accountScopedRoute('dashboard_app_view', { appId: app.id }),
activeOn: ['dashboard_app_view'],
})),
});
}
return items;
}); });
</script> </script>

View File

@ -1,6 +1,13 @@
<script setup> <script setup>
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const props = defineProps({
id: {
type: String,
default: undefined,
},
});
const emit = defineEmits(['change']); const emit = defineEmits(['change']);
const { t } = useI18n(); const { t } = useI18n();
@ -18,6 +25,7 @@ const updateValue = () => {
<template> <template>
<button <button
:id="props.id"
type="button" type="button"
class="relative h-4 transition-colors duration-200 ease-in-out rounded-full w-7 focus:outline-none focus:ring-1 focus:ring-n-brand focus:ring-offset-n-slate-2 focus:ring-offset-2 flex-shrink-0" class="relative h-4 transition-colors duration-200 ease-in-out rounded-full w-7 focus:outline-none focus:ring-1 focus:ring-n-brand focus:ring-offset-n-slate-2 focus:ring-offset-2 flex-shrink-0"
:class="modelValue ? 'bg-n-brand' : 'bg-n-slate-6 disabled:bg-n-slate-6/60'" :class="modelValue ? 'bg-n-brand' : 'bg-n-slate-6 disabled:bg-n-slate-6/60'"

View File

@ -25,7 +25,7 @@ export default {
}, },
data() { data() {
return { return {
hasOpenedAtleastOnce: false, hasOpenedAtleastOnce: this.isVisible,
iframeLoading: true, iframeLoading: true,
}; };
}, },
@ -46,8 +46,8 @@ export default {
}, },
}, },
watch: { watch: {
isVisible() { isVisible(value) {
if (this.isVisible) { if (value) {
this.hasOpenedAtleastOnce = true; this.hasOpenedAtleastOnce = true;
} }
}, },

View File

@ -39,6 +39,9 @@ export default {
currentChat: 'getSelectedChat', currentChat: 'getSelectedChat',
dashboardApps: 'dashboardApps/getRecords', dashboardApps: 'dashboardApps/getRecords',
}), }),
conversationDashboardApps() {
return this.dashboardApps.filter(app => !app.show_on_sidebar);
},
dashboardAppTabs() { dashboardAppTabs() {
return [ return [
{ {
@ -46,7 +49,7 @@ export default {
index: 0, index: 0,
name: this.$t('CONVERSATION.DASHBOARD_APP_TAB_MESSAGES'), name: this.$t('CONVERSATION.DASHBOARD_APP_TAB_MESSAGES'),
}, },
...this.dashboardApps.map((dashboardApp, index) => ({ ...this.conversationDashboardApps.map((dashboardApp, index) => ({
key: `dashboard-${dashboardApp.id}`, key: `dashboard-${dashboardApp.id}`,
index: index + 1, index: index + 1,
name: dashboardApp.title, name: dashboardApp.title,
@ -102,7 +105,7 @@ export default {
:show-back-button="isOnExpandedLayout && !isInboxView" :show-back-button="isOnExpandedLayout && !isInboxView"
/> />
<woot-tabs <woot-tabs
v-if="dashboardApps.length && currentChat.id" v-if="conversationDashboardApps.length && currentChat.id"
:index="activeIndex" :index="activeIndex"
class="-mt-px border-t border-t-n-background" class="-mt-px border-t border-t-n-background"
@change="onDashboardAppTabChange" @change="onDashboardAppTabChange"
@ -130,11 +133,11 @@ export default {
<slot /> <slot />
</div> </div>
<DashboardAppFrame <DashboardAppFrame
v-for="(dashboardApp, index) in dashboardApps" v-for="(dashboardApp, index) in conversationDashboardApps"
v-show="activeIndex - 1 === index" v-show="activeIndex - 1 === index"
:key="currentChat.id + '-' + dashboardApp.id" :key="currentChat.id + '-' + dashboardApp.id"
:is-visible="activeIndex - 1 === index" :is-visible="activeIndex - 1 === index"
:config="dashboardApps[index].content" :config="conversationDashboardApps[index].content"
:position="index" :position="index"
:current-chat="currentChat" :current-chat="currentChat"
/> />

View File

@ -216,13 +216,17 @@
"EDIT_TOOLTIP": "Edit app", "EDIT_TOOLTIP": "Edit app",
"DELETE_TOOLTIP": "Delete app" "DELETE_TOOLTIP": "Delete app"
}, },
"VIEW": {
"NOT_FOUND": "We couldn't find that dashboard app."
},
"FORM": { "FORM": {
"TITLE_LABEL": "Name", "TITLE_LABEL": "Name",
"TITLE_PLACEHOLDER": "Enter a name for your dashboard app", "TITLE_PLACEHOLDER": "Enter a name for your dashboard app",
"TITLE_ERROR": "A name for the dashboard app is required", "TITLE_ERROR": "A name for the dashboard app is required",
"URL_LABEL": "Endpoint", "URL_LABEL": "Endpoint",
"URL_PLACEHOLDER": "Enter the endpoint URL where your app is hosted", "URL_PLACEHOLDER": "Enter the endpoint URL where your app is hosted",
"URL_ERROR": "A valid URL is required" "URL_ERROR": "A valid URL is required",
"SHOW_ON_SIDEBAR_LABEL": "Show on sidebar"
}, },
"CREATE": { "CREATE": {
"HEADER": "Add a new dashboard app", "HEADER": "Add a new dashboard app",

View File

@ -328,6 +328,7 @@
"HOME": "Home", "HOME": "Home",
"AGENTS": "Agents", "AGENTS": "Agents",
"AGENT_BOTS": "Bots", "AGENT_BOTS": "Bots",
"APPS": "Apps",
"AUDIT_LOGS": "Audit Logs", "AUDIT_LOGS": "Audit Logs",
"INBOXES": "Inboxes", "INBOXES": "Inboxes",
"NOTIFICATIONS": "Notifications", "NOTIFICATIONS": "Notifications",

View File

@ -219,13 +219,17 @@
"EDIT_TOOLTIP": "Alterar aplicativo", "EDIT_TOOLTIP": "Alterar aplicativo",
"DELETE_TOOLTIP": "Excluir aplicativo" "DELETE_TOOLTIP": "Excluir aplicativo"
}, },
"VIEW": {
"NOT_FOUND": "Não encontramos este aplicativo do painel."
},
"FORM": { "FORM": {
"TITLE_LABEL": "Nome", "TITLE_LABEL": "Nome",
"TITLE_PLACEHOLDER": "Digite um nome para o aplicativo", "TITLE_PLACEHOLDER": "Digite um nome para o aplicativo",
"TITLE_ERROR": "É necessário um nome para o aplicativo", "TITLE_ERROR": "É necessário um nome para o aplicativo",
"URL_LABEL": "Endpoint", "URL_LABEL": "Endpoint",
"URL_PLACEHOLDER": "Digite a URL do endpoint onde seu aplicativo está hospedado", "URL_PLACEHOLDER": "Digite a URL do endpoint onde seu aplicativo está hospedado",
"URL_ERROR": "É necessário uma URL válida" "URL_ERROR": "É necessário uma URL válida",
"SHOW_ON_SIDEBAR_LABEL": "Mostrar na barra lateral"
}, },
"CREATE": { "CREATE": {
"HEADER": "Adicionar um novo aplicativo", "HEADER": "Adicionar um novo aplicativo",

View File

@ -328,6 +328,7 @@
"HOME": "Principal", "HOME": "Principal",
"AGENTS": "Agentes", "AGENTS": "Agentes",
"AGENT_BOTS": "Robôs", "AGENT_BOTS": "Robôs",
"APPS": "Apps",
"AUDIT_LOGS": "Auditoria", "AUDIT_LOGS": "Auditoria",
"INBOXES": "Caixas de Entrada", "INBOXES": "Caixas de Entrada",
"NOTIFICATIONS": "Notificações", "NOTIFICATIONS": "Notificações",

View File

@ -8,6 +8,7 @@ import { frontendURL } from '../../helper/URLHelper';
import helpcenterRoutes from './helpcenter/helpcenter.routes'; import helpcenterRoutes from './helpcenter/helpcenter.routes';
import campaignsRoutes from './campaigns/campaigns.routes'; import campaignsRoutes from './campaigns/campaigns.routes';
import { routes as captainRoutes } from './captain/captain.routes'; import { routes as captainRoutes } from './captain/captain.routes';
import dashboardAppsRoutes from './dashboardApps/dashboardApps.routes';
import AppContainer from './Dashboard.vue'; import AppContainer from './Dashboard.vue';
import Suspended from './suspended/Index.vue'; import Suspended from './suspended/Index.vue';
@ -26,6 +27,7 @@ export default {
...notificationRoutes, ...notificationRoutes,
...helpcenterRoutes.routes, ...helpcenterRoutes.routes,
...campaignsRoutes.routes, ...campaignsRoutes.routes,
...dashboardAppsRoutes.routes,
], ],
}, },
{ {

View File

@ -0,0 +1,72 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import DashboardAppFrame from 'dashboard/components/widgets/DashboardApp/Frame.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const route = useRoute();
const store = useStore();
const { t } = useI18n();
const dashboardApps = useMapGetter('dashboardApps/getRecords');
const isLoadingApps = ref(true);
const appId = computed(() => Number(route.params.appId));
const dashboardApp = computed(() => {
return dashboardApps.value.find(app => app.id === appId.value);
});
const notFound = computed(() => !isLoadingApps.value && !dashboardApp.value);
onMounted(async () => {
try {
if (!dashboardApps.value.length) {
await store.dispatch('dashboardApps/get');
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to fetch dashboard apps', error);
} finally {
isLoadingApps.value = false;
}
});
</script>
<template>
<div class="flex flex-col w-full h-full bg-n-background">
<div
v-if="isLoadingApps"
class="flex items-center justify-center w-full h-full"
>
<Spinner />
</div>
<div
v-else-if="notFound"
class="flex items-center justify-center w-full h-full px-4 text-center"
>
<p class="text-sm text-n-slate-11">
{{ t('INTEGRATION_SETTINGS.DASHBOARD_APPS.VIEW.NOT_FOUND') }}
</p>
</div>
<div v-else class="flex flex-col w-full h-full">
<div
class="flex items-center gap-3 px-4 py-3 border-b border-n-weak bg-n-background"
>
<h1 class="text-lg font-semibold text-n-slate-12">
{{ dashboardApp.title }}
</h1>
</div>
<div class="flex-1 min-h-0">
<DashboardAppFrame
v-if="dashboardApp"
is-visible
:config="dashboardApp.content"
:position="0"
:current-chat="null"
/>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,16 @@
import { frontendURL } from '../../../helper/URLHelper';
import DashboardAppView from './DashboardAppView.vue';
export default {
routes: [
{
path: frontendURL('accounts/:accountId/dashboard-apps/:appId'),
name: 'dashboard_app_view',
meta: {
permissions: ['administrator', 'agent'],
},
component: DashboardAppView,
props: route => ({ appId: route.params.appId }),
},
],
};

View File

@ -4,10 +4,12 @@ import { required, url } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
export default { export default {
components: { components: {
NextButton, NextButton,
ToggleSwitch,
}, },
props: { props: {
show: { show: {
@ -41,6 +43,7 @@ export default {
isLoading: false, isLoading: false,
app: { app: {
title: '', title: '',
show_on_sidebar: false,
content: { content: {
type: 'frame', type: 'frame',
url: '', url: '',
@ -62,6 +65,7 @@ export default {
if (this.mode === 'UPDATE' && this.selectedAppData) { if (this.mode === 'UPDATE' && this.selectedAppData) {
this.app.title = this.selectedAppData.title; this.app.title = this.selectedAppData.title;
this.app.content = this.selectedAppData.content[0]; this.app.content = this.selectedAppData.content[0];
this.app.show_on_sidebar = this.selectedAppData.show_on_sidebar ?? false;
} }
}, },
methods: { methods: {
@ -69,6 +73,7 @@ export default {
// Reset the data once closed // Reset the data once closed
this.app = { this.app = {
title: '', title: '',
show_on_sidebar: false,
content: { type: 'frame', url: '' }, content: { type: 'frame', url: '' },
}; };
this.$emit('close'); this.$emit('close');
@ -83,6 +88,7 @@ export default {
const action = this.mode.toLowerCase(); const action = this.mode.toLowerCase();
const payload = { const payload = {
title: this.app.title, title: this.app.title,
show_on_sidebar: this.app.show_on_sidebar,
content: [this.app.content], content: [this.app.content],
}; };
@ -149,6 +155,19 @@ export default {
@input="v$.app.content.url.$touch" @input="v$.app.content.url.$touch"
@blur="v$.app.content.url.$touch" @blur="v$.app.content.url.$touch"
/> />
<div class="flex items-center w-full gap-3 py-2">
<label
class="text-sm text-n-slate-12 cursor-pointer"
for="show-on-sidebar"
>
{{
$t(
'INTEGRATION_SETTINGS.DASHBOARD_APPS.FORM.SHOW_ON_SIDEBAR_LABEL'
)
}}
</label>
<ToggleSwitch id="show-on-sidebar" v-model="app.show_on_sidebar" />
</div>
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2"> <div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<NextButton <NextButton
faded faded

View File

@ -18,6 +18,11 @@ export const getters = {
getRecords(_state) { getRecords(_state) {
return _state.records; return _state.records;
}, },
getAppsOnSidebar(_state) {
return _state.records
.filter(record => record.show_on_sidebar)
.sort((a, b) => a.title.localeCompare(b.title));
},
}; };
export const actions = { export const actions = {

View File

@ -26,4 +26,30 @@ describe('#getters', () => {
isDeleting: false, isDeleting: false,
}); });
}); });
it('getAppsOnSidebar', () => {
const state = {
records: [
{
id: 1,
title: 'Zebra App',
show_on_sidebar: true,
},
{
id: 2,
title: 'Alpha App',
show_on_sidebar: true,
},
{
id: 3,
title: 'Beta App',
show_on_sidebar: false,
},
],
};
const result = getters.getAppsOnSidebar(state);
expect(result).toHaveLength(2);
expect(result[0].title).toEqual('Alpha App');
expect(result[1].title).toEqual('Zebra App');
});
}); });

View File

@ -2,13 +2,14 @@
# #
# Table name: dashboard_apps # Table name: dashboard_apps
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# content :jsonb # content :jsonb
# title :string not null # show_on_sidebar :boolean default(FALSE), not null
# created_at :datetime not null # title :string not null
# updated_at :datetime not null # created_at :datetime not null
# account_id :bigint not null # updated_at :datetime not null
# user_id :bigint # account_id :bigint not null
# user_id :bigint
# #
# Indexes # Indexes
# #

View File

@ -1,4 +1,5 @@
json.id resource.id json.id resource.id
json.title resource.title json.title resource.title
json.content resource.content json.content resource.content
json.show_on_sidebar resource.show_on_sidebar
json.created_at resource.created_at json.created_at resource.created_at

View File

@ -0,0 +1,5 @@
class AddShowOnSidebarToDashboardApps < ActiveRecord::Migration[7.1]
def change
add_column :dashboard_apps, :show_on_sidebar, :boolean, default: false, null: false
end
end

View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do ActiveRecord::Schema[7.1].define(version: 2025_11_17_003944) do
# These extensions should be enabled to support this database # These extensions should be enabled to support this database
enable_extension "pg_stat_statements" enable_extension "pg_stat_statements"
enable_extension "pg_trgm" enable_extension "pg_trgm"
@ -773,6 +773,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do
t.bigint "user_id" t.bigint "user_id"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.boolean "show_on_sidebar", default: false, null: false
t.index ["account_id"], name: "index_dashboard_apps_on_account_id" t.index ["account_id"], name: "index_dashboard_apps_on_account_id"
t.index ["user_id"], name: "index_dashboard_apps_on_user_id" t.index ["user_id"], name: "index_dashboard_apps_on_user_id"
end end

View File

@ -55,6 +55,9 @@ RSpec.describe 'DashboardAppsController', type: :request do
describe 'POST /api/v1/accounts/{account.id}/dashboard_apps' do describe 'POST /api/v1/accounts/{account.id}/dashboard_apps' do
let(:payload) { { dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'frame', url: 'https://link.com' }] } } } let(:payload) { { dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'frame', url: 'https://link.com' }] } } }
let(:payload_with_sidebar) do
{ dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'frame', url: 'https://link.com' }], show_on_sidebar: true } }
end
let(:no_ssl_payload) { { dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'frame', url: 'http://link.com' }] } } } let(:no_ssl_payload) { { dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'frame', url: 'http://link.com' }] } } }
let(:invalid_type_payload) { { dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'dda', url: 'https://link.com' }] } } } let(:invalid_type_payload) { { dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'dda', url: 'https://link.com' }] } } }
let(:invalid_url_payload) { { dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'frame', url: 'com' }] } } } let(:invalid_url_payload) { { dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'frame', url: 'com' }] } } }
@ -86,6 +89,17 @@ RSpec.describe 'DashboardAppsController', type: :request do
expect(json_response['content'][0]['type']).to eq payload[:dashboard_app][:content][0][:type] expect(json_response['content'][0]['type']).to eq payload[:dashboard_app][:content][0][:type]
end end
it 'creates the dashboard app with show_on_sidebar' do
expect do
post "/api/v1/accounts/#{account.id}/dashboard_apps", headers: user.create_new_auth_token,
params: payload_with_sidebar
end.to change(DashboardApp, :count).by(1)
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['show_on_sidebar']).to be true
end
it 'creates the dashboard app even if the URL does not have SSL' do it 'creates the dashboard app even if the URL does not have SSL' do
expect do expect do
post "/api/v1/accounts/#{account.id}/dashboard_apps", headers: user.create_new_auth_token, post "/api/v1/accounts/#{account.id}/dashboard_apps", headers: user.create_new_auth_token,
@ -134,6 +148,7 @@ RSpec.describe 'DashboardAppsController', type: :request do
describe 'PATCH /api/v1/accounts/{account.id}/dashboard_apps/:id' do describe 'PATCH /api/v1/accounts/{account.id}/dashboard_apps/:id' do
let(:payload) { { dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'frame', url: 'https://link.com' }] } } } let(:payload) { { dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'frame', url: 'https://link.com' }] } } }
let(:payload_with_sidebar) { { dashboard_app: { show_on_sidebar: true } } }
let(:user) { create(:user, account: account) } let(:user) { create(:user, account: account) }
let!(:dashboard_app) { create(:dashboard_app, user: user, account: account) } let!(:dashboard_app) { create(:dashboard_app, user: user, account: account) }
@ -159,6 +174,16 @@ RSpec.describe 'DashboardAppsController', type: :request do
expect(json_response['content'][0]['link']).to eq payload[:dashboard_app][:content][0][:link] expect(json_response['content'][0]['link']).to eq payload[:dashboard_app][:content][0][:link]
expect(json_response['content'][0]['type']).to eq payload[:dashboard_app][:content][0][:type] expect(json_response['content'][0]['type']).to eq payload[:dashboard_app][:content][0][:type]
end end
it 'updates the show_on_sidebar attribute' do
patch "/api/v1/accounts/#{account.id}/dashboard_apps/#{dashboard_app.id}",
headers: user.create_new_auth_token,
params: payload_with_sidebar,
as: :json
expect(response).to have_http_status(:success)
expect(dashboard_app.reload.show_on_sidebar).to be true
end
end end
end end