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:
parent
14f43a6bc5
commit
7f0748460e
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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'"
|
||||
|
||||
@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -328,6 +328,7 @@
|
||||
"HOME": "Home",
|
||||
"AGENTS": "Agents",
|
||||
"AGENT_BOTS": "Bots",
|
||||
"APPS": "Apps",
|
||||
"AUDIT_LOGS": "Audit Logs",
|
||||
"INBOXES": "Inboxes",
|
||||
"NOTIFICATIONS": "Notifications",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -328,6 +328,7 @@
|
||||
"HOME": "Principal",
|
||||
"AGENTS": "Agentes",
|
||||
"AGENT_BOTS": "Robôs",
|
||||
"APPS": "Apps",
|
||||
"AUDIT_LOGS": "Auditoria",
|
||||
"INBOXES": "Caixas de Entrada",
|
||||
"NOTIFICATIONS": "Notificações",
|
||||
|
||||
@ -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,
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -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>
|
||||
@ -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 }),
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -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
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
#
|
||||
# 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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user