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
params.require(:dashboard_app).permit(
:title,
:show_on_sidebar,
content: [:url, :type]
)
end

View File

@ -66,6 +66,7 @@ provideSidebarContext({
const inboxes = useMapGetter('inboxes/getInboxes');
const labels = useMapGetter('labels/getLabelsOnSidebar');
const dashboardApps = useMapGetter('dashboardApps/getAppsOnSidebar');
const teams = useMapGetter('teams/getMyTeams');
const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
const conversationCustomViews = useMapGetter(
@ -80,6 +81,7 @@ onMounted(() => {
store.dispatch('attributes/get');
store.dispatch('customViews/get', 'conversation');
store.dispatch('customViews/get', 'contact');
store.dispatch('dashboardApps/get');
});
const sortedInboxes = computed(() =>
@ -120,7 +122,7 @@ const newReportRoutes = () => [
const reportRoutes = computed(() => newReportRoutes());
const menuItems = computed(() => {
return [
const items = [
{
name: '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>

View File

@ -1,6 +1,13 @@
<script setup>
import { useI18n } from 'vue-i18n';
const props = defineProps({
id: {
type: String,
default: undefined,
},
});
const emit = defineEmits(['change']);
const { t } = useI18n();
@ -18,6 +25,7 @@ const updateValue = () => {
<template>
<button
:id="props.id"
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="modelValue ? 'bg-n-brand' : 'bg-n-slate-6 disabled:bg-n-slate-6/60'"

View File

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

View File

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

View File

@ -216,13 +216,17 @@
"EDIT_TOOLTIP": "Edit app",
"DELETE_TOOLTIP": "Delete app"
},
"VIEW": {
"NOT_FOUND": "We couldn't find that dashboard app."
},
"FORM": {
"TITLE_LABEL": "Name",
"TITLE_PLACEHOLDER": "Enter a name for your dashboard app",
"TITLE_ERROR": "A name for the dashboard app is required",
"URL_LABEL": "Endpoint",
"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": {
"HEADER": "Add a new dashboard app",

View File

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

View File

@ -219,13 +219,17 @@
"EDIT_TOOLTIP": "Alterar aplicativo",
"DELETE_TOOLTIP": "Excluir aplicativo"
},
"VIEW": {
"NOT_FOUND": "Não encontramos este aplicativo do painel."
},
"FORM": {
"TITLE_LABEL": "Nome",
"TITLE_PLACEHOLDER": "Digite um nome para o aplicativo",
"TITLE_ERROR": "É necessário um nome para o aplicativo",
"URL_LABEL": "Endpoint",
"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": {
"HEADER": "Adicionar um novo aplicativo",

View File

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

View File

@ -8,6 +8,7 @@ import { frontendURL } from '../../helper/URLHelper';
import helpcenterRoutes from './helpcenter/helpcenter.routes';
import campaignsRoutes from './campaigns/campaigns.routes';
import { routes as captainRoutes } from './captain/captain.routes';
import dashboardAppsRoutes from './dashboardApps/dashboardApps.routes';
import AppContainer from './Dashboard.vue';
import Suspended from './suspended/Index.vue';
@ -26,6 +27,7 @@ export default {
...notificationRoutes,
...helpcenterRoutes.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 NextButton from 'dashboard/components-next/button/Button.vue';
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
export default {
components: {
NextButton,
ToggleSwitch,
},
props: {
show: {
@ -41,6 +43,7 @@ export default {
isLoading: false,
app: {
title: '',
show_on_sidebar: false,
content: {
type: 'frame',
url: '',
@ -62,6 +65,7 @@ export default {
if (this.mode === 'UPDATE' && this.selectedAppData) {
this.app.title = this.selectedAppData.title;
this.app.content = this.selectedAppData.content[0];
this.app.show_on_sidebar = this.selectedAppData.show_on_sidebar ?? false;
}
},
methods: {
@ -69,6 +73,7 @@ export default {
// Reset the data once closed
this.app = {
title: '',
show_on_sidebar: false,
content: { type: 'frame', url: '' },
};
this.$emit('close');
@ -83,6 +88,7 @@ export default {
const action = this.mode.toLowerCase();
const payload = {
title: this.app.title,
show_on_sidebar: this.app.show_on_sidebar,
content: [this.app.content],
};
@ -149,6 +155,19 @@ export default {
@input="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">
<NextButton
faded

View File

@ -18,6 +18,11 @@ export const getters = {
getRecords(_state) {
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 = {

View File

@ -26,4 +26,30 @@ describe('#getters', () => {
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
#
# id :bigint not null, primary key
# content :jsonb
# title :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# user_id :bigint
# id :bigint not null, primary key
# content :jsonb
# show_on_sidebar :boolean default(FALSE), not null
# title :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# user_id :bigint
#
# Indexes
#

View File

@ -1,4 +1,5 @@
json.id resource.id
json.title resource.title
json.content resource.content
json.show_on_sidebar resource.show_on_sidebar
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.
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
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@ -773,6 +773,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do
t.bigint "user_id"
t.datetime "created_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 ["user_id"], name: "index_dashboard_apps_on_user_id"
end

View File

@ -55,6 +55,9 @@ RSpec.describe 'DashboardAppsController', type: :request 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_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(: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' }] } } }
@ -86,6 +89,17 @@ RSpec.describe 'DashboardAppsController', type: :request do
expect(json_response['content'][0]['type']).to eq payload[:dashboard_app][:content][0][:type]
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
expect do
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
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!(: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]['type']).to eq payload[:dashboard_app][:content][0][:type]
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