feat: Update settings pages UI (#13191)

This commit is contained in:
Sivin Varghese
2026-01-15 12:30:53 +05:30
committed by GitHub
parent f6b2bc170e
commit ea592481d1
120 changed files with 2388 additions and 2023 deletions
@@ -122,6 +122,8 @@
--overlay-avatar: 255, 255, 255, 0.67;
--button-color: 255 255 255;
--button-hover-color: 255, 255, 255, 0.2;
--label-background: 247 247 247;
--label-border: 0, 0, 0, 0.04;
--alpha-1: 215, 215, 215, 0.22;
--alpha-2: 196, 197, 198, 0.22;
@@ -252,6 +254,8 @@
--overlay-avatar: 0, 0, 0, 0.05;
--button-color: 42 43 51;
--button-hover-color: 0, 0, 0, 0.15;
--label-background: 36 38 45;
--label-border: 255, 255, 255, 0.03;
--alpha-1: 35, 36, 42, 0.8;
--alpha-2: 147, 153, 176, 0.12;
@@ -101,21 +101,23 @@ body {
/* heading-1: Large heading for pages and panels */
.text-heading-1 {
@apply font-interDisplay text-base font-medium;
line-height: 24px; /* 150% */
@apply font-inter text-lg font-520;
line-height: 24px; /* 133.333% */
letter-spacing: -0.27px;
}
/* heading-2: Secondary heading for sections */
.text-heading-2 {
@apply font-interDisplay text-sm font-medium;
line-height: 21px; /* 150% */
@apply font-inter text-base font-medium;
line-height: 24px; /* 133.333% */
letter-spacing: -0.27px;
}
/* heading-3: For card headings, breadcrumbs, subsections */
.text-heading-3 {
@apply font-interDisplay text-sm font-medium;
@apply font-inter text-sm font-medium;
line-height: 21px; /* 150% */
letter-spacing: 0.14px;
letter-spacing: -0.27px;
}
/* label: Standard label text for form fields */
@@ -22,11 +22,11 @@ const handleButtonClick = () => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
<header
class="sticky top-0 z-10 px-6 lg:px-0 after:absolute after:inset-x-0 after:-bottom-4 after:bg-gradient-to-b after:from-n-surface-1 after:from-10% after:dark:from-0% after:to-transparent after:h-4 after:pointer-events-none"
class="sticky top-0 z-10 px-6 after:absolute after:inset-x-0 after:-bottom-4 after:bg-gradient-to-b after:from-n-surface-1 after:from-10% after:dark:from-0% after:to-transparent after:h-4 after:pointer-events-none"
>
<div class="w-full max-w-5xl mx-auto lg:px-6">
<div class="w-full max-w-5xl mx-auto">
<div class="flex items-center justify-between w-full h-20 gap-2">
<span class="text-xl font-medium text-n-slate-12">
<span class="text-heading-1 text-n-slate-12">
{{ headerTitle }}
</span>
<div
@@ -45,8 +45,8 @@ const handleButtonClick = () => {
</div>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-5xl mx-auto py-4 lg:px-6">
<main class="flex-1 px-6 overflow-y-auto">
<div class="w-full mx-auto max-w-5xl py-4">
<slot name="default" />
</div>
</main>
@@ -16,12 +16,12 @@ const emit = defineEmits(['search', 'update:sort']);
<template>
<header
class="sticky top-0 z-10 after:absolute after:inset-x-0 after:-bottom-4 after:bg-gradient-to-b after:from-n-surface-1 after:from-10% after:dark:from-0% after:to-transparent after:h-4 after:pointer-events-none"
class="sticky top-0 z-10 after:absolute after:inset-x-0 after:-bottom-4 after:bg-gradient-to-b after:from-n-surface-1 after:from-10% after:dark:from-0% after:to-transparent after:h-4 after:pointer-events-none px-6"
>
<div
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
>
<span class="text-xl font-medium truncate text-n-slate-12">
<span class="text-heading-1 truncate text-n-slate-12">
{{ headerTitle }}
</span>
<div class="flex items-center flex-row flex-shrink-0 gap-2">
@@ -32,8 +32,8 @@ const updateCurrentPage = page => {
@search="emit('search', $event)"
@update:sort="emit('update:sort', $event)"
/>
<main class="flex-1 overflow-y-auto">
<div class="w-full mx-auto max-w-[60rem]">
<main class="flex-1 px-6 overflow-y-auto">
<div class="w-full mx-auto max-w-5xl py-4">
<slot name="default" />
</div>
</main>
@@ -43,7 +43,7 @@ const updateCurrentPage = page => {
:current-page="currentPage"
:total-items="totalItems"
:items-per-page="25"
class="max-w-[105rem]"
class="max-w-[67rem]"
@update:current-page="updateCurrentPage"
/>
</footer>
@@ -36,7 +36,7 @@ const emit = defineEmits([
<div
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[105rem] after:absolute after:inset-x-0 after:-bottom-4 after:bg-gradient-to-b after:from-n-surface-1 after:from-10% after:dark:from-0% after:to-transparent after:h-4 after:pointer-events-none"
>
<span class="text-lg leading-6 font-520 truncate text-n-slate-12">
<span class="text-heading-1 truncate text-n-slate-12">
{{ headerTitle }}
</span>
<div class="flex items-center flex-col sm:flex-row flex-shrink-0 gap-4">
@@ -86,7 +86,7 @@ const openFilter = () => {
@clear-filters="emit('clearFilters')"
/>
<main class="flex-1 overflow-y-auto">
<div class="w-full mx-auto max-w-[105rem]">
<div class="w-full mx-auto">
<ContactsActiveFiltersPreview
v-if="showActiveFiltersPreview"
:active-segment="activeSegment"
@@ -102,7 +102,6 @@ const openFilter = () => {
:current-page="currentPage"
:total-items="totalItems"
:items-per-page="itemsPerPage"
class="max-w-[105rem]"
@update:current-page="updateCurrentPage"
/>
</footer>
@@ -145,12 +145,12 @@ const selectedModel = computed({
</div>
<!-- RIGHT SECTION -->
<div class="flex items-center justify-end gap-2 flex-shrink-0">
<div class="flex items-center justify-end gap-1.5 flex-shrink-0">
<div v-if="showLabelsSection" class="min-w-0 w-full">
<CardLabels
:labels="chat.labels"
disable-toggle
class="my-0 [&>div]:justify-end"
class="my-0 [&>div]:justify-end justify-end"
/>
</div>
@@ -12,7 +12,7 @@ defineProps({
<template>
<div :title="inbox.name" class="flex items-center gap-0.5 min-w-0">
<ChannelIcon :inbox="inbox" class="size-4 flex-shrink-0 text-n-slate-11" />
<span class="truncate text-label-small text-n-slate-12">
<span class="truncate text-label-small text-n-slate-11">
{{ inbox.name }}
</span>
</div>
@@ -1,8 +1,10 @@
<script setup>
import { computed } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Label from 'dashboard/components-next/label/Label.vue';
import AttributeBadge from 'dashboard/components-next/CustomAttributes/AttributeBadge.vue';
import { computed } from 'vue';
const props = defineProps({
attribute: {
@@ -18,7 +20,7 @@ const props = defineProps({
const emit = defineEmits(['edit', 'delete']);
const iconByType = {
text: 'i-lucide-align-justify',
text: 'i-lucide-menu',
checkbox: 'i-lucide-circle-check-big',
list: 'i-lucide-list',
date: 'i-lucide-calendar',
@@ -28,61 +30,60 @@ const iconByType = {
const attributeIcon = computed(() => {
const typeKey = props.attribute.type?.toLowerCase();
return iconByType[typeKey] || 'i-lucide-align-justify';
return iconByType[typeKey] || 'i-lucide-menu';
});
</script>
<template>
<div
class="flex flex-col gap-2 p-4 bg-n-solid-1 rounded-2xl outline outline-1 outline-n-container"
>
<div class="flex flex-wrap gap-2 justify-between items-center">
<div class="flex flex-wrap gap-2 items-center min-w-0">
<h4 class="text-sm font-medium truncate text-n-slate-12">
{{ attribute.label }}
</h4>
<div class="w-px h-3 bg-n-strong" />
<div class="flex gap-2 items-center text-sm text-n-slate-11">
<div class="flex items-center gap-1.5 text-n-slate-11">
<Icon :icon="attributeIcon" class="size-4" />
<span class="text-sm">{{ attribute.type }}</span>
<div class="flex flex-col py-4 divide-y divide-n-weak">
<div class="flex justify-between flex-row items-center gap-4 min-w-0">
<div class="flex items-center gap-4 min-w-0">
<div
class="flex items-center flex-shrink-0 size-10 justify-center rounded-xl outline outline-1 outline-n-weak -outline-offset-1"
>
<Icon :icon="attributeIcon" class="size-4 text-n-slate-11" />
</div>
<div class="flex flex-col gap-1.5 items-start min-w-0">
<div class="flex items-center gap-2">
<h4 class="text-heading-3 truncate text-n-slate-12">
{{ attribute.label }}
</h4>
<div class="flex items-center gap-1.5">
<Label :label="attribute.type" compact />
<AttributeBadge
v-for="badge in badges"
:key="badge.type"
:type="badge.type"
/>
</div>
</div>
<div class="w-px h-3 bg-n-weak" />
<div class="flex items-center gap-1.5 text-n-slate-11">
<Icon icon="i-lucide-key-round" class="size-4" />
<span class="line-clamp-1 text-sm">{{ attribute.value }}</span>
<div class="grid grid-cols-[auto_1fr] items-center gap-1.5">
<Icon icon="i-lucide-key-round" class="size-3.5 text-n-slate-11" />
<div class="flex items-center gap-2 min-w-0">
<span class="text-body-main text-n-slate-11 truncate">
{{ attribute.value }}
</span>
<template
v-if="attribute.attribute_description || attribute.description"
>
<div class="w-px h-3 rounded-lg bg-n-weak flex-shrink-0" />
<span class="text-body-main text-n-slate-11 truncate">
{{ attribute.attribute_description || attribute.description }}
</span>
</template>
</div>
</div>
</div>
</div>
<div class="flex gap-2 items-center">
<AttributeBadge
v-for="badge in badges"
:key="badge.type"
:type="badge.type"
/>
<div
v-if="badges.length > 0"
class="w-px h-3 bg-n-strong ltr:ml-1.5 rtl:mr-1.5"
/>
<div class="flex gap-3 justify-end flex-shrink-0">
<Button
icon="i-lucide-pencil-line"
size="sm"
color="slate"
ghost
icon="i-woot-edit-pen"
slate
sm
@click="emit('edit', attribute)"
/>
<div class="w-px h-3 bg-n-strong" />
<Button
icon="i-lucide-trash"
size="sm"
color="slate"
ghost
@click="emit('delete', attribute)"
/>
<Button icon="i-woot-bin" slate sm @click="emit('delete', attribute)" />
</div>
</div>
<p class="mb-0 text-sm text-n-slate-11">
{{ attribute.attribute_description || attribute.description || '' }}
</p>
</div>
</template>
@@ -2,6 +2,7 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Label from 'dashboard/components-next/label/Label.vue';
const props = defineProps({
type: {
@@ -18,11 +19,13 @@ const attributeConfig = {
colorClass: 'text-n-blue-11',
icon: 'i-lucide-message-circle',
labelKey: 'ATTRIBUTES_MGMT.BADGES.PRE_CHAT',
color: 'slate',
},
resolution: {
colorClass: 'text-n-teal-11',
icon: 'i-lucide-circle-check-big',
labelKey: 'ATTRIBUTES_MGMT.BADGES.RESOLUTION',
color: 'slate',
},
};
const config = computed(
@@ -31,12 +34,9 @@ const config = computed(
</script>
<template>
<div
class="flex gap-1 justify-center items-center px-1.5 py-1 rounded-md shadow outline-1 outline outline-n-container bg-n-solid-2"
>
<Icon :icon="config.icon" class="size-4" :class="config.colorClass" />
<span class="text-xs" :class="config.colorClass">{{
t(config.labelKey)
}}</span>
</div>
<Label :label="t(config.labelKey)" :color="config.color" compact>
<template #icon>
<Icon :icon="config.icon" class="size-3.5 text-n-slate-12" />
</template>
</Label>
</template>
@@ -26,7 +26,7 @@ defineProps({
class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden"
>
<div
class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[28rem]"
class="relative w-full max-w-5xl mx-auto overflow-hidden h-full max-h-[28rem]"
>
<div
v-if="showBackdrop"
@@ -73,9 +73,9 @@ const onClickCreateButton = () => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
<header
class="sticky top-0 z-10 px-6 pb-3 lg:px-0 after:absolute after:inset-x-0 after:-bottom-4 after:bg-gradient-to-b after:from-n-surface-1 after:from-10% after:dark:from-0% after:to-transparent after:h-4 after:pointer-events-none"
class="sticky top-0 z-10 px-6 pb-3 after:absolute after:inset-x-0 after:-bottom-4 after:bg-gradient-to-b after:from-n-surface-1 after:from-10% after:dark:from-0% after:to-transparent after:h-4 after:pointer-events-none"
>
<div class="w-full max-w-5xl mx-auto lg:px-6">
<div class="w-full max-w-5xl mx-auto">
<div class="flex items-center justify-between gap-3">
<div
v-if="showHeaderTitle"
@@ -83,7 +83,7 @@ const onClickCreateButton = () => {
>
<span
v-if="activePortalName"
class="text-lg font-520 text-n-slate-12 ltr:mr-1 rtl:ml-1"
class="text-heading-1 text-n-slate-12 ltr:mr-1 rtl:ml-1"
>
{{ activePortalName }}
</span>
@@ -91,7 +91,7 @@ const onClickCreateButton = () => {
<OnClickOutside @trigger="showPortalSwitcher = false">
<Button
icon="i-lucide-chevron-down"
variant="ghost"
:variant="showPortalSwitcher ? 'faded' : 'ghost'"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-slate-3 hover:bg-n-slate-3 [&>span]:size-4"
@@ -133,8 +133,8 @@ const onClickCreateButton = () => {
<slot name="header-actions" />
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-5xl mx-auto py-3 lg:px-6">
<main class="flex-1 px-6 overflow-y-auto">
<div class="w-full max-w-5xl mx-auto py-3">
<slot name="content" />
</div>
</main>
@@ -143,7 +143,7 @@ const onClickCreateButton = () => {
:current-page="currentPage"
:total-items="totalItems"
:items-per-page="itemsPerPage"
class="max-w-[105rem]"
class="max-w-[67rem]"
@update:current-page="updateCurrentPage"
/>
</footer>
@@ -23,7 +23,7 @@ const handleButtonClick = button => {
class="flex flex-col items-start gap-3 justify-between ltr:pl-4 ltr:pr-2 rtl:pl-2 rtl:pr-4 my-2 pt-2"
>
<div class="flex items-center justify-between gap-2 flex-1 w-full">
<span class="text-heading-1 text-n-slate-12">{{ title }}</span>
<span class="text-heading-2 text-n-slate-12">{{ title }}</span>
<div class="flex items-center h-6">
<Button
v-for="button in buttons"
@@ -121,7 +121,8 @@ const borderRadiusClass = computed(() => {
if (props.size <= 16) return 'rounded'; // 4px
if (props.size <= 24) return 'rounded-md'; // 6px
if (props.size <= 32) return 'rounded-lg'; // 8px
return 'rounded-lg'; // 16px
if (props.size <= 48) return 'rounded-xl'; // 12px
return 'rounded-2xl'; // 16px
});
const avatarStyles = computed(() => ({
@@ -117,9 +117,9 @@ const handleCreateAssistant = () => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
<header
class="sticky top-0 z-10 px-6 after:absolute after:inset-x-0 after:-bottom-4 after:bg-gradient-to-b after:from-n-surface-1 after:from-10% after:dark:from-0% after:to-transparent after:h-4 after:pointer-events-none after:z-10"
class="sticky top-0 z-10 px-6 after:absolute after:inset-x-0 after:-bottom-4 after:bg-gradient-to-b after:from-n-surface-1 after:from-10% after:dark:from-0% after:to-transparent after:h-4 after:pointer-events-none"
>
<div class="w-full max-w-[60rem] mx-auto">
<div class="w-full max-w-5xl mx-auto">
<div
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
>
@@ -142,7 +142,9 @@ const handleCreateAssistant = () => {
>
<Button
icon="i-lucide-chevron-down"
variant="ghost"
:variant="
showAssistantSwitcherDropdown ? 'faded' : 'ghost'
"
color="slate"
size="xs"
:disabled="isFetchingAssistants"
@@ -211,7 +213,7 @@ const handleCreateAssistant = () => {
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto">
<div class="w-full max-w-[60rem] h-full mx-auto py-4">
<div class="w-full max-w-5xl h-full mx-auto py-4">
<slot v-if="!showPaywall" name="controls" />
<div
v-if="isFetching"
@@ -234,7 +236,7 @@ const handleCreateAssistant = () => {
:current-page="currentPage"
:total-items="totalCount"
:items-per-page="itemsPerPage"
class="max-w-[105rem]"
class="max-w-[67rem]"
@update:current-page="handlePageChange"
/>
</footer>
@@ -27,7 +27,7 @@ const openBilling = () => {
<template>
<div
class="w-full max-w-[60rem] mx-auto h-full max-h-[448px] grid place-content-center"
class="w-full max-w-5xl mx-auto h-full max-h-[448px] grid place-content-center"
>
<BasePaywallModal
class="mx-auto"
@@ -50,7 +50,7 @@ const openLink = link => {
]"
>
<section
class="absolute top-full mt-6 ltr:left-0 rtl:right-0 outline outline-1 outline-n-weak bg-n-alpha-3 backdrop-blur-[100px] rounded-xl p-4 w-80"
class="absolute top-full mt-6 ltr:left-0 rtl:right-0 outline outline-1 outline-n-weak bg-n-alpha-3 backdrop-blur-[100px] rounded-xl p-4 w-80 z-20"
>
<div
class="absolute -top-[0.77rem] ltr:left-12 rtl:right-12 w-6 h-6 ltr:rotate-45 rtl:-rotate-45 rtl:rounded-tr ltr:rounded-tl rtl:border-r ltr:border-l border-t border-n-weak bg-n-alpha-3 z-10"
@@ -1,32 +1,70 @@
<script setup>
defineProps({
import { computed } from 'vue';
const props = defineProps({
label: {
type: Object,
type: [Object, String],
required: true,
},
compact: {
type: Boolean,
default: false,
},
color: {
type: String,
default: 'slate',
validator: value =>
['slate', 'amber', 'teal', 'ruby', 'blue', 'iris'].includes(value),
},
});
const COLOR_CLASSES = {
slate: 'bg-n-label-color outline-n-label-border text-n-slate-12',
amber: 'bg-n-amber-2 outline-n-amber-4 text-n-amber-11',
teal: 'bg-n-teal-2 outline-n-teal-4 text-n-teal-11',
ruby: 'bg-n-ruby-2 outline-n-ruby-4 text-n-ruby-11',
blue: 'bg-n-blue-2 outline-n-blue-4 text-n-blue-11',
iris: 'bg-n-iris-2 outline-n-iris-4 text-n-iris-11',
};
const isStringLabel = computed(() => typeof props.label === 'string');
const labelTitle = computed(() => {
return isStringLabel.value ? props.label : props.label?.title;
});
const labelDescription = computed(() => {
return (!isStringLabel.value && props.label?.description) || '';
});
const labelColor = computed(() => {
return isStringLabel.value ? null : props.label.color;
});
const colorClasses = computed(() => COLOR_CLASSES[props.color]);
</script>
<template>
<div
:title="label.description"
class="bg-n-button-color gap-1.5 rounded-lg -outline-offset-1 outline outline-1 outline-n-container inline-flex items-center flex-shrink-0"
:class="compact ? 'px-1.5 h-6 gap-1 rounded-md' : 'px-2.5 h-8 rounded-lg'"
:title="labelDescription"
class="gap-1.5 rounded-lg -outline-offset-1 outline outline-1 inline-flex items-center flex-shrink-0"
:class="[
colorClasses,
compact ? 'px-1.5 h-6 gap-1 rounded-md' : 'px-2.5 h-8 rounded-lg',
]"
>
<span
v-if="labelColor"
class="rounded-sm flex-shrink-0"
:class="compact ? 'size-1.5' : 'size-2'"
:style="{ background: label.color }"
:style="{ background: labelColor }"
/>
<slot v-else name="icon" />
<span
class="text-n-slate-12 whitespace-nowrap"
class="whitespace-nowrap"
:class="compact ? 'text-label-small' : 'text-label !font-420'"
>
{{ label.title }}
{{ labelTitle }}
</span>
<slot name="action" />
</div>
@@ -81,7 +81,7 @@ const showDivider = index => {
<template v-for="(tab, index) in tabs" :key="index">
<button
:ref="el => (tabRefs[index] = el)"
class="relative z-10 px-4 truncate py-1.5 font-460 text-sm border-0 outline-1 outline-transparent rounded-lg transition-all duration-200 ease-out hover:text-n-brand active:scale-[1.02]"
class="relative z-10 px-4 truncate py-1.5 text-label border-0 outline-1 outline-transparent rounded-lg transition-all duration-200 ease-out hover:text-n-brand active:scale-[1.02]"
:class="[
activeTab === index
? 'text-n-blue-11 scale-100'
@@ -0,0 +1,175 @@
<script setup>
import { ref } from 'vue';
import { BaseTable, BaseTableRow, BaseTableCell } from './index';
import Button from '../button/Button.vue';
import Avatar from '../avatar/Avatar.vue';
import ToggleSwitch from '../switch/Switch.vue';
const automationData = ref([
{
id: 1,
name: 'Welcome Message',
description: 'Send welcome message to new contacts',
active: true,
createdOn: 'Apr 21, 2022',
},
{
id: 2,
name: 'Auto-assign to Sales',
description: 'Automatically assign sales conversations to sales team',
active: false,
createdOn: 'May 15, 2022',
},
{
id: 3,
name: 'Tag Premium Users',
description: 'Add premium tag to conversations from premium users',
active: true,
createdOn: 'Jun 10, 2022',
},
]);
const agentData = ref([
{
id: 1,
name: 'John Doe',
email: 'john@example.com',
role: 'Administrator',
verified: true,
avatarUrl: '',
},
{
id: 2,
name: 'Jane Smith',
email: 'jane@example.com',
role: 'Agent',
verified: true,
avatarUrl: '',
},
]);
const emptyData = ref([]);
const headers = ['Name', 'Active', 'Created on', 'Actions'];
const agentHeaders = ['Agent', 'Role', 'Verification', 'Actions'];
</script>
<template>
<Story title="Components/Table" :layout="{ type: 'grid', width: '100%' }">
<!-- Basic Table -->
<Variant title="Basic Table">
<div class="p-4 bg-n-surface-1">
<BaseTable :headers="headers" :items="automationData">
<template #row="{ items }">
<BaseTableRow
v-for="automation in items"
:key="automation.id"
:item="automation"
>
<template #default>
<BaseTableCell>
<div class="flex items-center gap-2 min-w-0 max-w-full">
<span
class="text-body-main text-n-slate-12 truncate min-w-0 flex-1"
>
{{ automation.name }}
</span>
<div class="w-px h-3 rounded-lg bg-n-weak flex-shrink-0" />
<span
class="text-body-main text-n-slate-11 truncate min-w-0 flex-1"
>
{{ automation.description }}
</span>
</div>
</BaseTableCell>
<BaseTableCell>
<div class="flex justify-center">
<ToggleSwitch v-model="automation.active" />
</div>
</BaseTableCell>
<BaseTableCell>
<span
class="text-body-main text-n-slate-12 whitespace-nowrap"
>
{{ automation.createdOn }}
</span>
</BaseTableCell>
<BaseTableCell align="end" class="w-24">
<div class="flex gap-3 justify-end flex-shrink-0">
<Button icon="i-woot-edit-pen" slate sm />
<Button icon="i-woot-bin" slate sm />
<Button icon="i-woot-clone" slate sm />
</div>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
</BaseTable>
</div>
</Variant>
<!-- Table with Avatars -->
<Variant title="Table with Avatars">
<div class="p-4 bg-n-surface-1">
<BaseTable :headers="agentHeaders" :items="agentData">
<template #row="{ items }">
<BaseTableRow v-for="agent in items" :key="agent.id" :item="agent">
<template #default>
<BaseTableCell>
<div class="flex items-center gap-3 min-w-0">
<Avatar :user="agent" :size="40" class="flex-shrink-0" />
<div class="flex flex-col min-w-0">
<span class="text-body-main text-n-slate-12 truncate">
{{ agent.name }}
</span>
<span class="text-body-main text-n-slate-11 truncate">
{{ agent.email }}
</span>
</div>
</div>
</BaseTableCell>
<BaseTableCell>
<span
class="text-body-main text-n-slate-12 whitespace-nowrap"
>
{{ agent.role }}
</span>
</BaseTableCell>
<BaseTableCell>
<span
class="text-body-main text-n-slate-12 whitespace-nowrap"
>
{{ agent.verified ? 'Verified' : 'Pending' }}
</span>
</BaseTableCell>
<BaseTableCell align="end" class="w-24">
<div class="flex gap-3 justify-end flex-shrink-0">
<Button icon="i-woot-edit-pen" slate sm />
<Button icon="i-woot-bin" slate sm />
</div>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
</BaseTable>
</div>
</Variant>
<!-- Empty State -->
<Variant title="Empty State">
<div class="p-4 bg-n-surface-1">
<BaseTable
:headers="headers"
:items="emptyData"
no-data-message="No automation rules found"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,60 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
headers: {
type: Array,
default: () => [],
},
items: {
type: Array,
default: () => [],
},
noDataMessage: {
type: String,
default: '',
},
loading: {
type: Boolean,
default: false,
},
});
const hasHeaderSlot = computed(() => !!props.headers.length);
const showHeaders = computed(
() => hasHeaderSlot.value && props.items.length > 0
);
</script>
<template>
<div class="w-full">
<table class="min-w-full table-auto divide-y divide-n-weak">
<thead v-if="showHeaders">
<tr>
<th
v-for="(header, index) in headers"
:key="index"
class="py-4 ltr:pr-4 rtl:pl-4 text-start text-heading-3 text-n-slate-12 capitalize"
>
<slot :name="`header-${index}`" :header="header">
{{ header }}
</slot>
</th>
</tr>
</thead>
<tbody class="divide-y divide-n-weak text-n-slate-11">
<template v-if="items.length">
<slot name="row" :items="items" />
</template>
<tr v-else-if="noDataMessage && !loading">
<td
:colspan="headers.length || 1"
class="py-20 text-center text-body-main !text-base text-n-slate-11"
>
{{ noDataMessage }}
</td>
</tr>
</tbody>
</table>
</div>
</template>
@@ -0,0 +1,22 @@
<script setup>
defineProps({
align: {
type: String,
default: 'start',
validator: value => ['start', 'center', 'end'].includes(value),
},
});
</script>
<template>
<td
class="py-3 ltr:pr-4 rtl:pl-4 text-body-main"
:class="{
'text-start': align === 'start',
'text-center': align === 'center',
'text-end': align === 'end',
}"
>
<slot />
</td>
</template>
@@ -0,0 +1,14 @@
<script setup>
defineProps({
item: {
type: Object,
required: true,
},
});
</script>
<template>
<tr>
<slot :item="item" />
</tr>
</template>
@@ -0,0 +1,3 @@
export { default as BaseTable } from './BaseTable.vue';
export { default as BaseTableRow } from './BaseTableRow.vue';
export { default as BaseTableCell } from './BaseTableCell.vue';
@@ -18,7 +18,7 @@ const props = defineProps({
},
year: {
type: [Number, String],
required: true,
default: '',
},
});
@@ -29,7 +29,7 @@ const onToggle = () => {
class="flex items-center select-none w-full m-0 cursor-grab justify-between drag-handle px-4 py-0 focus-visible:outline-none outline-none !transition-none"
@click.stop="onToggle"
>
<h5 class="text-n-slate-12 text-heading-2 mb-0 py-0 pr-2 pl-0">
<h5 class="text-n-slate-12 text-heading-3 mb-0 py-0 pr-2 pl-0">
{{ title }}
</h5>
<div
@@ -53,7 +53,7 @@ const toggleConversationLayout = () => {
<template>
<div class="flex items-center justify-between gap-2 px-4 h-[3.25rem]">
<div class="flex items-center justify-center min-w-0">
<h1 class="text-heading-1 truncate text-n-slate-12" :title="pageTitle">
<h1 class="text-heading-2 truncate text-n-slate-12" :title="pageTitle">
{{ pageTitle }}
</h1>
<span
@@ -1,31 +0,0 @@
<script setup>
defineProps({
title: {
type: String,
default: '',
},
description: {
type: String,
default: '',
},
});
</script>
<template>
<div class="flex flex-col items-start w-full gap-6">
<div class="flex flex-col w-full gap-4">
<h4 v-if="title" class="text-lg font-medium text-n-slate-12">
{{ title }}
</h4>
<div class="flex flex-row items-center justify-between">
<div class="flex-grow h-px bg-n-weak" />
</div>
<p v-if="description" class="mb-0 text-sm font-normal text-n-slate-12">
{{ description }}
</p>
</div>
<div class="flex flex-col w-full gap-6">
<slot />
</div>
</div>
</template>
@@ -11,7 +11,7 @@ defineProps({
<h6
class="flex items-center gap-3 text-base text-center w-100 text-n-slate-11"
>
<span class="text-base font-medium text-n-slate-12">
<span class="text-body-main !text-base text-n-slate-12">
{{ message }}
</span>
<Spinner class="text-n-brand" />
@@ -15,7 +15,7 @@ export default {
<template>
<div class="border-b border-solid border-n-weak/60">
<div class="max-w-6xl w-full mx-auto pt-4 pb-0 px-8">
<div class="max-w-6xl w-full mx-auto pt-4 pb-0 px-6">
<h2 class="text-2xl text-n-slate-12 mb-1 font-medium">
{{ headerTitle }}
</h2>
@@ -134,7 +134,7 @@ const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
<BackButton v-if="showBackButton" :back-url="backButtonUrl" />
<div class="flex items-center gap-1 min-w-0 ltr:mr-2 rtl:ml-2">
<span class="text-heading-1 text-n-slate-12 truncate min-w-0">
<span class="text-heading-2 text-n-slate-12 truncate min-w-0">
{{ currentContact.name }}
</span>
<Icon
@@ -4,6 +4,8 @@
"LOADING_EDITOR": "Loading editor...",
"DESCRIPTION": "Agent Bots are like the most fabulous members of your team. They can handle the small stuff, so you can focus on the stuff that matters. Give them a try. You can manage your bots from this page or create new ones using the 'Add Bot' button.",
"LEARN_MORE": "Learn about agent bots",
"SEARCH_PLACEHOLDER": "Search bots...",
"NO_RESULTS": "No bots found matching your search",
"GLOBAL_BOT": "System bot",
"GLOBAL_BOT_BADGE": "System",
"AVATAR": {
@@ -34,7 +36,8 @@
"LOADING": "Fetching bots...",
"TABLE_HEADER": {
"DETAILS": "Bot Details",
"URL": "Webhook URL"
"URL": "Webhook URL",
"ACTIONS": "Actions"
}
},
"DELETE": {
@@ -96,6 +96,8 @@
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
}
},
"SEARCH_PLACEHOLDER": "Search agents..",
"NO_RESULTS": "No agents found matching your search",
"SEARCH": {
"NO_RESULTS": "No results found."
},
@@ -5,6 +5,8 @@
"LOADING": "Fetching custom attributes",
"DESCRIPTION": "A custom attribute tracks additional details about your contacts or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
"LEARN_MORE": "Learn more about custom attributes",
"SEARCH_PLACEHOLDER": "Search attributes...",
"NO_RESULTS": "No attributes found matching your search",
"ATTRIBUTE_MODELS": {
"CONVERSATION": "Conversation",
"CONTACT": "Contact"
@@ -5,6 +5,8 @@
"LEARN_MORE": "Learn more about automation",
"HEADER_BTN_TXT": "Add Automation Rule",
"LOADING": "Fetching automation rules",
"SEARCH_PLACEHOLDER": "Search automation rules...",
"NO_RESULTS": "No automation rules found matching your search",
"ADD": {
"TITLE": "Add Automation Rule",
"SUBMIT": "Create",
@@ -42,9 +44,9 @@
"LIST": {
"TABLE_HEADER": {
"NAME": "Name",
"DESCRIPTION": "Description",
"ACTIVE": "Active",
"CREATED_ON": "Created on"
"CREATED_ON": "Created on",
"ACTIONS": "Actions"
},
"404": "No automation rules found"
},
@@ -5,6 +5,8 @@
"DESCRIPTION": "Canned Responses are pre-written reply templates that help you quickly respond to a conversation. Agents can type the '/' character followed by the shortcode to insert a canned response during a conversation. ",
"HEADER_BTN_TXT": "Add canned response",
"LOADING": "Fetching canned responses...",
"SEARCH_PLACEHOLDER": "Search canned responses...",
"NO_RESULTS": "No canned responses found matching your search",
"SEARCH_404": "There are no items matching this query.",
"LIST": {
"404": "There are no canned responses available in this account.",
@@ -5,6 +5,8 @@
"DESCRIPTION": "Custom roles are roles that are created by the account owner or admin. These roles can be assigned to agents to define their access and permissions within the account. Custom roles can be created with specific permissions and access levels to suit the requirements of the organization.",
"HEADER_BTN_TXT": "Add custom role",
"LOADING": "Fetching custom roles...",
"SEARCH_PLACEHOLDER": "Search custom roles...",
"NO_RESULTS": "No custom roles found matching your search",
"SEARCH_404": "There are no items matching this query.",
"PAYWALL": {
"TITLE": "Upgrade to create custom roles",
@@ -3,6 +3,8 @@
"HEADER": "Inboxes",
"DESCRIPTION": "A channel is the mode of communication your customer chooses to interact with you. An inbox is where you manage interactions for a specific channel. It can include communications from various sources such as email, live chat, and social media.",
"LEARN_MORE": "Learn more about inboxes",
"SEARCH_PLACEHOLDER": "Search inboxes...",
"NO_RESULTS": "No inboxes found matching your search",
"RECONNECTION_REQUIRED": "Your inbox is disconnected. You won't receive new messages until you reauthorize it.",
"CLICK_TO_RECONNECT": "Click here to reconnect.",
"WHATSAPP_REGISTRATION_INCOMPLETE": "Your WhatsApp Business registration isnt complete. Please check your display name status in Meta Business Manager before reconnecting.",
@@ -3,6 +3,8 @@
"FETCHING": "Fetching Integrations",
"NO_HOOK_CONFIGURED": "There are no {integrationId} integrations configured in this account.",
"HEADER": "Applications",
"SEARCH_PLACEHOLDER": "Search...",
"NO_RESULTS": "No results found matching your search",
"STATUS": {
"ENABLED": "Enabled",
"DISABLED": "Disabled"
@@ -31,6 +33,7 @@
"LIST": {
"FETCHING": "Fetching integration hooks",
"INBOX": "Inbox",
"ACTIONS": "Actions",
"DELETE": {
"BUTTON_TEXT": "Delete"
}
@@ -1,6 +1,7 @@
{
"INTEGRATION_SETTINGS": {
"SHOPIFY": {
"HEADER": "Shopify",
"DELETE": {
"TITLE": "Delete Shopify Integration",
"MESSAGE": "Are you sure you want to delete the Shopify integration?"
@@ -19,6 +20,8 @@
"DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.",
"LEARN_MORE": "Learn more about integrations",
"LOADING": "Fetching integrations",
"SEARCH_PLACEHOLDER": "Search integrations...",
"NO_RESULTS": "No integrations found matching your search",
"CAPTAIN": {
"DISABLED": "Captain is not enabled on your account.",
"CLICK_HERE_TO_CONFIGURE": "Click here to configure",
@@ -28,6 +31,8 @@
"WEBHOOK": {
"SUBSCRIBED_EVENTS": "Subscribed Events",
"LEARN_MORE": "Learn more about webhooks",
"SEARCH_PLACEHOLDER": "Search webhooks...",
"NO_RESULTS": "No webhooks found matching your search",
"FORM": {
"CANCEL": "Cancel",
"DESC": "Webhook events provide you the realtime information about what's happening in your Chatwoot account. Please enter a valid URL to configure a callback.",
@@ -104,6 +109,7 @@
}
},
"SLACK": {
"HEADER": "Slack",
"DELETE": "Delete",
"DELETE_CONFIRMATION": {
"TITLE": "Delete the integration",
@@ -201,10 +207,16 @@
"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",
"SEARCH_PLACEHOLDER": "Search dashboard apps...",
"NO_RESULTS": "No dashboard apps found matching your search",
"LIST": {
"404": "There are no dashboard apps configured on this account yet",
"LOADING": "Fetching dashboard apps...",
"TABLE_HEADER": { "NAME": "Name", "ENDPOINT": "Endpoint" },
"TABLE_HEADER": {
"NAME": "Name",
"ENDPOINT": "Endpoint",
"ACTIONS": "Actions"
},
"EDIT_TOOLTIP": "Edit app",
"DELETE_TOOLTIP": "Delete app"
},
@@ -240,6 +252,7 @@
}
},
"LINEAR": {
"HEADER": "Linear",
"ADD_OR_LINK_BUTTON": "Create/Link Linear Issue",
"LOADING": "Fetching linear issues...",
"LOADING_ERROR": "There was an error fetching the linear issues, please try again",
@@ -334,6 +347,7 @@
}
},
"NOTION": {
"HEADER": "Notion",
"DELETE": {
"TITLE": "Are you sure you want to delete the Notion integration?",
"MESSAGE": "Deleting this integration will remove access to your Notion workspace and stop all related functionality.",
@@ -5,6 +5,8 @@
"LOADING": "Fetching labels",
"DESCRIPTION": "Labels help you categorize and prioritize conversations and leads. You can assign a label to a conversation or contact using the side panel.",
"LEARN_MORE": "Learn more about labels",
"SEARCH_PLACEHOLDER": "Search labels...",
"NO_RESULTS": "No labels found matching your search",
"SEARCH_404": "There are no items matching this query",
"LIST": {
"404": "There are no labels available in this account.",
@@ -13,7 +15,8 @@
"TABLE_HEADER": {
"NAME": "Name",
"DESCRIPTION": "Description",
"COLOR": "Color"
"COLOR": "Color",
"ACTION": "Actions"
}
},
"FORM": {
@@ -6,6 +6,8 @@
"HEADER_BTN_TXT": "Add a new macro",
"HEADER_BTN_TXT_SAVE": "Save macro",
"LOADING": "Fetching macros",
"SEARCH_PLACEHOLDER": "Search macros...",
"NO_RESULTS": "No macros found matching your search",
"ERROR": "Something went wrong. Please try again",
"ORDER_INFO": "Macros will run in the order you add your actions. You can rearrange them by dragging them by the handle beside each node.",
"ADD": {
@@ -29,7 +31,8 @@
"NAME": "Name",
"CREATED BY": "Created by",
"LAST_UPDATED_BY": "Last updated by",
"VISIBILITY": "Visibility"
"VISIBILITY": "Visibility",
"ACTIONS": "Actions"
},
"404": "No macros found"
},
@@ -1,7 +1,7 @@
{
"MFA_SETTINGS": {
"TITLE": "Two-Factor Authentication",
"SUBTITLE": "Secure your account with TOTP-based authentication",
"SUBTITLE": "Protect your account from unauthorized access with TOTP-based authentication. This adds an extra layer of security to your account.",
"DESCRIPTION": "Add an extra layer of security to your account using a time-based one-time password (TOTP)",
"STATUS_TITLE": "Authentication Status",
"STATUS_DESCRIPTION": "Manage your two-factor authentication settings and backup recovery codes",
@@ -6,6 +6,10 @@
"DESCRIPTION": "Service Level Agreements (SLAs) are contracts that define clear expectations between your team and customers. They establish standards for response and resolution times, creating a framework for accountability and ensures a consistent, high-quality experience.",
"LEARN_MORE": "Learn more about SLA",
"LOADING": "Fetching SLAs",
"SEARCH_PLACEHOLDER": "Search SLA...",
"SEARCH": {
"NO_RESULTS": "No SLA found matching your search"
},
"PAYWALL": {
"TITLE": "Upgrade to create SLAs",
"AVAILABLE_ON": "The SLA feature is only available in the Business and Enterprise plans.",
@@ -20,14 +24,18 @@
},
"LIST": {
"404": "There are no SLAs available in this account.",
"TABLE_HEADER": {
"SLA": "SLA",
"BUSINESS_HOURS": "Business hours"
},
"EMPTY": {
"TITLE_1": "Enterprise P0",
"DESC_1": "Issues raised by enterprise customers, that require immediate attention.",
"TITLE_2": "Enterprise P1",
"DESC_2": "Issues raised by enterprise customers, that needs to be acknowledged quickly."
},
"BUSINESS_HOURS_ON": "Business hours on",
"BUSINESS_HOURS_OFF": "Business hours off",
"BUSINESS_HOURS_ON": "Turned on",
"BUSINESS_HOURS_OFF": "Turned off",
"RESPONSE_TYPES": {
"FRT": "First response time threshold",
"NRT": "Next response time threshold",
@@ -5,6 +5,8 @@
"LOADING": "Fetching teams",
"DESCRIPTION": "Teams allow you to organize agents into groups based on their responsibilities. An agent can belong to multiple teams. When working collaboratively, you can assign conversations to specific teams.",
"LEARN_MORE": "Learn more about teams",
"SEARCH_PLACEHOLDER": "Search teams...",
"NO_RESULTS": "No teams found matching your search",
"LIST": {
"404": "There are no teams created on this account.",
"EDIT_TEAM": "Edit team",
@@ -64,8 +66,8 @@
"ERROR_MESSAGE": "Couldn't save the team details. Try again."
},
"AGENTS": {
"AGENT": "AGENT",
"EMAIL": "EMAIL",
"AGENT": "Agent",
"EMAIL": "Email",
"BUTTON_TEXT": "Add agents",
"ADD_AGENTS": "Adding Agents to your Team...",
"SELECT": "select",
@@ -143,7 +143,7 @@ export default {
/>
<main
class="flex flex-1 h-full w-full min-h-0 px-0 overflow-hidden md:rounded-lg md:border md:border-n-weak bg-n-surface-1"
class="flex flex-1 h-full w-full relative min-h-0 px-0 overflow-hidden md:rounded-lg md:border md:border-n-weak bg-n-surface-1 after:absolute after:bottom-0 after:inset-x-0 after:h-3 after:bg-gradient-to-t after:from-n-surface-1 after:from-0% after:to-transparent after:z-50 after:pointer-events-none"
>
<UpgradePage
v-show="showUpgradePage"
@@ -280,11 +280,11 @@ onMounted(() => {
</template>
<template #body>
<LimitBanner class="mb-5 z-20 relative" />
<LimitBanner class="mb-5 relative" />
<Banner
v-if="pendingCount > 0 && bulkSelectedIds.size === 0"
color="blue"
class="mb-4 -mt-2 z-20 relative"
class="mb-4 -mt-2 relative"
:action-label="$t('CAPTAIN.RESPONSES.PENDING_BANNER.ACTION')"
@action="navigateToPendingFAQs"
>
@@ -154,7 +154,7 @@ onMounted(() => {
t('COMPANIES.EMPTY_STATE.TITLE')
}}</span>
</div>
<div v-else class="flex flex-col gap-4 px-6 py-4">
<div v-else class="flex flex-col gap-4 py-4">
<CompaniesCard
v-for="company in companies"
:id="company.id"
@@ -79,9 +79,11 @@ export default {
</script>
<template>
<div class="flex items-center justify-between w-full gap-1 h-12 px-3">
<div
class="flex items-center justify-between w-full gap-1 h-[3.25rem] ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3"
>
<div class="flex items-center gap-2 min-w-0 flex-1">
<h1 class="min-w-0 text-base font-medium truncate text-n-slate-12">
<h1 class="text-heading-2 truncate text-n-slate-12 min-w-0">
{{ $t('INBOX.LIST.TITLE') }}
</h1>
<div class="relative">
@@ -91,7 +93,7 @@ export default {
trailing-icon
slate
xs
faded
:variant="showInboxDisplayMenu ? 'faded' : 'solid'"
@click="openInboxDisplayMenu"
/>
<InboxDisplayMenu
@@ -106,8 +108,8 @@ export default {
<NextButton
icon="i-lucide-sliders-vertical"
slate
xs
faded
sm
:variant="showInboxOptionMenu ? 'faded' : 'ghost'"
@click="openInboxOptionsMenu"
/>
<InboxOptionMenu
@@ -1,17 +1,12 @@
/* eslint arrow-body-style: 0 */
import { frontendURL } from '../../../helper/URLHelper';
import SettingsWrapper from '../settings/Wrapper.vue';
import SettingsWrapper from '../settings/SettingsWrapper.vue';
import NotificationsView from './components/NotificationsView.vue';
export const routes = [
{
path: frontendURL('accounts/:accountId/notifications'),
component: SettingsWrapper,
props: {
headerTitle: '',
icon: '',
showNewButton: false,
},
children: [
{
path: '',
@@ -41,14 +41,14 @@ export default {
<template>
<div
class="flex justify-between items-center h-20 min-h-[3.5rem] px-4 py-2 bg-n-surface-1"
class="flex justify-between items-center h-20 min-h-[3.5rem] px-6 py-2 bg-n-surface-1"
>
<h1 class="flex items-center mb-0 text-2xl text-n-slate-12">
<BackButton
v-if="showBackButton"
:button-label="backButtonLabel"
:back-url="backUrl"
class="ml-2 mr-4"
class="ltr:mr-4 rtl:ml-4"
/>
<slot />
@@ -20,7 +20,7 @@ defineProps({
</script>
<template>
<div class="flex flex-col w-full h-full gap-8 font-inter">
<div class="flex flex-col w-full h-full gap-4 font-inter">
<slot name="header" />
<!-- Added to render any templates that should be rendered before body -->
<main>
@@ -8,13 +8,13 @@ export default {
</script>
<template>
<div class="flex flex-col w-full items-start mb-4">
<h2 class="text-xl font-medium mb-1 text-n-slate-12 break-words">
<div class="flex flex-col gap-1.5 w-full items-start mb-4">
<h2 class="text-heading-1 text-n-slate-12 break-words">
{{ headerTitle }}
</h2>
<p
v-dompurify-html="headerContent"
class="text-sm w-full text-n-slate-11"
class="text-body-main w-full text-n-slate-11"
/>
</div>
</template>
@@ -1,22 +1,26 @@
<script setup>
import { useRoute } from 'vue-router';
defineProps({
keepAlive: {
type: Boolean,
default: true,
},
});
const route = useRoute();
</script>
<template>
<div
class="flex flex-col w-full h-full m-0 p-6 sm:py-8 lg:px-16 overflow-auto bg-n-surface-1 font-inter"
class="flex flex-col w-full h-full m-0 pb-8 pt-4 px-6 overflow-auto bg-n-surface-1"
>
<div class="flex items-start w-full max-w-6xl mx-auto">
<div class="flex items-start w-full max-w-5xl mx-auto">
<router-view v-slot="{ Component }">
<keep-alive v-if="keepAlive">
<component :is="Component" />
<component :is="Component" :key="route.fullPath" />
</keep-alive>
<component :is="Component" v-else />
<component :is="Component" v-else :key="route.fullPath" />
</router-view>
</div>
</div>
@@ -34,7 +34,7 @@ const showSettingsHeader = computed(
:class="{ 'max-w-6xl w-full mx-auto': fullWidth }"
/>
<router-view v-slot="{ Component }" class="px-5 flex-1 overflow-hidden">
<router-view v-slot="{ Component }" class="px-4 flex-1 overflow-hidden">
<component :is="Component" v-if="!keepAlive" :key="$route.fullPath" />
<keep-alive v-else>
<component :is="Component" :key="$route.fullPath" />
@@ -154,12 +154,13 @@ export default {
</script>
<template>
<div class="flex flex-col max-w-2xl mx-auto w-full">
<div class="flex flex-col w-full">
<BaseSettingsHeader :title="$t('GENERAL_SETTINGS.TITLE')" />
<div class="flex-grow flex-shrink min-w-0 mt-3">
<SectionLayout
:title="$t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.TITLE')"
:description="$t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.NOTE')"
class="!pt-0"
>
<form
v-if="!uiFlags.isFetchingItem"
@@ -21,7 +21,7 @@ const { t } = useI18n();
>
<header class="grid grid-cols-4">
<div class="col-span-3">
<h4 class="text-lg font-medium text-n-slate-12 flex items-center gap-2">
<h4 class="text-heading-2 text-n-slate-12 flex items-center gap-2">
<slot name="title">{{ title }}</slot>
<div
v-if="beta"
@@ -31,7 +31,7 @@ const { t } = useI18n();
{{ t('GENERAL.BETA') }}
</div>
</h4>
<p class="text-n-slate-11 text-sm mt-2">
<p class="text-n-slate-11 text-body-main mt-2">
<slot name="description">{{ description }}</slot>
</p>
</div>
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { picoSearch } from '@scmmishra/pico-search';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
@@ -10,6 +11,11 @@ import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import AgentBotModal from './components/AgentBotModal.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import {
BaseTable,
BaseTableRow,
BaseTableCell,
} from 'dashboard/components-next/table';
const MODAL_TYPES = {
CREATE: 'create',
@@ -23,6 +29,7 @@ const agentBots = useMapGetter('agentBots/getBots');
const uiFlags = useMapGetter('agentBots/getUIFlags');
const selectedBot = ref({});
const searchQuery = ref('');
const loading = ref({});
const modalType = ref(MODAL_TYPES.CREATE);
const agentBotModalRef = ref(null);
@@ -32,11 +39,18 @@ const tableHeaders = computed(() => {
return [
t('AGENT_BOTS.LIST.TABLE_HEADER.DETAILS'),
t('AGENT_BOTS.LIST.TABLE_HEADER.URL'),
t('AGENT_BOTS.LIST.TABLE_HEADER.ACTIONS'),
];
});
const selectedBotName = computed(() => selectedBot.value?.name || '');
const filteredAgentBots = computed(() => {
const query = searchQuery.value.trim();
if (!query) return agentBots.value;
return picoSearch(agentBots.value, query, ['name', 'description']);
});
const openAddModal = () => {
modalType.value = MODAL_TYPES.CREATE;
selectedBot.value = {};
@@ -86,87 +100,89 @@ onMounted(() => {
>
<template #header>
<BaseSettingsHeader
v-model:search-query="searchQuery"
:title="t('AGENT_BOTS.HEADER')"
:description="t('AGENT_BOTS.DESCRIPTION')"
:link-text="t('AGENT_BOTS.LEARN_MORE')"
:search-placeholder="t('AGENT_BOTS.SEARCH_PLACEHOLDER')"
feature-name="agent_bots"
>
<template #actions>
<Button
icon="i-lucide-circle-plus"
:label="$t('AGENT_BOTS.ADD.TITLE')"
@click="openAddModal"
/>
<Button :label="$t('AGENT_BOTS.ADD.TITLE')" @click="openAddModal" />
</template>
</BaseSettingsHeader>
</template>
<template #body>
<table class="min-w-full overflow-x-auto divide-y divide-n-strong">
<thead>
<th
v-for="thHeader in tableHeaders"
:key="thHeader"
class="py-4 font-semibold text-left ltr:pr-4 rtl:pl-4 text-n-slate-11"
>
{{ thHeader }}
</th>
</thead>
<tbody class="flex-1 divide-y divide-n-weak text-n-slate-12">
<tr v-for="bot in agentBots" :key="bot.id">
<td class="py-4 ltr:pr-4 rtl:pl-4">
<div class="flex flex-row items-center gap-4">
<Avatar
:name="bot.name"
:src="bot.thumbnail"
:size="40"
rounded-full
/>
<div>
<span class="block font-medium break-words">
{{ bot.name }}
<span
v-if="bot.system_bot"
class="text-xs text-n-slate-12 bg-n-blue-5 inline-block rounded-md py-0.5 px-1 ltr:ml-1 rtl:mr-1"
>
{{ $t('AGENT_BOTS.GLOBAL_BOT_BADGE') }}
<BaseTable
:headers="tableHeaders"
:items="filteredAgentBots"
:no-data-message="
searchQuery ? t('AGENT_BOTS.NO_RESULTS') : t('AGENT_BOTS.LIST.404')
"
>
<template #row="{ items }">
<BaseTableRow v-for="bot in items" :key="bot.id" :item="bot">
<template #default>
<BaseTableCell class="max-w-0">
<div class="flex items-center gap-4 min-w-0">
<Avatar
:name="bot.name"
:src="bot.thumbnail"
:size="40"
rounded-full
class="flex-shrink-0"
/>
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="text-body-main text-n-slate-12 truncate">
{{ bot.name }}
</span>
<span
v-if="bot.system_bot"
class="text-xs text-n-slate-12 bg-n-blue-5 rounded-md py-0.5 px-1 flex-shrink-0"
>
{{ $t('AGENT_BOTS.GLOBAL_BOT_BADGE') }}
</span>
</div>
<span class="text-body-main text-n-slate-11 block truncate">
{{ bot.description }}
</span>
</span>
<span class="text-sm text-n-slate-11">
{{ bot.description }}
</span>
</div>
</div>
</div>
</td>
<td class="py-4 ltr:pr-4 rtl:pl-4 text-sm">
{{ bot.outgoing_url || bot.bot_config?.webhook_url }}
</td>
<td class="py-4 min-w-xs">
<div class="flex gap-1 justify-end">
<Button
v-if="!bot.system_bot"
v-tooltip.top="t('AGENT_BOTS.EDIT.BUTTON_TEXT')"
icon="i-lucide-pen"
slate
xs
faded
:is-loading="loading[bot.id]"
@click="openEditModal(bot)"
/>
<Button
v-if="!bot.system_bot"
v-tooltip.top="t('AGENT_BOTS.DELETE.BUTTON_TEXT')"
icon="i-lucide-trash-2"
xs
ruby
faded
:is-loading="loading[bot.id]"
@click="openDeletePopup(bot)"
/>
</div>
</td>
</tr>
</tbody>
</table>
</BaseTableCell>
<BaseTableCell class="max-w-0">
<span class="text-body-main text-n-slate-11 truncate block">
{{ bot.outgoing_url || bot.bot_config?.webhook_url }}
</span>
</BaseTableCell>
<BaseTableCell align="end" class="w-24">
<div class="flex gap-3 justify-end flex-shrink-0">
<Button
v-if="!bot.system_bot"
v-tooltip.top="t('AGENT_BOTS.EDIT.BUTTON_TEXT')"
icon="i-woot-edit-pen"
slate
sm
:is-loading="loading[bot.id]"
@click="openEditModal(bot)"
/>
<Button
v-if="!bot.system_bot"
v-tooltip.top="t('AGENT_BOTS.DELETE.BUTTON_TEXT')"
icon="i-woot-bin"
slate
sm
:is-loading="loading[bot.id]"
@click="openDeletePopup(bot)"
/>
</div>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
</BaseTable>
</template>
<AgentBotModal
@@ -3,6 +3,7 @@ import { useAlert } from 'dashboard/composables';
import { computed, onMounted, ref } from 'vue';
import Avatar from 'next/avatar/Avatar.vue';
import { useI18n } from 'vue-i18n';
import { picoSearch } from '@scmmishra/pico-search';
import {
useStoreGetters,
useStore,
@@ -25,6 +26,7 @@ const showDeletePopup = ref(false);
const showEditPopup = ref(false);
const agentAPI = ref({ message: '' });
const currentAgent = ref({});
const searchQuery = ref('');
const deleteConfirmText = computed(
() => `${t('AGENT_MGMT.DELETE.CONFIRM.YES')} ${currentAgent.value.name}`
@@ -37,6 +39,13 @@ const deleteMessage = computed(() => {
});
const agentList = computed(() => getters['agents/getAgents'].value);
const filteredAgentList = computed(() => {
const query = searchQuery.value.trim();
if (!query) return agentList.value;
return picoSearch(agentList.value, query, ['name', 'email']);
});
const uiFlags = computed(() => getters['agents/getUIFlags'].value);
const currentUserId = computed(() => getters.getCurrentUserID.value);
const customRoles = useMapGetter('customRole/getCustomRoles');
@@ -144,14 +153,15 @@ const confirmDeletion = () => {
>
<template #header>
<BaseSettingsHeader
v-model:search-query="searchQuery"
:title="$t('AGENT_MGMT.HEADER')"
:description="$t('AGENT_MGMT.DESCRIPTION')"
:link-text="$t('AGENT_MGMT.LEARN_MORE')"
:search-placeholder="$t('AGENT_MGMT.SEARCH_PLACEHOLDER')"
feature-name="agents"
>
<template #actions>
<Button
icon="i-lucide-circle-plus"
:label="$t('AGENT_MGMT.HEADER_BTN_TXT')"
@click="openAddPopup"
/>
@@ -159,97 +169,105 @@ const confirmDeletion = () => {
</BaseSettingsHeader>
</template>
<template #body>
<table class="divide-y divide-n-weak">
<tbody class="divide-y divide-n-weak text-n-slate-11">
<tr v-for="(agent, index) in agentList" :key="agent.email">
<td class="py-4 ltr:pr-4 rtl:pl-4">
<div class="flex flex-row items-center gap-4">
<Avatar
:src="agent.thumbnail"
:name="agent.name"
:status="agent.availability_status"
:size="40"
hide-offline-status
rounded-full
/>
<div>
<span class="block font-medium capitalize">
{{ agent.name }}
</span>
<span>{{ agent.email }}</span>
</div>
</div>
</td>
<td class="relative py-4 ltr:pr-4 rtl:pl-4">
<span
class="block font-medium w-fit"
:class="{
'hover:text-gray-900 group cursor-pointer':
agent.custom_role_id,
}"
>
{{ getAgentRoleName(agent) }}
<div
class="absolute left-0 z-10 hidden max-w-[300px] w-auto bg-white rounded-xl border border-n-weak shadow-lg top-14 md:top-12 dark:bg-n-solid-2"
:class="{ 'group-hover:block': agent.custom_role_id }"
<span
v-if="!filteredAgentList.length && searchQuery"
class="flex-1 flex items-center justify-center py-20 text-center text-body-main !text-base text-n-slate-11"
>
{{ $t('AGENT_MGMT.NO_RESULTS') }}
</span>
<div v-else class="divide-y divide-n-weak">
<div
v-for="(agent, index) in filteredAgentList"
:key="agent.email"
class="flex justify-between flex-row items-start gap-4 py-4"
>
<div class="flex items-center gap-4">
<Avatar
:src="agent.thumbnail"
:name="agent.name"
:status="agent.availability_status"
:size="40"
hide-offline-status
/>
<div class="flex flex-col gap-1.5 items-start">
<span class="block text-heading-3 text-n-slate-12 capitalize">
{{ agent.name }}
</span>
<div class="flex items-center gap-2">
<span class="text-body-main text-n-slate-11">
{{ agent.email }}
</span>
<div class="w-px h-3 bg-n-strong rounded-lg" />
<span
class="block w-fit text-body-main text-n-slate-11 relative"
:class="{
'hover:text-n-slate-12 group cursor-pointer':
agent.custom_role_id,
}"
>
<div class="flex flex-col gap-1 p-4">
<span class="font-semibold">
{{ $t('AGENT_MGMT.LIST.AVAILABLE_CUSTOM_ROLE') }}
</span>
<ul class="pl-4 mb-0 list-disc">
<li
v-for="permission in getAgentRolePermissions(agent)"
:key="permission"
class="font-normal"
>
{{
$t(
`CUSTOM_ROLE.PERMISSIONS.${permission.toUpperCase()}`
)
}}
</li>
</ul>
{{ getAgentRoleName(agent) }}
<div
class="absolute ltr:left-0 rtl:right-0 z-10 hidden w-[300px] bg-n-alpha-3 backdrop-blur-[100px] rounded-xl outline outline-1 outline-n-container shadow-lg top-14 md:top-12"
:class="{ 'group-hover:block': agent.custom_role_id }"
>
<div class="flex flex-col gap-1 p-4">
<span class="text-heading-3 text-n-slate-12">
{{ $t('AGENT_MGMT.LIST.AVAILABLE_CUSTOM_ROLE') }}
</span>
<ul class="ltr:pl-4 rtl:pr-4 mb-0 list-disc">
<li
v-for="permission in getAgentRolePermissions(agent)"
:key="permission"
class="text-body-main text-n-slate-11"
>
{{
$t(
`CUSTOM_ROLE.PERMISSIONS.${permission.toUpperCase()}`
)
}}
</li>
</ul>
</div>
</div>
</div>
</span>
</td>
<td class="py-4 ltr:pr-4 rtl:pl-4">
<span v-if="agent.confirmed">
{{ $t('AGENT_MGMT.LIST.VERIFIED') }}
</span>
<span v-if="!agent.confirmed">
{{ $t('AGENT_MGMT.LIST.VERIFICATION_PENDING') }}
</span>
</td>
<td class="py-4">
<div class="flex justify-end gap-1">
<Button
v-if="showEditAction(agent)"
v-tooltip.top="$t('AGENT_MGMT.EDIT.BUTTON_TEXT')"
icon="i-lucide-pen"
slate
xs
faded
@click="openEditPopup(agent)"
/>
<Button
v-if="showDeleteAction(agent)"
v-tooltip.top="$t('AGENT_MGMT.DELETE.BUTTON_TEXT')"
icon="i-lucide-trash-2"
xs
ruby
faded
:is-loading="loading[agent.id]"
@click="openDeletePopup(agent, index)"
/>
</span>
<div class="w-px h-3 bg-n-strong rounded-lg" />
<span
v-if="agent.confirmed"
class="text-body-main text-n-slate-11"
>
{{ $t('AGENT_MGMT.LIST.VERIFIED') }}
</span>
<span
v-if="!agent.confirmed"
class="text-body-main text-n-slate-11"
>
{{ $t('AGENT_MGMT.LIST.VERIFICATION_PENDING') }}
</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="flex justify-end gap-3">
<Button
v-if="showEditAction(agent)"
v-tooltip.top="$t('AGENT_MGMT.EDIT.BUTTON_TEXT')"
icon="i-woot-edit-pen"
slate
sm
@click="openEditPopup(agent)"
/>
<Button
v-if="showDeleteAction(agent)"
v-tooltip.top="$t('AGENT_MGMT.DELETE.BUTTON_TEXT')"
icon="i-woot-bin"
slate
sm
:is-loading="loading[agent.id]"
@click="openDeletePopup(agent, index)"
/>
</div>
</div>
</div>
</template>
<woot-modal v-model:show="showAddPopup" :on-close="hideAddPopup">
@@ -1,176 +0,0 @@
<script setup>
import { useAlert } from 'dashboard/composables';
import EditAttribute from './EditAttribute.vue';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
attributeModel: {
type: String,
default: 'conversation_attribute',
},
});
const { t } = useI18n();
const showEditPopup = ref(false);
const showDeletePopup = ref(false);
const selectedAttribute = ref({});
const getters = useStoreGetters();
const store = useStore();
const attributes = computed(() =>
getters['attributes/getAttributesByModel'].value(props.attributeModel)
);
const uiFlags = computed(() => getters['attributes/getUIFlags'].value);
const attributeDisplayName = computed(
() => selectedAttribute.value.attribute_display_name
);
const deleteConfirmText = computed(
() =>
`${t('ATTRIBUTES_MGMT.DELETE.CONFIRM.YES')} ${attributeDisplayName.value}`
);
const deleteRejectText = computed(() => t('ATTRIBUTES_MGMT.DELETE.CONFIRM.NO'));
const confirmDeleteTitle = computed(() =>
t('ATTRIBUTES_MGMT.DELETE.CONFIRM.TITLE', {
attributeName: attributeDisplayName.value,
})
);
const confirmPlaceHolderText = computed(
() =>
`${t('ATTRIBUTES_MGMT.DELETE.CONFIRM.PLACE_HOLDER', {
attributeName: attributeDisplayName.value,
})}`
);
const deleteAttributes = async ({ id }) => {
try {
await store.dispatch('attributes/delete', id);
useAlert(t('ATTRIBUTES_MGMT.DELETE.API.SUCCESS_MESSAGE'));
} catch (error) {
const errorMessage =
error?.response?.message || t('ATTRIBUTES_MGMT.DELETE.API.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
const openEditPopup = response => {
showEditPopup.value = true;
selectedAttribute.value = response;
};
const hideEditPopup = () => {
showEditPopup.value = false;
};
const closeDelete = () => {
showDeletePopup.value = false;
selectedAttribute.value = {};
};
const confirmDeletion = () => {
deleteAttributes(selectedAttribute.value);
closeDelete();
};
const openDelete = value => {
showDeletePopup.value = true;
selectedAttribute.value = value;
};
const tableHeaders = computed(() => {
return [
t('ATTRIBUTES_MGMT.LIST.TABLE_HEADER.NAME'),
t('ATTRIBUTES_MGMT.LIST.TABLE_HEADER.DESCRIPTION'),
t('ATTRIBUTES_MGMT.LIST.TABLE_HEADER.TYPE'),
t('ATTRIBUTES_MGMT.LIST.TABLE_HEADER.KEY'),
];
});
</script>
<template>
<div class="flex flex-col">
<table class="min-w-full overflow-x-auto">
<thead>
<th
v-for="tableHeader in tableHeaders"
:key="tableHeader"
class="py-4 ltr:pr-4 rtl:pl-4 text-left font-semibold text-n-slate-11"
>
{{ tableHeader }}
</th>
</thead>
<tbody class="divide-y divide-n-weak flex-1 text-n-slate-12">
<tr v-for="attribute in attributes" :key="attribute.attribute_key">
<td
class="py-4 ltr:pr-4 rtl:pl-4 overflow-hidden whitespace-nowrap text-ellipsis"
>
{{ attribute.attribute_display_name }}
</td>
<td class="py-4 ltr:pr-4 rtl:pl-4">
{{ attribute.attribute_description }}
</td>
<td
class="py-4 ltr:pr-4 rtl:pl-4 overflow-hidden whitespace-nowrap text-ellipsis"
>
{{
$t(
`ATTRIBUTES_MGMT.ATTRIBUTE_TYPES.${attribute.attribute_display_type?.toUpperCase()}`
)
}}
</td>
<td
class="py-4 ltr:pr-4 rtl:pl-4 attribute-key overflow-hidden whitespace-nowrap text-ellipsis"
>
{{ attribute.attribute_key }}
</td>
<td class="py-4 min-w-xs">
<div class="flex gap-1 justify-end">
<Button
v-tooltip.top="$t('ATTRIBUTES_MGMT.LIST.BUTTONS.EDIT')"
icon="i-lucide-pen"
slate
xs
faded
@click="openEditPopup(attribute)"
/>
<Button
v-tooltip.top="$t('ATTRIBUTES_MGMT.LIST.BUTTONS.DELETE')"
icon="i-lucide-trash-2"
xs
ruby
faded
@click="openDelete(attribute)"
/>
</div>
</td>
</tr>
</tbody>
</table>
<woot-modal v-model:show="showEditPopup" :on-close="hideEditPopup">
<EditAttribute
:selected-attribute="selectedAttribute"
:is-updating="uiFlags.isUpdating"
@on-close="hideEditPopup"
/>
</woot-modal>
<woot-confirm-delete-modal
v-if="showDeletePopup"
v-model:show="showDeletePopup"
:title="confirmDeleteTitle"
:message="$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.MESSAGE')"
:confirm-text="deleteConfirmText"
:reject-text="deleteRejectText"
:confirm-value="selectedAttribute.attribute_display_name"
:confirm-place-holder-text="confirmPlaceHolderText"
@on-confirm="confirmDeletion"
@on-close="closeDelete"
/>
</div>
</template>
<style lang="scss" scoped>
.attribute-key {
font-family: monospace;
}
</style>
@@ -2,6 +2,7 @@
import { computed, onMounted, ref } from 'vue';
import { useToggle } from '@vueuse/core';
import { useAlert } from 'dashboard/composables';
import { picoSearch } from '@scmmishra/pico-search';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import AddAttribute from './AddAttribute.vue';
import EditAttribute from './EditAttribute.vue';
@@ -26,6 +27,7 @@ const inboxes = useMapGetter('inboxes/getInboxes');
const [showAddPopup, toggleAddPopup] = useToggle(false);
const selectedTabIndex = ref(0);
const searchQuery = ref('');
const uiFlags = computed(() => getters['attributes/getUIFlags'].value);
const [showEditPopup, toggleEditPopup] = useToggle(false);
const [showDeletePopup, toggleDeletePopup] = useToggle(false);
@@ -77,6 +79,7 @@ const attributes = computed(() =>
const onClickTabChange = tab => {
selectedTabIndex.value = tab.key;
searchQuery.value = '';
};
const handleEditAttribute = attribute => {
@@ -144,6 +147,16 @@ const derivedAttributes = computed(() =>
badges: buildBadges(attribute),
}))
);
const filteredAttributes = computed(() => {
const query = searchQuery.value.trim();
if (!query) return derivedAttributes.value;
return picoSearch(derivedAttributes.value, query, [
'attribute_display_name',
'attribute_key',
'attribute_description',
]);
});
</script>
<template>
@@ -153,14 +166,15 @@ const derivedAttributes = computed(() =>
>
<template #header>
<BaseSettingsHeader
v-model:search-query="searchQuery"
:title="$t('ATTRIBUTES_MGMT.HEADER')"
:description="$t('ATTRIBUTES_MGMT.DESCRIPTION')"
:link-text="$t('ATTRIBUTES_MGMT.LEARN_MORE')"
:search-placeholder="$t('ATTRIBUTES_MGMT.SEARCH_PLACEHOLDER')"
feature-name="custom_attributes"
>
<template #actions>
<Button
icon="i-lucide-circle-plus"
:label="$t('ATTRIBUTES_MGMT.HEADER_BTN_TXT')"
@click="openAddPopup"
/>
@@ -168,16 +182,21 @@ const derivedAttributes = computed(() =>
</BaseSettingsHeader>
</template>
<template #body>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-4">
<TabBar
:tabs="tabsForTabBar"
:initial-active-tab="selectedTabIndex"
class="max-w-xl"
@tab-changed="onClickTabChange"
/>
<div v-if="derivedAttributes.length" class="grid gap-3">
<span
v-if="!filteredAttributes.length && searchQuery"
class="flex-1 flex items-center justify-center py-20 text-center text-body-main !text-base text-n-slate-11"
>
{{ $t('ATTRIBUTES_MGMT.NO_RESULTS') }}
</span>
<div v-else-if="filteredAttributes.length" class="flex flex-col">
<AttributeListItem
v-for="attribute in derivedAttributes"
v-for="attribute in filteredAttributes"
:key="attribute.id"
:attribute="attribute"
:badges="attribute.badges"
@@ -2,8 +2,14 @@
import { useAlert } from 'dashboard/composables';
import { messageTimestamp } from 'shared/helpers/timeHelper';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import TableFooter from 'dashboard/components/widgets/TableFooter.vue';
import {
BaseTable,
BaseTableRow,
BaseTableCell,
} from 'dashboard/components-next/table';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import {
generateTranslationPayload,
generateLogActionKey,
@@ -75,63 +81,68 @@ const tableHeaders = computed(() => {
</script>
<template>
<div class="flex-1 overflow-auto">
<BaseSettingsHeader
:title="$t('AUDIT_LOGS.HEADER')"
:description="$t('AUDIT_LOGS.DESCRIPTION')"
:link-text="$t('AUDIT_LOGS.LEARN_MORE')"
feature-name="audit_logs"
/>
<div class="mt-6 flex-1 text-n-slate-11">
<woot-loading-state
v-if="uiFlags.fetchingList"
:message="$t('AUDIT_LOGS.LOADING')"
<SettingsLayout
:is-loading="uiFlags.fetchingList"
:loading-message="$t('AUDIT_LOGS.LOADING')"
:no-records-found="!records.length"
:no-records-message="$t('AUDIT_LOGS.LIST.404')"
>
<template #header>
<BaseSettingsHeader
:title="$t('AUDIT_LOGS.HEADER')"
:description="$t('AUDIT_LOGS.DESCRIPTION')"
:link-text="$t('AUDIT_LOGS.LEARN_MORE')"
feature-name="audit_logs"
/>
<p
v-else-if="!records.length"
class="flex flex-col items-center justify-center h-full text-base p-8"
>
{{ $t('AUDIT_LOGS.LIST.404') }}
</p>
<div v-else class="min-w-full overflow-x-auto">
<table class="divide-y divide-n-weak">
<thead>
<th
v-for="thHeader in tableHeaders"
:key="thHeader"
class="py-4 ltr:pr-4 rtl:pl-4 text-left font-semibold text-n-slate-11"
</template>
<template #body>
<div class="flex flex-col">
<BaseTable :headers="tableHeaders" :items="records">
<template #row="{ items }">
<BaseTableRow
v-for="auditLogItem in items"
:key="auditLogItem.id"
:item="auditLogItem"
>
{{ thHeader }}
</th>
</thead>
<tbody class="divide-y divide-n-weak text-n-slate-11">
<tr v-for="auditLogItem in records" :key="auditLogItem.id">
<td class="py-4 ltr:pr-4 rtl:pl-4 break-all whitespace-nowrap">
{{ generateLogText(auditLogItem) }}
</td>
<td class="py-4 ltr:pr-4 rtl:pl-4 break-all whitespace-nowrap">
{{
messageTimestamp(
auditLogItem.created_at,
'MMM dd, yyyy hh:mm a'
)
}}
</td>
<td class="py-4 w-[8.75rem]">
{{ auditLogItem.remote_address }}
</td>
</tr>
</tbody>
</table>
<TableFooter
<template #default>
<BaseTableCell>
<span
class="text-body-main text-n-slate-12 whitespace-nowrap"
>
{{ generateLogText(auditLogItem) }}
</span>
</BaseTableCell>
<BaseTableCell>
<span
class="text-body-main text-n-slate-11 whitespace-nowrap"
>
{{
messageTimestamp(
auditLogItem.created_at,
'MMM dd, yyyy hh:mm a'
)
}}
</span>
</BaseTableCell>
<BaseTableCell class="w-36">
<span class="text-body-main text-n-slate-11">
{{ auditLogItem.remote_address }}
</span>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
</BaseTable>
<PaginationFooter
:current-page="Number(meta.currentPage)"
:total-count="meta.totalEntries"
:page-size="meta.perPage"
class="border-n-weak border-t !px-0 py-4"
@page-change="onPageChange"
:total-items="meta.totalEntries"
:items-per-page="meta.perPage"
class="!px-0"
@update:current-page="onPageChange"
/>
</div>
</div>
</div>
</template>
</SettingsLayout>
</template>
@@ -3,6 +3,7 @@ import { computed } from 'vue';
import { messageStamp } from 'shared/helpers/timeHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
import { BaseTableRow, BaseTableCell } from 'dashboard/components-next/table';
const props = defineProps({
automation: {
@@ -35,47 +36,58 @@ const automationActive = computed({
</script>
<template>
<tr>
<td class="py-4 ltr:pr-4 rtl:pl-4 min-w-[200px]">{{ automation.name }}</td>
<td class="py-4 ltr:pr-4 rtl:pl-4">{{ automation.description }}</td>
<td class="py-4 ltr:pr-4 rtl:pl-4">
<ToggleSwitch v-model="automationActive" />
</td>
<td
class="py-4 ltr:pr-4 rtl:pl-4 min-w-[12px]"
:title="readableDateWithTime(automation.created_on)"
>
{{ readableDate(automation.created_on) }}
</td>
<td class="py-4 min-w-xs">
<div class="flex gap-1 justify-end flex-shrink-0">
<Button
v-tooltip.top="$t('AUTOMATION.FORM.EDIT')"
icon="i-lucide-pen"
slate
xs
faded
:is-loading="loading"
@click="$emit('edit', automation)"
/>
<Button
v-tooltip.top="$t('AUTOMATION.CLONE.TOOLTIP')"
icon="i-lucide-copy-plus"
xs
faded
:is-loading="loading"
@click="$emit('clone', automation)"
/>
<Button
v-tooltip.top="$t('AUTOMATION.FORM.DELETE')"
:is-loading="loading"
icon="i-lucide-trash-2"
xs
ruby
faded
@click="$emit('delete', automation)"
/>
</div>
</td>
</tr>
<BaseTableRow :item="automation">
<template #default>
<BaseTableCell class="max-w-0 w-full">
<div class="flex items-center gap-2 min-w-0">
<span class="text-body-main text-n-slate-12 truncate">
{{ automation.name }}
</span>
<div class="w-px h-3 rounded-lg bg-n-weak flex-shrink-0" />
<span class="text-body-main text-n-slate-11 truncate">
{{ automation.description }}
</span>
</div>
</BaseTableCell>
<BaseTableCell>
<ToggleSwitch v-model="automationActive" />
</BaseTableCell>
<BaseTableCell :title="readableDateWithTime(automation.created_on)">
<span class="text-body-main text-n-slate-12 whitespace-nowrap">
{{ readableDate(automation.created_on) }}
</span>
</BaseTableCell>
<BaseTableCell align="end">
<div class="flex gap-3 justify-end flex-shrink-0">
<Button
v-tooltip.top="$t('AUTOMATION.FORM.EDIT')"
icon="i-woot-edit-pen"
slate
sm
:is-loading="loading"
@click="$emit('edit', automation)"
/>
<Button
v-tooltip.top="$t('AUTOMATION.FORM.DELETE')"
:is-loading="loading"
icon="i-woot-bin"
slate
sm
@click="$emit('delete', automation)"
/>
<Button
v-tooltip.top="$t('AUTOMATION.CLONE.TOOLTIP')"
icon="i-woot-clone"
sm
slate
:is-loading="loading"
@click="$emit('clone', automation)"
/>
</div>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
@@ -7,8 +7,10 @@ import SettingsLayout from '../SettingsLayout.vue';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import { picoSearch } from '@scmmishra/pico-search';
import AutomationRuleRow from './AutomationRuleRow.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { BaseTable } from 'dashboard/components-next/table';
const getters = useStoreGetters();
const store = useStore();
@@ -20,12 +22,19 @@ const showAddPopup = ref(false);
const showEditPopup = ref(false);
const showDeleteConfirmationPopup = ref(false);
const selectedAutomation = ref({});
const searchQuery = ref('');
const toggleModalTitle = ref(t('AUTOMATION.TOGGLE.ACTIVATION_TITLE'));
const toggleModalDescription = ref(
t('AUTOMATION.TOGGLE.ACTIVATION_DESCRIPTION')
);
const records = computed(() => getters['automations/getAutomations'].value);
const filteredRecords = computed(() => {
const query = searchQuery.value.trim();
if (!query) return records.value;
return picoSearch(records.value, query, ['name', 'description']);
});
const uiFlags = computed(() => getters['automations/getUIFlags'].value);
const accountId = computed(() => getters.getCurrentAccountId.value);
@@ -165,9 +174,9 @@ const toggleAutomation = async ({ id, name, status }) => {
const tableHeaders = computed(() => {
return [
t('AUTOMATION.LIST.TABLE_HEADER.NAME'),
t('AUTOMATION.LIST.TABLE_HEADER.DESCRIPTION'),
t('AUTOMATION.LIST.TABLE_HEADER.ACTIVE'),
t('AUTOMATION.LIST.TABLE_HEADER.CREATED_ON'),
t('AUTOMATION.LIST.TABLE_HEADER.ACTIONS'),
];
});
</script>
@@ -181,14 +190,15 @@ const tableHeaders = computed(() => {
>
<template #header>
<BaseSettingsHeader
v-model:search-query="searchQuery"
:title="$t('AUTOMATION.HEADER')"
:description="$t('AUTOMATION.DESCRIPTION')"
:link-text="$t('AUTOMATION.LEARN_MORE')"
:search-placeholder="$t('AUTOMATION.SEARCH_PLACEHOLDER')"
feature-name="automation"
>
<template #actions>
<Button
icon="i-lucide-circle-plus"
:label="$t('AUTOMATION.HEADER_BTN_TXT')"
@click="openAddPopup"
/>
@@ -196,19 +206,16 @@ const tableHeaders = computed(() => {
</BaseSettingsHeader>
</template>
<template #body>
<table class="min-w-full divide-y divide-n-weak">
<thead>
<th
v-for="thHeader in tableHeaders"
:key="thHeader"
class="py-4 ltr:pr-4 rtl:pl-4 rtl:text-right ltr:text-left font-semibold text-n-slate-11"
>
{{ thHeader }}
</th>
</thead>
<tbody class="divide-y divide-n-weak text-n-slate-11">
<BaseTable
:headers="tableHeaders"
:items="filteredRecords"
:no-data-message="
searchQuery ? $t('AUTOMATION.NO_RESULTS') : $t('AUTOMATION.LIST.404')
"
>
<template #row="{ items }">
<AutomationRuleRow
v-for="automation in records"
v-for="automation in items"
:key="automation.id"
:automation="automation"
:loading="loading[automation.id]"
@@ -217,8 +224,8 @@ const tableHeaders = computed(() => {
@edit="openEditPopup"
@delete="openDeletePopup"
/>
</tbody>
</table>
</template>
</BaseTable>
</template>
<woot-modal
@@ -2,13 +2,21 @@
import { useAlert } from 'dashboard/composables';
import AddCanned from './AddCanned.vue';
import EditCanned from './EditCanned.vue';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import { computed, onMounted, ref, defineOptions } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import { picoSearch } from '@scmmishra/pico-search';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import {
BaseTable,
BaseTableRow,
BaseTableCell,
} from 'dashboard/components-next/table';
defineOptions({
name: 'CannedResponseSettings',
@@ -28,9 +36,17 @@ const activeResponse = ref({});
const cannedResponseAPI = ref({ message: '' });
const sortOrder = ref('asc');
const searchQuery = ref('');
const records = computed(() =>
getters.getSortedCannedResponses.value(sortOrder.value)
);
const filteredRecords = computed(() => {
const query = searchQuery.value.trim();
if (!query) return records.value;
return picoSearch(records.value, query, ['short_code', 'content']);
});
const uiFlags = computed(() => getters.getUIFlags.value);
const deleteConfirmText = computed(
@@ -114,103 +130,112 @@ const confirmDeletion = () => {
const tableHeaders = computed(() => {
return [
t('CANNED_MGMT.LIST.TABLE_HEADER.SHORT_CODE'),
t('CANNED_MGMT.LIST.TABLE_HEADER.CONTENT'),
t('CANNED_MGMT.LIST.TABLE_HEADER.ACTIONS'),
];
});
</script>
<template>
<div class="flex-1 overflow-auto">
<BaseSettingsHeader
:title="$t('CANNED_MGMT.HEADER')"
:description="$t('CANNED_MGMT.DESCRIPTION')"
:link-text="$t('CANNED_MGMT.LEARN_MORE')"
feature-name="canned_responses"
>
<template #actions>
<Button
icon="i-lucide-circle-plus"
:label="$t('CANNED_MGMT.HEADER_BTN_TXT')"
@click="openAddPopup"
/>
</template>
</BaseSettingsHeader>
<div class="mt-6 flex-1">
<woot-loading-state
v-if="uiFlags.fetchingList"
:message="$t('CANNED_MGMT.LOADING')"
/>
<p
v-else-if="!records.length"
class="flex flex-col items-center justify-center h-full text-base text-n-slate-11 py-8"
<SettingsLayout
:is-loading="uiFlags.fetchingList"
:loading-message="$t('CANNED_MGMT.LOADING')"
:no-records-found="!records.length"
:no-records-message="$t('CANNED_MGMT.LIST.404')"
>
<template #header>
<BaseSettingsHeader
v-model:search-query="searchQuery"
:title="$t('CANNED_MGMT.HEADER')"
:description="$t('CANNED_MGMT.DESCRIPTION')"
:link-text="$t('CANNED_MGMT.LEARN_MORE')"
:search-placeholder="$t('CANNED_MGMT.SEARCH_PLACEHOLDER')"
feature-name="canned_responses"
>
{{ $t('CANNED_MGMT.LIST.404') }}
</p>
<table v-else class="min-w-full overflow-x-auto divide-y divide-n-weak">
<thead>
<th
v-for="thHeader in tableHeaders"
:key="thHeader"
class="py-4 ltr:pr-4 rtl:pl-4 text-left font-semibold text-n-slate-11 last:text-right"
>
<span v-if="thHeader !== tableHeaders[0]">
{{ thHeader }}
</span>
<button
v-else
class="flex items-center p-0 cursor-pointer"
@click="toggleSort"
>
<span class="mb-0">
{{ thHeader }}
</span>
<fluent-icon
class="ml-2 size-4"
:icon="sortOrder === 'desc' ? 'chevron-up' : 'chevron-down'"
/>
</button>
</th>
</thead>
<tbody class="divide-y divide-n-weak text-n-slate-11">
<tr
v-for="(cannedItem, index) in records"
:key="cannedItem.short_code"
>
<td
class="py-4 ltr:pr-4 rtl:pl-4 truncate max-w-xs font-medium"
:title="cannedItem.short_code"
>
{{ cannedItem.short_code }}
</td>
<td class="py-4 ltr:pr-4 rtl:pl-4 md:break-all whitespace-normal">
{{ getPlainText(cannedItem.content) }}
</td>
<td class="py-4 flex justify-end gap-1">
<Button
v-tooltip.top="$t('CANNED_MGMT.EDIT.BUTTON_TEXT')"
icon="i-lucide-pen"
slate
xs
faded
@click="openEditPopup(cannedItem)"
/>
<Button
v-tooltip.top="$t('CANNED_MGMT.DELETE.BUTTON_TEXT')"
icon="i-lucide-trash-2"
xs
ruby
faded
:is-loading="loading[cannedItem.id]"
@click="openDeletePopup(cannedItem, index)"
/>
</td>
</tr>
</tbody>
</table>
</div>
<template #actions>
<Button
:label="$t('CANNED_MGMT.HEADER_BTN_TXT')"
@click="openAddPopup"
/>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<BaseTable
:headers="tableHeaders"
:items="filteredRecords"
:no-data-message="
!records.length
? $t('CANNED_MGMT.LIST.404')
: searchQuery
? $t('CANNED_MGMT.NO_RESULTS')
: ''
"
>
<template #header-0>
<button
class="flex items-center gap-2 p-0 cursor-pointer"
@click="toggleSort"
>
<span class="mb-0">
{{ tableHeaders[0] }}
</span>
<Icon
class="size-5 text-n-slate-11 flex-shrink-0"
:icon="
sortOrder === 'desc'
? 'i-woot-sort-descending'
: 'i-woot-sort-ascending'
"
/>
</button>
</template>
<template #header-1>
{{ tableHeaders[1] }}
</template>
<template #row="{ items }">
<BaseTableRow
v-for="cannedItem in items"
:key="cannedItem.short_code"
:item="cannedItem"
>
<template #default>
<BaseTableCell class="max-w-0">
<div class="flex flex-col gap-2 min-w-0">
<span class="text-heading-3 text-n-slate-12 truncate block">
{{ cannedItem.short_code }}
</span>
<p class="text-body-main text-n-slate-11 line-clamp-5">
{{ getPlainText(cannedItem.content) }}
</p>
</div>
</BaseTableCell>
<BaseTableCell align="end" class="w-24">
<div class="flex gap-3 justify-end flex-shrink-0">
<Button
v-tooltip.top="$t('CANNED_MGMT.EDIT.BUTTON_TEXT')"
icon="i-woot-edit-pen"
slate
sm
@click="openEditPopup(cannedItem)"
/>
<Button
v-tooltip.top="$t('CANNED_MGMT.DELETE.BUTTON_TEXT')"
icon="i-woot-bin"
slate
sm
:is-loading="loading[cannedItem.id]"
@click="openDeletePopup(cannedItem)"
/>
</div>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
</BaseTable>
</template>
<woot-modal v-model:show="showAddPopup" :on-close="hideAddPopup">
<AddCanned :on-close="hideAddPopup" />
</woot-modal>
@@ -235,5 +260,5 @@ const tableHeaders = computed(() => {
:confirm-text="deleteConfirmText"
:reject-text="deleteRejectText"
/>
</div>
</SettingsLayout>
</template>
@@ -1,9 +1,10 @@
<script setup>
import { computed, useSlots } from 'vue';
import CustomBrandPolicyWrapper from 'dashboard/components/CustomBrandPolicyWrapper.vue';
import { getHelpUrlForFeature } from '../../../../helper/featureHelper';
import BackButton from '../../../../components/widgets/BackButton.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const props = defineProps({
title: {
@@ -11,10 +12,6 @@ const props = defineProps({
required: true,
},
description: {
type: String,
required: true,
},
iconName: {
type: String,
default: '',
},
@@ -30,52 +27,66 @@ const props = defineProps({
type: String,
default: '',
},
searchPlaceholder: {
type: String,
default: '',
},
});
const helpURL = getHelpUrlForFeature(props.featureName);
const slots = useSlots();
const openInNewTab = url => {
if (!url) return;
window.open(url, '_blank', 'noopener noreferrer');
};
const searchQuery = defineModel('searchQuery', { type: String, default: '' });
const hasHeaderActions = computed(
() => props.searchPlaceholder || slots.actions
);
const helpURL = getHelpUrlForFeature(props.featureName);
</script>
<template>
<div class="flex flex-col items-start w-full gap-2">
<div class="flex flex-col items-start w-full">
<BackButton
v-if="backButtonLabel"
compact
:button-label="backButtonLabel"
class="my-1"
/>
<div class="flex items-center justify-between w-full gap-4">
<div class="flex items-center gap-3">
<div
v-if="iconName"
class="flex items-center w-10 h-10 p-1 rounded-full bg-n-blue-2"
<div
v-if="title"
class="flex items-center justify-between w-full gap-4 min-h-10"
:class="!hasHeaderActions ? 'mb-1' : 'mb-3'"
>
<h1 class="text-heading-1 text-n-slate-12">
{{ title }}
</h1>
<div v-if="searchPlaceholder || slots.actions" class="gap-3 flex">
<Input
v-if="searchPlaceholder"
v-model="searchQuery"
:placeholder="searchPlaceholder"
autofocus
class="w-56 min-w-0 hidden sm:flex [&>input]:ltr:!pl-8 [&>input]:rtl:!pr-8 [&>input]:!rounded-[0.625rem]"
size="md"
type="search"
>
<div
class="flex items-center justify-center w-full h-full rounded-full bg-n-blue-3"
>
<fluent-icon
size="14"
:icon="iconName"
type="outline"
class="flex-shrink-0 text-n-brand"
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute -translate-y-1/2 text-n-slate-11 size-4 top-1/2 ltr:left-2.5 rtl:right-2.5"
/>
</div>
</div>
<h1 class="text-xl font-medium tracking-tight text-n-slate-12">
{{ title }}
</h1>
</div>
<!-- Slot for additional actions on larger screens -->
<div class="hidden gap-2 sm:flex">
</template>
</Input>
<slot name="actions" />
</div>
</div>
<div class="flex flex-col w-full gap-3 text-n-slate-11">
<div
v-if="description || $slots.description || linkText || helpURL"
class="flex flex-col w-full gap-2 text-n-slate-11 my-3"
>
<p
class="mb-0 text-sm font-normal line-clamp-5 sm:line-clamp-none max-w-3xl"
v-if="description || $slots.description"
class="mb-0 line-clamp-5 sm:line-clamp-none max-w-3xl text-body-main"
>
<slot name="description">{{ description }}</slot>
</p>
@@ -85,31 +96,11 @@ const openInNewTab = url => {
:href="helpURL"
target="_blank"
rel="noopener noreferrer"
class="items-center hidden gap-1 text-sm font-medium sm:inline-flex w-fit text-n-blue-text hover:underline"
class="items-center gap-1 text-label inline-flex w-fit text-n-blue-11 hover:underline"
>
{{ linkText }}
<Icon
icon="i-lucide-chevron-right"
class="flex-shrink-0 text-n-blue-text size-4"
/>
</a>
</CustomBrandPolicyWrapper>
</div>
<div
class="flex flex-wrap items-start justify-start w-full gap-3 sm:hidden"
>
<slot name="actions" />
<CustomBrandPolicyWrapper :show-on-custom-branded-instance="false">
<Button
v-if="helpURL && linkText"
blue
link
icon="i-lucide-chevron-right"
trailing-icon
:label="linkText"
@click="openInNewTab(helpURL)"
/>
</CustomBrandPolicyWrapper>
</div>
</div>
</template>
@@ -9,6 +9,8 @@ import Button from 'dashboard/components-next/button/Button.vue';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { picoSearch } from '@scmmishra/pico-search';
import { BaseTable } from 'dashboard/components-next/table';
const store = useStore();
const { t } = useI18n();
@@ -19,8 +21,15 @@ const selectedRole = ref(null);
const loading = ref({});
const showDeleteConfirmationPopup = ref(false);
const activeResponse = ref({});
const searchQuery = ref('');
const records = useMapGetter('customRole/getCustomRoles');
const filteredRecords = computed(() => {
const query = searchQuery.value.trim();
if (!query) return records.value;
return picoSearch(records.value, query, ['name', 'description']);
});
const uiFlags = useMapGetter('customRole/getUIFlags');
const deleteConfirmText = computed(
@@ -129,14 +138,15 @@ const confirmDeletion = () => {
>
<template #header>
<BaseSettingsHeader
v-model:search-query="searchQuery"
:title="$t('CUSTOM_ROLE.HEADER')"
:description="$t('CUSTOM_ROLE.DESCRIPTION')"
:link-text="$t('CUSTOM_ROLE.LEARN_MORE')"
:search-placeholder="$t('CUSTOM_ROLE.SEARCH_PLACEHOLDER')"
feature-name="canned_responses"
>
<template #actions>
<Button
icon="i-lucide-circle-plus"
:label="$t('CUSTOM_ROLE.HEADER_BTN_TXT')"
:disabled="isBehindAPaywall"
@click="openAddModal"
@@ -147,26 +157,25 @@ const confirmDeletion = () => {
<template #body>
<CustomRolePaywall v-if="isBehindAPaywall" />
<table v-else class="min-w-full overflow-x-auto divide-y divide-n-weak">
<thead>
<th
v-for="thHeader in tableHeaders"
:key="thHeader"
class="py-4 ltr:pr-4 rtl:pl-4 font-semibold text-left text-n-slate-11"
>
<span class="mb-0">
{{ thHeader }}
</span>
</th>
</thead>
<CustomRoleTableBody
:roles="records"
:loading="loading"
@edit="openEditModal"
@delete="openDeletePopup"
/>
</table>
<BaseTable
v-else
:headers="tableHeaders"
:items="filteredRecords"
:no-data-message="
searchQuery
? $t('CUSTOM_ROLE.NO_RESULTS')
: $t('CUSTOM_ROLE.LIST.404')
"
>
<template #row="{ items }">
<CustomRoleTableBody
:roles="items"
:loading="loading"
@edit="openEditModal"
@delete="openDeletePopup"
/>
</template>
</BaseTable>
</template>
<woot-modal
@@ -79,7 +79,7 @@ const tableHeaders = computed(() => {
<th
v-for="thHeader in tableHeaders"
:key="thHeader"
class="py-4 ltr:pr-4 rtl:pl-4 font-semibold text-left text-n-slate-11"
class="py-4 ltr:pr-4 rtl:pl-4 text-start text-heading-3 text-n-slate-12"
>
<span class="mb-0">
{{ thHeader }}
@@ -3,6 +3,7 @@ import { useI18n } from 'vue-i18n';
import { getI18nKey } from 'dashboard/routes/dashboard/settings/helper/settingsHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import { BaseTableRow, BaseTableCell } from 'dashboard/components-next/table';
defineProps({
roles: {
@@ -27,43 +28,49 @@ const getFormattedPermissions = role => {
</script>
<template>
<tbody class="divide-y divide-n-weak text-n-slate-11">
<tr v-for="(customRole, index) in roles" :key="index">
<td
class="max-w-xs py-4 ltr:pr-4 rtl:pl-4 font-medium truncate align-baseline"
:title="customRole.name"
>
{{ customRole.name }}
</td>
<td
class="py-4 ltr:pr-4 rtl:pl-4 whitespace-normal align-baseline md:break-words"
>
{{ customRole.description }}
</td>
<td
class="py-4 ltr:pr-4 rtl:pl-4 whitespace-normal align-baseline md:break-words"
>
{{ getFormattedPermissions(customRole) }}
</td>
<td class="flex justify-end gap-1 py-4">
<Button
v-tooltip.top="$t('CUSTOM_ROLE.EDIT.BUTTON_TEXT')"
icon="i-lucide-pen"
slate
xs
faded
@click="emit('edit', customRole)"
/>
<Button
v-tooltip.top="$t('CUSTOM_ROLE.DELETE.BUTTON_TEXT')"
icon="i-lucide-trash-2"
xs
ruby
faded
:is-loading="loading[customRole.id]"
@click="emit('delete', customRole)"
/>
</td>
</tr>
</tbody>
<BaseTableRow
v-for="customRole in roles"
:key="customRole.id"
:item="customRole"
>
<template #default>
<BaseTableCell>
<span class="text-body-main text-n-slate-12 truncate block">
{{ customRole.name }}
</span>
</BaseTableCell>
<BaseTableCell>
<span class="text-body-main text-n-slate-11 truncate block">
{{ customRole.description }}
</span>
</BaseTableCell>
<BaseTableCell>
<span class="text-body-main text-n-slate-11 block">
{{ getFormattedPermissions(customRole) }}
</span>
</BaseTableCell>
<BaseTableCell align="end" class="w-24">
<div class="flex gap-3 justify-end flex-shrink-0">
<Button
v-tooltip.top="$t('CUSTOM_ROLE.EDIT.BUTTON_TEXT')"
icon="i-woot-edit-pen"
slate
sm
@click="emit('edit', customRole)"
/>
<Button
v-tooltip.top="$t('CUSTOM_ROLE.DELETE.BUTTON_TEXT')"
icon="i-woot-bin"
slate
sm
:is-loading="loading[customRole.id]"
@click="emit('delete', customRole)"
/>
</div>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
@@ -95,7 +95,7 @@ export default {
</script>
<template>
<div class="mx-8">
<div class="mx-6">
<SettingsSection
:title="$t('INBOX_MGMT.IMAP.TITLE')"
:sub-title="$t('INBOX_MGMT.IMAP.SUBTITLE')"
@@ -2,6 +2,7 @@
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { picoSearch } from '@scmmishra/pico-search';
import Avatar from 'next/avatar/Avatar.vue';
import { useAdmin } from 'dashboard/composables/useAdmin';
import SettingsLayout from '../SettingsLayout.vue';
@@ -22,6 +23,7 @@ const { isAdmin } = useAdmin();
const showDeletePopup = ref(false);
const selectedInbox = ref({});
const searchQuery = ref('');
const inboxes = useMapGetter('inboxes/getInboxes');
@@ -29,6 +31,12 @@ const inboxesList = computed(() => {
return inboxes.value?.slice().sort((a, b) => a.name.localeCompare(b.name));
});
const filteredInboxesList = computed(() => {
const query = searchQuery.value.trim();
if (!query) return inboxesList.value;
return picoSearch(inboxesList.value, query, ['name', 'channel_type']);
});
const uiFlags = computed(() => getters['inboxes/getUIFlags'].value);
const deleteConfirmText = computed(
@@ -80,88 +88,88 @@ const openDelete = inbox => {
>
<template #header>
<BaseSettingsHeader
v-model:search-query="searchQuery"
:title="$t('INBOX_MGMT.HEADER')"
:description="$t('INBOX_MGMT.DESCRIPTION')"
:link-text="$t('INBOX_MGMT.LEARN_MORE')"
:search-placeholder="$t('INBOX_MGMT.SEARCH_PLACEHOLDER')"
feature-name="inboxes"
>
<template #actions>
<router-link v-if="isAdmin" :to="{ name: 'settings_inbox_new' }">
<Button
icon="i-lucide-circle-plus"
:label="$t('SETTINGS.INBOXES.NEW_INBOX')"
/>
<Button :label="$t('SETTINGS.INBOXES.NEW_INBOX')" />
</router-link>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<table class="min-w-full overflow-x-auto">
<tbody class="divide-y divide-n-weak flex-1 text-n-slate-12">
<tr v-for="inbox in inboxesList" :key="inbox.id">
<td class="py-4 ltr:pr-4 rtl:pl-4">
<div class="flex items-center flex-row gap-4">
<div
v-if="inbox.avatar_url"
class="bg-n-alpha-3 rounded-full size-10 ring ring-n-solid-1 border border-n-strong shadow-sm grid place-items-center"
>
<Avatar
:src="inbox.avatar_url"
:name="inbox.name"
:size="24"
rounded-full
/>
</div>
<div
v-else
class="size-10 justify-center bg-n-alpha-3 rounded-full ring ring-n-solid-1 border border-n-strong shadow-sm grid place-items-center"
>
<ChannelIcon class="size-6 text-n-slate-10" :inbox="inbox" />
</div>
<div>
<span class="block font-medium capitalize">
{{ inbox.name }}
</span>
<ChannelName
:channel-type="inbox.channel_type"
:medium="inbox.medium"
/>
</div>
</div>
</td>
<td class="py-4">
<div class="flex gap-1 justify-end">
<router-link
:to="{
name: 'settings_inbox_show',
params: { inboxId: inbox.id },
}"
>
<Button
v-if="isAdmin"
v-tooltip.top="$t('INBOX_MGMT.SETTINGS')"
icon="i-lucide-settings"
slate
xs
faded
/>
</router-link>
<Button
v-if="isAdmin"
v-tooltip.top="$t('INBOX_MGMT.DELETE.BUTTON_TEXT')"
icon="i-lucide-trash-2"
xs
ruby
faded
@click="openDelete(inbox)"
/>
</div>
</td>
</tr>
</tbody>
</table>
<span
v-if="!filteredInboxesList.length && searchQuery"
class="flex-1 flex items-center justify-center py-20 text-center text-body-main !text-base text-n-slate-11"
>
{{ $t('INBOX_MGMT.NO_RESULTS') }}
</span>
<div v-else class="divide-y divide-n-weak">
<div
v-for="inbox in filteredInboxesList"
:key="inbox.id"
class="flex justify-between flex-row items-start gap-4 py-4"
>
<div class="flex items-center gap-4">
<div
v-if="inbox.avatar_url"
class="bg-n-alpha-3 rounded-xl size-10 ring ring-n-solid-1 border border-n-strong shadow-sm grid place-items-center"
>
<Avatar
:src="inbox.avatar_url"
:name="inbox.name"
:size="24"
rounded-full
/>
</div>
<div
v-else
class="size-10 justify-center bg-n-alpha-3 rounded-xl ring ring-n-solid-1 border border-n-strong shadow-sm grid place-items-center"
>
<ChannelIcon class="size-6 text-n-slate-10" :inbox="inbox" />
</div>
<div class="flex flex-col items-start gap-1">
<span class="block text-heading-3 text-n-slate-12 capitalize">
{{ inbox.name }}
</span>
<ChannelName
:channel-type="inbox.channel_type"
:medium="inbox.medium"
class="text-body-main text-n-slate-11"
/>
</div>
</div>
<div class="flex gap-3 justify-end">
<router-link
:to="{
name: 'settings_inbox_show',
params: { inboxId: inbox.id },
}"
>
<Button
v-if="isAdmin"
v-tooltip.top="$t('INBOX_MGMT.SETTINGS')"
icon="i-woot-settings"
slate
sm
/>
</router-link>
<Button
v-if="isAdmin"
v-tooltip.top="$t('INBOX_MGMT.DELETE.BUTTON_TEXT')"
icon="i-woot-bin"
slate
sm
@click="openDelete(inbox)"
/>
</div>
</div>
</div>
</template>
<woot-confirm-delete-modal
@@ -96,7 +96,7 @@ export default {
</script>
<template>
<div class="mx-8 my-2 text-base">
<div class="mx-6 my-2 text-base">
<div class="mx-0 mt-6 mb-3">
{{ $t('INBOX_MGMT.PRE_CHAT_FORM.DESCRIPTION') }}
</div>
@@ -509,7 +509,7 @@ export default {
:header-title="inboxName"
>
<woot-tabs
class="[&_ul]:p-0"
class="[&_ul]:p-0 top-px relative"
:index="selectedTabIndex"
:border="false"
@change="onTabChange"
@@ -540,7 +540,7 @@ export default {
:content="$t('INBOX_MGMT.ADD.INSTAGRAM.DUPLICATE_INBOX_BANNER')"
class="mx-8 mt-5"
/>
<div v-if="selectedTabKey === 'inbox-settings'" class="mx-8">
<div v-if="selectedTabKey === 'inbox-settings'" class="mx-6">
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_UPDATE_TITLE')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_UPDATE_SUB_TEXT')"
@@ -923,7 +923,7 @@ export default {
</SettingsSection>
</div>
<div v-if="selectedTabKey === 'collaborators'" class="mx-8">
<div v-if="selectedTabKey === 'collaborators'" class="mx-6">
<CollaboratorsPage :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'configuration'">
@@ -155,7 +155,7 @@ export default {
</script>
<template>
<div class="mx-8">
<div class="mx-6">
<SettingsSection
:title="$t('INBOX_MGMT.SMTP.TITLE')"
:sub-title="$t('INBOX_MGMT.SMTP.SUBTITLE')"
@@ -271,8 +271,8 @@ export default {
</script>
<template>
<div class="mx-8">
<div class="flex p-2.5">
<div class="mx-6">
<div class="flex py-6">
<div class="w-100 lg:w-[40%]">
<div class="min-h-full py-4 overflow-y-scroll px-px">
<form @submit.prevent="updateWidget">
@@ -136,7 +136,7 @@ const getStatusTextColor = status => STATUS_COLORS[status] || 'text-n-slate-12';
</script>
<template>
<div class="gap-4 pt-8 mx-8">
<div class="gap-4 pt-8 mx-6">
<div
class="px-5 py-5 space-y-6 rounded-xl border shadow-sm border-n-weak bg-n-solid-2"
>
@@ -74,7 +74,7 @@ export default {
</script>
<template>
<div class="mx-8">
<div class="mx-6">
<LoadingState v-if="uiFlags.isFetching || uiFlags.isFetchingAgentBot" />
<form
v-else
@@ -121,7 +121,7 @@ export default {
</script>
<template>
<div class="mx-8">
<div class="mx-6">
<SettingsSection
:title="$t('INBOX_MGMT.BUSINESS_HOURS.TITLE')"
:sub-title="$t('INBOX_MGMT.BUSINESS_HOURS.SUBTITLE')"
@@ -3,7 +3,7 @@ import { frontendURL } from '../../../../helper/URLHelper';
import ChannelFactory from './ChannelFactory.vue';
import SettingsContent from '../Wrapper.vue';
import SettingWrapper from '../SettingsWrapper.vue';
import SettingsWrapper from '../SettingsWrapper.vue';
import InboxHome from './Index.vue';
import Settings from './Settings.vue';
import InboxChannel from './InboxChannels.vue';
@@ -15,7 +15,7 @@ export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/inboxes'),
component: SettingWrapper,
component: SettingsWrapper,
children: [
{
path: '',
@@ -149,7 +149,7 @@ export default {
</script>
<template>
<div v-if="isATwilioChannel" class="mx-8">
<div v-if="isATwilioChannel" class="mx-6">
<SettingsSection
:title="$t('INBOX_MGMT.ADD.TWILIO.API_CALLBACK.TITLE')"
:sub-title="$t('INBOX_MGMT.ADD.TWILIO.API_CALLBACK.SUBTITLE')"
@@ -170,7 +170,7 @@ export default {
</div>
</SettingsSection>
</div>
<div v-else-if="isAVoiceChannel" class="mx-8">
<div v-else-if="isAVoiceChannel" class="mx-6">
<SettingsSection
:title="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
:sub-title="
@@ -189,7 +189,7 @@ export default {
</SettingsSection>
</div>
<div v-else-if="isALineChannel" class="mx-8">
<div v-else-if="isALineChannel" class="mx-6">
<SettingsSection
:title="$t('INBOX_MGMT.ADD.LINE_CHANNEL.API_CALLBACK.TITLE')"
:sub-title="$t('INBOX_MGMT.ADD.LINE_CHANNEL.API_CALLBACK.SUBTITLE')"
@@ -198,7 +198,7 @@ export default {
</SettingsSection>
</div>
<div v-else-if="isAWebWidgetInbox">
<div class="mx-8">
<div class="mx-6">
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.MESSENGER_HEADING')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.MESSENGER_SUB_HEAD')"
@@ -268,7 +268,7 @@ export default {
</SettingsSection>
</div>
</div>
<div v-else-if="isAPIInbox" class="mx-8">
<div v-else-if="isAPIInbox" class="mx-6">
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_IDENTIFIER')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_IDENTIFIER_SUB_TEXT')"
@@ -300,7 +300,7 @@ export default {
</SettingsSection>
</div>
<div v-else-if="isAnEmailChannel">
<div class="mx-8">
<div class="mx-6">
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.FORWARD_EMAIL_TITLE')"
:sub-title="
@@ -327,7 +327,7 @@ export default {
<SmtpSettings v-if="inbox.imap_enabled" :inbox="inbox" />
</div>
<div v-else-if="isAWhatsAppChannel && !isATwilioChannel">
<div v-if="inbox.provider_config" class="mx-8">
<div v-if="inbox.provider_config" class="mx-6">
<!-- Embedded Signup Section -->
<template v-if="isEmbeddedSignupWhatsApp">
<SettingsSection
@@ -414,7 +414,7 @@ const handleConfirmTemplateUpdate = async () => {
</script>
<template>
<div class="mx-8">
<div class="mx-6">
<SectionLayout
:title="$t('INBOX_MGMT.CSAT.TITLE')"
:description="$t('INBOX_MGMT.CSAT.SUBTITLE')"
@@ -1,4 +1,5 @@
<script setup>
import { BaseTableRow, BaseTableCell } from 'dashboard/components-next/table';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
@@ -12,40 +13,48 @@ defineEmits(['edit', 'delete']);
</script>
<template>
<tr class="max-w-full py-1">
<td
class="py-4 ltr:pr-4 rtl:pl-4 text-sm w-40 max-w-[10rem] truncate"
:title="app.title"
>
{{ app.title }}
</td>
<td
class="max-w-lg py-4 ltr:pr-4 rtl:pl-4 text-sm truncate"
:title="app.content[0].url"
>
{{ app.content[0].url }}
</td>
<td class="flex gap-1 py-4 ltr:pr-4 rtl:pl-4 text-sm sm:pr-0 justify-end">
<Button
v-tooltip.top="
$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.LIST.EDIT_TOOLTIP')
"
icon="i-lucide-pen"
slate
xs
faded
@click="$emit('edit', app)"
/>
<Button
v-tooltip.top="
$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.LIST.DELETE_TOOLTIP')
"
icon="i-lucide-trash-2"
xs
ruby
faded
@click="$emit('delete', app)"
/>
</td>
</tr>
<BaseTableRow :item="app">
<template #default>
<BaseTableCell>
<span
class="text-body-main text-n-slate-12 truncate block"
:title="app.title"
>
{{ app.title }}
</span>
</BaseTableCell>
<BaseTableCell>
<span
class="text-body-main text-n-slate-11 truncate block"
:title="app.content[0].url"
>
{{ app.content[0].url }}
</span>
</BaseTableCell>
<BaseTableCell align="end" class="w-24">
<div class="flex justify-end gap-3 flex-shrink-0">
<Button
v-tooltip.top="
$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.LIST.EDIT_TOOLTIP')
"
icon="i-woot-edit-pen"
slate
sm
@click="$emit('edit', app)"
/>
<Button
v-tooltip.top="
$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.LIST.DELETE_TOOLTIP')
"
icon="i-woot-bin"
slate
sm
@click="$emit('delete', app)"
/>
</div>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
@@ -1,14 +1,19 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { picoSearch } from '@scmmishra/pico-search';
import { BaseTable } from 'dashboard/components-next/table';
import DashboardAppModal from './DashboardAppModal.vue';
import DashboardAppsRow from './DashboardAppsRow.vue';
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
import SettingsLayout from '../../SettingsLayout.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
BaseSettingsHeader,
SettingsLayout,
BaseTable,
DashboardAppModal,
DashboardAppsRow,
NextButton,
@@ -20,6 +25,7 @@ export default {
showDeleteConfirmationPopup: false,
selectedApp: {},
mode: 'CREATE',
searchQuery: '',
};
},
computed: {
@@ -27,12 +33,20 @@ export default {
records: 'dashboardApps/getRecords',
uiFlags: 'dashboardApps/getUIFlags',
}),
filteredRecords() {
const query = this.searchQuery.trim();
if (!query) return this.records;
return picoSearch(this.records, query, ['title']);
},
tableHeaders() {
return [
this.$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.LIST.TABLE_HEADER.NAME'),
this.$t(
'INTEGRATION_SETTINGS.DASHBOARD_APPS.LIST.TABLE_HEADER.ENDPOINT'
),
this.$t(
'INTEGRATION_SETTINGS.DASHBOARD_APPS.LIST.TABLE_HEADER.ACTIONS'
),
];
},
},
@@ -84,59 +98,52 @@ export default {
</script>
<template>
<div class="flex flex-col flex-1 gap-8 overflow-auto">
<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>
<NextButton
icon="i-lucide-circle-plus"
:label="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.HEADER_BTN_TXT')"
@click="openCreatePopup"
/>
</template>
</BaseSettingsHeader>
<div class="w-full overflow-x-auto text-n-slate-11">
<p
v-if="!uiFlags.isFetching && !records.length"
class="flex flex-col items-center justify-center h-full"
<SettingsLayout
:is-loading="uiFlags.isFetching"
:loading-message="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.LIST.LOADING')"
:no-records-found="!records.length"
:no-records-message="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.LIST.404')"
>
<template #header>
<BaseSettingsHeader
v-model:search-query="searchQuery"
:title="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.TITLE')"
:description="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.DESCRIPTION')"
:link-text="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.LEARN_MORE')"
:search-placeholder="
$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.SEARCH_PLACEHOLDER')
"
feature-name="dashboard_apps"
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
>
{{ $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-n-weak"
<template #actions>
<NextButton
:label="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.HEADER_BTN_TXT')"
@click="openCreatePopup"
/>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<span
v-if="!filteredRecords.length && searchQuery"
class="flex-1 flex items-center justify-center py-20 text-center text-body-main !text-base text-n-slate-11"
>
<thead>
<th
v-for="thHeader in tableHeaders"
:key="thHeader"
class="py-4 ltr:pr-4 rtl:pl-4 font-semibold text-left text-n-slate-11"
>
{{ thHeader }}
</th>
</thead>
<tbody class="divide-y divide-n-weak">
{{ $t('INTEGRATION_SETTINGS.DASHBOARD_APPS.NO_RESULTS') }}
</span>
<BaseTable v-else :headers="tableHeaders" :items="filteredRecords">
<template #row="{ items }">
<DashboardAppsRow
v-for="(dashboardAppItem, index) in records"
v-for="(dashboardAppItem, index) in items"
:key="dashboardAppItem.id"
:index="index"
:app="dashboardAppItem"
@edit="editApp"
@delete="openDeletePopup"
/>
</tbody>
</table>
</div>
</template>
</BaseTable>
</template>
<DashboardAppModal
v-if="showDashboardAppPopup"
:show="showDashboardAppPopup"
@@ -160,5 +167,5 @@ export default {
"
:reject-text="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.DELETE.CONFIRM_NO')"
/>
</div>
</SettingsLayout>
</template>
@@ -1,7 +1,8 @@
<script setup>
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import { computed, onMounted } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { useBranding } from 'shared/composables/useBranding';
import { picoSearch } from '@scmmishra/pico-search';
import IntegrationItem from './IntegrationItem.vue';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
@@ -10,12 +11,19 @@ const store = useStore();
const getters = useStoreGetters();
const { replaceInstallationName } = useBranding();
const searchQuery = ref('');
const uiFlags = getters['integrations/getUIFlags'];
const integrationList = computed(
() => getters['integrations/getAppIntegrations'].value
);
const filteredIntegrationList = computed(() => {
const query = searchQuery.value.trim();
if (!query) return integrationList.value;
return picoSearch(integrationList.value, query, ['name', 'description']);
});
onMounted(() => {
store.dispatch('integrations/get');
});
@@ -28,19 +36,30 @@ onMounted(() => {
>
<template #header>
<BaseSettingsHeader
v-model:search-query="searchQuery"
:title="$t('INTEGRATION_SETTINGS.HEADER')"
:description="
replaceInstallationName($t('INTEGRATION_SETTINGS.DESCRIPTION'))
"
:link-text="$t('INTEGRATION_SETTINGS.LEARN_MORE')"
:search-placeholder="$t('INTEGRATION_SETTINGS.SEARCH_PLACEHOLDER')"
feature-name="integrations"
/>
</template>
<template #body>
<div class="flex-grow flex-shrink overflow-auto">
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<span
v-if="!filteredIntegrationList.length && searchQuery"
class="flex-1 flex items-center justify-center py-20 text-center text-body-main !text-base text-n-slate-11"
>
{{ $t('INTEGRATION_SETTINGS.NO_RESULTS') }}
</span>
<div
v-else
class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"
>
<IntegrationItem
v-for="item in integrationList"
v-for="item in filteredIntegrationList"
:id="item.id"
:key="item.id"
:logo="item.logo"
@@ -6,12 +6,16 @@ import { useIntegrationHook } from 'dashboard/composables/useIntegrationHook';
import NewHook from './NewHook.vue';
import SingleIntegrationHooks from './SingleIntegrationHooks.vue';
import MultipleIntegrationHooks from './MultipleIntegrationHooks.vue';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
export default {
components: {
NewHook,
SingleIntegrationHooks,
MultipleIntegrationHooks,
SettingsLayout,
BaseSettingsHeader,
},
props: {
integrationId: {
@@ -108,26 +112,35 @@ export default {
</script>
<template>
<div class="overflow-auto p-4 w-full my-auto flex flex-wrap h-full">
<div v-if="showIntegrationHooks" class="w-full">
<div v-if="isIntegrationMultiple">
<MultipleIntegrationHooks
:integration-id="integrationId"
:show-add-button="showAddButton"
@add="openAddHookModal"
@delete="openDeletePopup"
/>
</div>
<SettingsLayout :is-loading="uiFlags.isFetching">
<template v-if="isIntegrationSingle" #header>
<BaseSettingsHeader
:title="integration.name || ''"
description=""
:feature-name="integrationId"
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
/>
</template>
<template #body>
<div v-if="showIntegrationHooks" class="w-full">
<div v-if="isIntegrationMultiple">
<MultipleIntegrationHooks
:integration-id="integrationId"
:show-add-button="showAddButton"
@add="openAddHookModal"
@delete="openDeletePopup"
/>
</div>
<div v-if="isIntegrationSingle">
<SingleIntegrationHooks
:integration-id="integrationId"
@add="openAddHookModal"
@delete="openDeletePopup"
/>
<div v-if="isIntegrationSingle">
<SingleIntegrationHooks
:integration-id="integrationId"
@add="openAddHookModal"
@delete="openDeletePopup"
/>
</div>
</div>
</div>
</template>
<woot-modal v-model:show="showAddHookModal" :on-close="hideAddHookModal">
<NewHook :integration-id="integrationId" @close="hideAddHookModal" />
</woot-modal>
@@ -141,5 +154,5 @@ export default {
:confirm-text="confirmText"
:reject-text="cancelText"
/>
</div>
</SettingsLayout>
</template>
@@ -7,7 +7,8 @@ import {
} from 'dashboard/composables/store';
import Integration from './Integration.vue';
import Spinner from 'shared/components/Spinner.vue';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
const store = useStore();
@@ -35,8 +36,16 @@ onMounted(() => {
</script>
<template>
<div class="flex-grow flex-shrink p-4 overflow-auto max-w-6xl mx-auto">
<div v-if="integrationLoaded && !uiFlags.isCreatingLinear">
<SettingsLayout :is-loading="!integrationLoaded || uiFlags.isCreatingLinear">
<template #header>
<BaseSettingsHeader
:title="$t('INTEGRATION_SETTINGS.LINEAR.HEADER')"
description=""
feature-name="linear_integration"
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
/>
</template>
<template #body>
<Integration
:integration-id="integration.id"
:integration-logo="integration.logo"
@@ -49,9 +58,6 @@ onMounted(() => {
message: $t('INTEGRATION_SETTINGS.LINEAR.DELETE.MESSAGE'),
}"
/>
</div>
<div v-else class="flex items-center justify-center flex-1">
<Spinner size="" color-scheme="primary" />
</div>
</div>
</template>
</SettingsLayout>
</template>
@@ -1,64 +1,80 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { ref, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useIntegrationHook } from 'dashboard/composables/useIntegrationHook';
import {
BaseTable,
BaseTableRow,
BaseTableCell,
} from 'dashboard/components-next/table';
import { useI18n } from 'vue-i18n';
import BaseSettingsHeader from 'dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
BaseSettingsHeader,
NextButton,
const props = defineProps({
integrationId: {
type: String,
required: true,
},
props: {
integrationId: {
type: String,
required: true,
},
showAddButton: {
type: Boolean,
default: false,
},
showAddButton: {
type: Boolean,
default: false,
},
emits: ['delete', 'add'],
setup(props) {
const { integration, isHookTypeInbox, hasConnectedHooks } =
useIntegrationHook(props.integrationId);
return { integration, isHookTypeInbox, hasConnectedHooks };
},
computed: {
...mapGetters({
globalConfig: 'globalConfig/get',
}),
hookHeaders() {
return this.integration.visible_properties;
},
hooks() {
if (!this.hasConnectedHooks) {
return [];
}
const { hooks } = this.integration;
return hooks.map(hook => ({
...hook,
id: hook.id,
properties: this.hookHeaders.map(property =>
hook.settings[property] ? hook.settings[property] : '--'
),
}));
},
},
mounted() {},
methods: {
inboxName(hook) {
return hook.inbox ? hook.inbox.name : '';
},
},
};
});
defineEmits(['delete', 'add']);
const { t } = useI18n();
const { integration, isHookTypeInbox, hasConnectedHooks } = useIntegrationHook(
props.integrationId
);
const globalConfig = useMapGetter('globalConfig/get');
const searchQuery = ref('');
const hookHeaders = computed(() => {
const headers = [...(integration.value.visible_properties || [])];
if (isHookTypeInbox.value) {
headers.push(t('INTEGRATION_APPS.LIST.INBOX'));
}
headers.push(t('INTEGRATION_APPS.LIST.ACTIONS'));
return headers;
});
const hooks = computed(() => {
if (!hasConnectedHooks.value) {
return [];
}
const { hooks: integrationHooks } = integration.value;
const visibleProperties = integration.value.visible_properties || [];
return integrationHooks.map(hook => ({
...hook,
id: hook.id,
properties: visibleProperties.map(property =>
hook.settings[property] ? hook.settings[property] : '--'
),
}));
});
const filteredHooks = computed(() => {
const query = searchQuery.value?.trim() || '';
if (!query) return hooks.value;
const lowerQuery = query.toLowerCase();
return (
hooks.value?.filter(hook =>
hook.properties?.some(prop => prop?.toLowerCase().includes(lowerQuery))
) || []
);
});
const inboxName = hook => (hook.inbox ? hook.inbox.name : '');
</script>
<template>
<div class="flex flex-col flex-1 gap-8 overflow-auto">
<BaseSettingsHeader
:title="integration.name"
v-model:search-query="searchQuery"
:title="integration.name || ''"
:description="
$t(
`INTEGRATION_APPS.SIDEBAR_DESCRIPTION.${integration.name.toUpperCase()}`,
@@ -67,61 +83,58 @@ export default {
"
:feature-name="integrationId"
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
:search-placeholder="$t('INTEGRATION_APPS.SEARCH_PLACEHOLDER')"
>
<template #actions>
<NextButton
v-if="showAddButton"
icon="i-lucide-circle-plus"
:label="$t('INTEGRATION_APPS.ADD_BUTTON')"
@click="$emit('add')"
/>
</template>
</BaseSettingsHeader>
<div class="w-full">
<table v-if="hasConnectedHooks">
<thead
class="[&>th]:font-semibold [&>th]:tracking-[1px] ltr:[&>th]:text-left rtl:[&>th]:text-right [&>th]:px-2.5 [&>th]:uppercase [&>th]:text-n-slate-12"
>
<th
v-for="hookHeader in hookHeaders"
:key="hookHeader"
class="ltr:!pl-0 rtl:!pr-0"
>
{{ hookHeader }}
</th>
<th v-if="isHookTypeInbox">
{{ $t('INTEGRATION_APPS.LIST.INBOX') }}
</th>
</thead>
<tbody>
<tr
v-for="hook in hooks"
:key="hook.id"
class="border-b border-n-weak [&>td]:p-2.5 [&>td]:text-n-slate-12"
>
<td
v-for="property in hook.properties"
:key="property"
class="ltr:!pl-0 rtl:!pr-0"
>
{{ property }}
</td>
<td v-if="isHookTypeInbox" class="break-words">
{{ inboxName(hook) }}
</td>
<td class="flex justify-end gap-1">
<NextButton
v-tooltip.top="$t('INTEGRATION_APPS.LIST.DELETE.BUTTON_TEXT')"
icon="i-lucide-trash-2"
xs
ruby
faded
@click="$emit('delete', hook)"
/>
</td>
</tr>
</tbody>
</table>
<BaseTable
v-if="hasConnectedHooks"
:headers="hookHeaders"
:items="filteredHooks"
:no-data-message="searchQuery ? $t('INTEGRATION_APPS.NO_RESULTS') : ''"
>
<template #row="{ items }">
<BaseTableRow v-for="hook in items" :key="hook.id" :item="hook">
<template #default>
<BaseTableCell
v-for="property in hook.properties"
:key="property"
>
<span class="text-body-main text-n-slate-12">
{{ property }}
</span>
</BaseTableCell>
<BaseTableCell v-if="isHookTypeInbox">
<span class="text-body-main text-n-slate-11 break-words">
{{ inboxName(hook) }}
</span>
</BaseTableCell>
<BaseTableCell align="end" class="w-12">
<div class="flex justify-end gap-3 flex-shrink-0">
<NextButton
v-tooltip.top="
$t('INTEGRATION_APPS.LIST.DELETE.BUTTON_TEXT')
"
icon="i-woot-bin"
slate
sm
@click="$emit('delete', hook)"
/>
</div>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
</BaseTable>
<p v-else class="flex flex-col items-center justify-center h-full">
{{
$t('INTEGRATION_APPS.NO_HOOK_CONFIGURED', {
@@ -11,7 +11,8 @@ import ButtonNext from 'next/button/Button.vue';
import notionClient from 'dashboard/api/notion_auth.js';
import Integration from './Integration.vue';
import Spinner from 'shared/components/Spinner.vue';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
const { t } = useI18n();
const store = useStore();
@@ -49,8 +50,16 @@ onMounted(() => {
</script>
<template>
<div class="flex-grow flex-shrink p-4 overflow-auto mx-auto">
<div v-if="integrationLoaded && !uiFlags.isCreatingNotion">
<SettingsLayout :is-loading="!integrationLoaded || uiFlags.isCreatingNotion">
<template #header>
<BaseSettingsHeader
:title="$t('INTEGRATION_SETTINGS.NOTION.HEADER')"
description=""
feature-name="notion_integration"
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
/>
</template>
<template #body>
<Integration
:integration-id="integration.id"
:integration-logo="integration.logo"
@@ -72,9 +81,6 @@ onMounted(() => {
/>
</template>
</Integration>
</div>
<div v-else class="flex items-center justify-center flex-1">
<Spinner size="" color-scheme="primary" />
</div>
</div>
</template>
</SettingsLayout>
</template>
@@ -5,13 +5,15 @@ import {
useMapGetter,
useStore,
} from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import Integration from './Integration.vue';
import Spinner from 'shared/components/Spinner.vue';
import integrationAPI from 'dashboard/api/integrations';
import Input from 'dashboard/components-next/input/Input.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
defineProps({
error: {
@@ -21,6 +23,7 @@ defineProps({
});
const store = useStore();
const { t } = useI18n();
const dialogRef = ref(null);
const integrationLoaded = ref(false);
const storeUrl = ref('');
@@ -88,64 +91,67 @@ onMounted(() => {
</script>
<template>
<div class="flex-grow flex-shrink p-4 overflow-auto max-w-6xl mx-auto">
<div
v-if="integrationLoaded && !uiFlags.isCreatingShopify"
class="flex flex-col gap-6"
>
<Integration
:integration-id="integration.id"
:integration-logo="integration.logo"
:integration-name="integration.name"
:integration-description="integration.description"
:integration-enabled="integration.enabled"
:integration-action="integrationAction"
:delete-confirmation-text="{
title: $t('INTEGRATION_SETTINGS.SHOPIFY.DELETE.TITLE'),
message: $t('INTEGRATION_SETTINGS.SHOPIFY.DELETE.MESSAGE'),
}"
>
<template #action>
<Button
teal
:label="$t('INTEGRATION_SETTINGS.CONNECT.BUTTON_TEXT')"
@click="openStoreUrlDialog"
<SettingsLayout :is-loading="!integrationLoaded || uiFlags.isCreatingShopify">
<template #header>
<BaseSettingsHeader
:title="$t('INTEGRATION_SETTINGS.SHOPIFY.HEADER')"
description=""
feature-name="shopify_integration"
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
/>
</template>
<template #body>
<div class="flex flex-col gap-6">
<Integration
:integration-id="integration.id"
:integration-logo="integration.logo"
:integration-name="integration.name"
:integration-description="integration.description"
:integration-enabled="integration.enabled"
:integration-action="integrationAction"
:delete-confirmation-text="{
title: t('INTEGRATION_SETTINGS.SHOPIFY.DELETE.TITLE'),
message: t('INTEGRATION_SETTINGS.SHOPIFY.DELETE.MESSAGE'),
}"
>
<template #action>
<Button
teal
:label="t('INTEGRATION_SETTINGS.CONNECT.BUTTON_TEXT')"
@click="openStoreUrlDialog"
/>
</template>
</Integration>
<div
v-if="error"
class="flex items-center justify-center flex-1 outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow p-6"
>
<p class="text-n-ruby-9">
{{ t('INTEGRATION_SETTINGS.SHOPIFY.ERROR') }}
</p>
</div>
<Dialog
ref="dialogRef"
:title="t('INTEGRATION_SETTINGS.SHOPIFY.STORE_URL.TITLE')"
:is-loading="isSubmitting"
@confirm="handleStoreUrlSubmit"
@close="hideStoreUrlModal"
>
<Input
v-model="storeUrl"
:label="t('INTEGRATION_SETTINGS.SHOPIFY.STORE_URL.LABEL')"
:placeholder="
t('INTEGRATION_SETTINGS.SHOPIFY.STORE_URL.PLACEHOLDER')
"
:message="
!storeUrlError
? t('INTEGRATION_SETTINGS.SHOPIFY.STORE_URL.HELP')
: storeUrlError
"
:message-type="storeUrlError ? 'error' : 'info'"
/>
</template>
</Integration>
<div
v-if="error"
class="flex items-center justify-center flex-1 outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow p-6"
>
<p class="text-n-ruby-9">
{{ $t('INTEGRATION_SETTINGS.SHOPIFY.ERROR') }}
</p>
</Dialog>
</div>
<Dialog
ref="dialogRef"
:title="$t('INTEGRATION_SETTINGS.SHOPIFY.STORE_URL.TITLE')"
:is-loading="isSubmitting"
@confirm="handleStoreUrlSubmit"
@close="hideStoreUrlModal"
>
<Input
v-model="storeUrl"
:label="$t('INTEGRATION_SETTINGS.SHOPIFY.STORE_URL.LABEL')"
:placeholder="
$t('INTEGRATION_SETTINGS.SHOPIFY.STORE_URL.PLACEHOLDER')
"
:message="
!storeUrlError
? $t('INTEGRATION_SETTINGS.SHOPIFY.STORE_URL.HELP')
: storeUrlError
"
:message-type="storeUrlError ? 'error' : 'info'"
/>
</Dialog>
</div>
<div v-else class="flex items-center justify-center flex-1">
<Spinner size="" color-scheme="primary" />
</div>
</div>
</template>
</SettingsLayout>
</template>
@@ -6,7 +6,8 @@ import { useI18n } from 'vue-i18n';
import Integration from './Integration.vue';
import SelectChannelWarning from './Slack/SelectChannelWarning.vue';
import SlackIntegrationHelpText from './Slack/SlackIntegrationHelpText.vue';
import Spinner from 'shared/components/Spinner.vue';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
const props = defineProps({
code: { type: String, default: '' },
@@ -76,32 +77,42 @@ onMounted(() => {
</script>
<template>
<div
v-if="integrationLoaded && !uiFlags.isCreatingSlack"
class="flex flex-col flex-1 overflow-auto gap-5 pt-1 pb-10"
>
<Integration
:integration-id="integration.id"
:integration-logo="integration.logo"
:integration-name="integration.name"
:integration-description="integration.description"
:integration-enabled="integration.enabled"
:integration-action="integrationAction"
:action-button-text="$t('INTEGRATION_SETTINGS.SLACK.DELETE')"
:delete-confirmation-text="{
title: $t('INTEGRATION_SETTINGS.SLACK.DELETE_CONFIRMATION.TITLE'),
message: $t('INTEGRATION_SETTINGS.SLACK.DELETE_CONFIRMATION.MESSAGE'),
}"
/>
<div v-if="areHooksAvailable" class="flex-1">
<SelectChannelWarning
v-if="!isIntegrationHookEnabled"
:has-connected-a-channel="hasConnectedAChannel"
<SettingsLayout :is-loading="!integrationLoaded || uiFlags.isCreatingSlack">
<template #header>
<BaseSettingsHeader
:title="$t('INTEGRATION_SETTINGS.SLACK.HEADER')"
description=""
feature-name="slack_integration"
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
/>
<SlackIntegrationHelpText :selected-channel-name="selectedChannelName" />
</div>
</div>
<div v-else class="flex items-center justify-center flex-1">
<Spinner size="" color-scheme="primary" />
</div>
</template>
<template #body>
<div class="space-y-5">
<Integration
:integration-id="integration.id"
:integration-logo="integration.logo"
:integration-name="integration.name"
:integration-description="integration.description"
:integration-enabled="integration.enabled"
:integration-action="integrationAction"
:action-button-text="$t('INTEGRATION_SETTINGS.SLACK.DELETE')"
:delete-confirmation-text="{
title: $t('INTEGRATION_SETTINGS.SLACK.DELETE_CONFIRMATION.TITLE'),
message: $t(
'INTEGRATION_SETTINGS.SLACK.DELETE_CONFIRMATION.MESSAGE'
),
}"
/>
<div v-if="areHooksAvailable" class="flex-1">
<SelectChannelWarning
v-if="!isIntegrationHookEnabled"
:has-connected-a-channel="hasConnectedAChannel"
/>
<SlackIntegrationHelpText
:selected-channel-name="selectedChannelName"
/>
</div>
</div>
</template>
</SettingsLayout>
</template>
@@ -2,7 +2,9 @@
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useBranding } from 'shared/composables/useBranding';
import { picoSearch } from '@scmmishra/pico-search';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { BaseTable } from 'dashboard/components-next/table';
import NewWebhook from './NewWebHook.vue';
import EditWebhook from './EditWebHook.vue';
import WebhookRow from './WebhookRow.vue';
@@ -14,6 +16,7 @@ export default {
SettingsLayout,
NextButton,
BaseSettingsHeader,
BaseTable,
NewWebhook,
EditWebhook,
WebhookRow,
@@ -29,6 +32,7 @@ export default {
showEditPopup: false,
showDeleteConfirmationPopup: false,
selectedWebHook: {},
searchQuery: '',
};
},
computed: {
@@ -39,6 +43,11 @@ export default {
integration() {
return this.$store.getters['integrations/getIntegration']('webhook');
},
filteredRecords() {
const query = this.searchQuery.trim();
if (!query) return this.records;
return picoSearch(this.records, query, ['name', 'url']);
},
tableHeaders() {
return [
this.$t(
@@ -103,16 +112,19 @@ export default {
<template #header>
<BaseSettingsHeader
v-if="integration.name"
v-model:search-query="searchQuery"
:title="integration.name"
:description="replaceInstallationName(integration.description)"
:link-text="$t('INTEGRATION_SETTINGS.WEBHOOK.LEARN_MORE')"
:search-placeholder="
$t('INTEGRATION_SETTINGS.WEBHOOK.SEARCH_PLACEHOLDER')
"
feature-name="webhook"
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
>
<template #actions>
<NextButton
blue
icon="i-lucide-circle-plus"
:label="$t('INTEGRATION_SETTINGS.WEBHOOK.HEADER_BTN_TXT')"
@click="openAddPopup"
/>
@@ -120,27 +132,24 @@ export default {
</BaseSettingsHeader>
</template>
<template #body>
<table class="min-w-full divide-y divide-n-weak">
<thead>
<th
v-for="thHeader in tableHeaders"
:key="thHeader"
class="py-4 ltr:pr-4 rtl:pl-4 text-left font-semibold text-n-slate-11 last:text-right last:pr-4"
>
{{ thHeader }}
</th>
</thead>
<tbody class="divide-y divide-n-weak flex-1 text-n-slate-12">
<BaseTable
:headers="tableHeaders"
:items="filteredRecords"
:no-data-message="
searchQuery ? $t('INTEGRATION_SETTINGS.WEBHOOK.NO_RESULTS') : ''
"
>
<template #row="{ items }">
<WebhookRow
v-for="(webHookItem, index) in records"
v-for="(webHookItem, index) in items"
:key="webHookItem.id"
:index="index"
:webhook="webHookItem"
@edit="openEditPopup"
@delete="openDeletePopup"
/>
</tbody>
</table>
</template>
</BaseTable>
</template>
<woot-modal v-model:show="showAddPopup" :on-close="hideAddPopup">
<NewWebhook v-if="showAddPopup" :on-close="hideAddPopup" />
@@ -3,7 +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 { BaseTableRow, BaseTableCell } from 'dashboard/components-next/table';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
@@ -35,45 +35,48 @@ const subscribedEvents = computed(() => {
</script>
<template>
<tr>
<td class="py-4 ltr:pr-4 rtl:pl-4">
<div class="flex gap-2 font-medium break-words text-n-slate-12">
<template v-if="webhook.name">
{{ webhook.name }}
<span class="text-n-slate-11">
<BaseTableRow :item="webhook">
<template #default>
<BaseTableCell>
<div class="flex gap-2 font-medium break-words text-n-slate-12">
<template v-if="webhook.name">
{{ webhook.name }}
<span class="text-n-slate-11">
{{ webhook.url }}
</span>
</template>
<template v-else>
{{ webhook.url }}
</template>
</div>
<div class="block mt-1 text-sm text-n-slate-11">
<span class="font-medium">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.SUBSCRIBED_EVENTS') }}:
</span>
</template>
<template v-else>
{{ webhook.url }}
</template>
</div>
<div class="block mt-1 text-sm text-n-slate-11">
<span class="font-medium">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.SUBSCRIBED_EVENTS') }}:
</span>
<ShowMore :text="subscribedEvents" :limit="60" />
</div>
</td>
<td class="py-4 min-w-xs">
<div class="flex justify-end gap-1">
<Button
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.BUTTON_TEXT')"
icon="i-lucide-pen"
slate
xs
faded
@click="emit('edit', webhook)"
/>
<Button
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.BUTTON_TEXT')"
icon="i-lucide-trash-2"
xs
ruby
faded
@click="emit('delete', webhook, index)"
/>
</div>
</td>
</tr>
<ShowMore :text="subscribedEvents" :limit="60" />
</div>
</BaseTableCell>
<BaseTableCell align="end" class="w-24">
<div class="flex justify-end gap-3 flex-shrink-0">
<Button
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.BUTTON_TEXT')"
icon="i-woot-edit-pen"
slate
sm
@click="emit('edit', webhook)"
/>
<Button
v-tooltip.top="
$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.BUTTON_TEXT')
"
icon="i-woot-bin"
slate
sm
@click="emit('delete', webhook, index)"
/>
</div>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
@@ -6,7 +6,6 @@ import Index from './Index.vue';
import Webhook from './Webhooks/Index.vue';
import DashboardApps from './DashboardApps/Index.vue';
import Slack from './Slack.vue';
import SettingsContent from '../Wrapper.vue';
import Linear from './Linear.vue';
import Notion from './Notion.vue';
import Shopify from './Shopify.vue';
@@ -49,28 +48,7 @@ export default {
},
{
path: frontendURL('accounts/:accountId/settings/integrations'),
component: SettingsContent,
props: params => {
const integrationId = params.params?.integration_id;
const hideHeader = ['dialogflow'].includes(integrationId);
// Don't show header
if (hideHeader) {
return {};
}
const showBackButton = params.name !== 'settings_integrations';
const backUrl =
params.name === 'settings_integrations_integration'
? { name: 'settings_integrations' }
: '';
return {
headerTitle: 'INTEGRATION_SETTINGS.HEADER',
icon: 'flash-on',
showBackButton,
backUrl,
};
},
component: SettingsWrapper,
children: [
{
path: 'slack',
@@ -3,12 +3,18 @@ import { useAlert } from 'dashboard/composables';
import { computed, onBeforeMount, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import { picoSearch } from '@scmmishra/pico-search';
import AddLabel from './AddLabel.vue';
import EditLabel from './EditLabel.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import {
BaseTable,
BaseTableRow,
BaseTableCell,
} from 'dashboard/components-next/table';
const getters = useStoreGetters();
const store = useStore();
@@ -19,8 +25,15 @@ const showAddPopup = ref(false);
const showEditPopup = ref(false);
const showDeleteConfirmationPopup = ref(false);
const selectedLabel = ref({});
const searchQuery = ref('');
const records = computed(() => getters['labels/getLabels'].value);
const filteredRecords = computed(() => {
const query = searchQuery.value.trim();
if (!query) return records.value;
return picoSearch(records.value, query, ['title', 'description']);
});
const uiFlags = computed(() => getters['labels/getUIFlags'].value);
const deleteMessage = computed(() => ` ${selectedLabel.value.title}?`);
@@ -72,6 +85,7 @@ const tableHeaders = computed(() => {
t('LABEL_MGMT.LIST.TABLE_HEADER.NAME'),
t('LABEL_MGMT.LIST.TABLE_HEADER.DESCRIPTION'),
t('LABEL_MGMT.LIST.TABLE_HEADER.COLOR'),
t('LABEL_MGMT.LIST.TABLE_HEADER.ACTION'),
];
});
@@ -89,14 +103,15 @@ onBeforeMount(() => {
>
<template #header>
<BaseSettingsHeader
v-model:search-query="searchQuery"
:title="$t('LABEL_MGMT.HEADER')"
:description="$t('LABEL_MGMT.DESCRIPTION')"
:link-text="$t('LABEL_MGMT.LEARN_MORE')"
:search-placeholder="$t('LABEL_MGMT.SEARCH_PLACEHOLDER')"
feature-name="labels"
>
<template #actions>
<Button
icon="i-lucide-circle-plus"
:label="$t('LABEL_MGMT.HEADER_BTN_TXT')"
@click="openAddPopup"
/>
@@ -104,58 +119,64 @@ onBeforeMount(() => {
</BaseSettingsHeader>
</template>
<template #body>
<table class="min-w-full overflow-x-auto divide-y divide-n-weak">
<thead>
<th
v-for="thHeader in tableHeaders"
:key="thHeader"
class="py-4 font-semibold text-left ltr:pr-4 rtl:pl-4 text-n-slate-11"
>
{{ thHeader }}
</th>
</thead>
<tbody class="flex-1 divide-y divide-n-weak text-n-slate-12">
<tr v-for="(label, index) in records" :key="label.title">
<td class="py-4 ltr:pr-4 rtl:pl-4">
<span class="mb-1 font-medium break-words text-n-slate-12">
{{ label.title }}
</span>
</td>
<td class="py-4 ltr:pr-4 rtl:pl-4">{{ label.description }}</td>
<td class="py-4 leading-6 ltr:pr-4 rtl:pl-4">
<div class="flex items-center">
<span
class="w-4 h-4 mr-1 border border-solid rounded rtl:mr-0 rtl:ml-1 border-n-weak"
:style="{ backgroundColor: label.color }"
/>
{{ label.color }}
</div>
</td>
<td class="py-4 min-w-xs">
<div class="flex gap-1 justify-end">
<Button
v-tooltip.top="$t('LABEL_MGMT.FORM.EDIT')"
icon="i-lucide-pen"
slate
xs
faded
:is-loading="loading[label.id]"
@click="openEditPopup(label)"
/>
<Button
v-tooltip.top="$t('LABEL_MGMT.FORM.DELETE')"
icon="i-lucide-trash-2"
xs
ruby
faded
:is-loading="loading[label.id]"
@click="openDeletePopup(label, index)"
/>
</div>
</td>
</tr>
</tbody>
</table>
<BaseTable
:headers="tableHeaders"
:items="filteredRecords"
:no-data-message="
searchQuery ? $t('LABEL_MGMT.NO_RESULTS') : $t('LABEL_MGMT.LIST.404')
"
>
<template #row="{ items }">
<BaseTableRow v-for="label in items" :key="label.title" :item="label">
<template #default>
<BaseTableCell>
<span class="text-body-main text-n-slate-12">
{{ label.title }}
</span>
</BaseTableCell>
<BaseTableCell>
<span class="text-body-main text-n-slate-11">
{{ label.description }}
</span>
</BaseTableCell>
<BaseTableCell>
<div class="flex items-center">
<span
class="w-4 h-4 ltr:mr-2 rtl:ml-2 border border-solid rounded border-n-weak"
:style="{ backgroundColor: label.color }"
/>
<span class="text-body-main text-n-slate-12">
{{ label.color }}
</span>
</div>
</BaseTableCell>
<BaseTableCell align="end">
<div class="flex gap-3 justify-end flex-shrink-0">
<Button
v-tooltip.top="$t('LABEL_MGMT.FORM.EDIT')"
icon="i-woot-edit-pen"
slate
sm
:is-loading="loading[label.id]"
@click="openEditPopup(label)"
/>
<Button
v-tooltip.top="$t('LABEL_MGMT.FORM.DELETE')"
icon="i-woot-bin"
slate
sm
:is-loading="loading[label.id]"
@click="openDeletePopup(label)"
/>
</div>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
</BaseTable>
</template>
<woot-modal v-model:show="showAddPopup" :on-close="hideAddPopup">
@@ -1,5 +1,6 @@
<script setup>
import { useAlert } from 'dashboard/composables';
import { picoSearch } from '@scmmishra/pico-search';
import MacrosTableRow from './MacrosTableRow.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
@@ -7,6 +8,7 @@ import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import { BaseTable } from 'dashboard/components-next/table';
const getters = useStoreGetters();
const store = useStore();
@@ -14,10 +16,17 @@ const { t } = useI18n();
const showDeleteConfirmationPopup = ref(false);
const selectedMacro = ref({});
const searchQuery = ref('');
const records = computed(() => getters['macros/getMacros'].value);
const uiFlags = computed(() => getters['macros/getUIFlags'].value);
const filteredRecords = computed(() => {
const query = searchQuery.value.trim();
if (!query) return records.value;
return picoSearch(records.value, query, ['name']);
});
const deleteMessage = computed(() => ` ${selectedMacro.value.name}?`);
onMounted(() => {
@@ -53,6 +62,7 @@ const tableHeaders = computed(() => {
t('MACROS.LIST.TABLE_HEADER.CREATED BY'),
t('MACROS.LIST.TABLE_HEADER.LAST_UPDATED_BY'),
t('MACROS.LIST.TABLE_HEADER.VISIBILITY'),
t('MACROS.LIST.TABLE_HEADER.ACTIONS'),
];
});
</script>
@@ -67,41 +77,37 @@ const tableHeaders = computed(() => {
>
<template #header>
<BaseSettingsHeader
v-model:search-query="searchQuery"
:title="$t('MACROS.HEADER')"
:description="$t('MACROS.DESCRIPTION')"
:link-text="$t('MACROS.LEARN_MORE')"
:search-placeholder="$t('MACROS.SEARCH_PLACEHOLDER')"
feature-name="macros"
>
<template #actions>
<router-link :to="{ name: 'macros_new' }">
<Button
icon="i-lucide-circle-plus"
:label="$t('MACROS.HEADER_BTN_TXT')"
/>
<Button :label="$t('MACROS.HEADER_BTN_TXT')" />
</router-link>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<table class="min-w-full divide-y divide-n-weak">
<thead>
<th
v-for="thHeader in tableHeaders"
:key="thHeader"
class="py-4 ltr:pr-4 rtl:pl-4 text-left font-semibold text-n-slate-11"
>
{{ thHeader }}
</th>
</thead>
<tbody class="divide-y divide-n-weak text-n-slate-11">
<BaseTable
:headers="tableHeaders"
:items="filteredRecords"
:no-data-message="
searchQuery ? $t('MACROS.NO_RESULTS') : $t('MACROS.LIST.404')
"
>
<template #row="{ items }">
<MacrosTableRow
v-for="(macro, index) in records"
:key="index"
v-for="macro in items"
:key="macro.id"
:macro="macro"
@delete="openDeletePopup(macro)"
/>
</tbody>
</table>
</template>
</BaseTable>
<woot-delete-modal
v-model:show="showDeleteConfirmationPopup"
:on-close="closeDeletePopup"
@@ -128,7 +128,7 @@ const saveMacro = async macroData => {
</script>
<template>
<div class="flex flex-col flex-1 h-full overflow-auto">
<div class="flex flex-col flex-1 h-full overflow-auto mx-2">
<woot-loading-state
v-if="uiFlags.isFetchingItem"
:message="t('MACROS.EDITOR.LOADING')"
@@ -108,9 +108,9 @@ export default {
</script>
<template>
<div class="flex flex-col w-full h-auto md:flex-row md:h-full">
<div class="flex flex-col w-full h-auto lg:flex-row lg:h-full">
<div
class="flex-1 w-full h-full max-h-full px-12 py-4 overflow-y-auto md:w-auto macro-gradient-radial dark:macro-dark-gradient-radial macro-gradient-radial-size"
class="flex-1 w-full h-full max-h-full ltr:pl-12 ltr:pr-6 rtl:pl-6 rtl:pr-12 py-4 overflow-y-auto lg:w-auto macro-gradient-radial dark:macro-dark-gradient-radial macro-gradient-radial-size"
>
<MacroNodes
v-model="macro.actions"
@@ -121,7 +121,7 @@ export default {
@reset-action="resetNode"
/>
</div>
<div class="w-full md:w-1/3 pb-4">
<div class="w-full lg:w-1/3 pb-4">
<MacroProperties
:macro-name="macro.name"
:macro-visibility="macro.visibility"
@@ -1,9 +1,11 @@
<script>
import NextButton from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
export default {
components: {
NextButton,
Icon,
},
inject: ['v$'],
props: {
@@ -53,56 +55,57 @@ export default {
</p>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<button
class="p-2 relative rounded-md border border-solid text-left cursor-default"
class="p-2 relative rounded-md border border-solid justify-between items-start gap-2 flex flex-col text-start cursor-default"
:class="isActive('global')"
@click="onUpdateVisibility('global')"
>
<fluent-icon
v-if="macroVisibility === 'global'"
icon="checkmark-circle"
type="solid"
class="absolute text-n-brand top-2 right-2"
/>
<p
class="block m-0 text-sm font-medium leading-[1.8] text-n-slate-12"
>
{{ $t('MACROS.EDITOR.VISIBILITY.GLOBAL.LABEL') }}
</p>
<p class="text-xs text-n-slate-11">
<div class="flex items-center gap-2 min-w-0 justify-between w-full">
<p class="block m-0 text-heading-3 text-n-slate-12 line-clamp-1">
{{ $t('MACROS.EDITOR.VISIBILITY.GLOBAL.LABEL') }}
</p>
<Icon
v-if="macroVisibility === 'global'"
icon="i-lucide-circle-check-big"
class="text-n-brand size-4"
/>
</div>
<p class="text-n-slate-11 text-label-small">
{{ $t('MACROS.EDITOR.VISIBILITY.GLOBAL.DESCRIPTION') }}
</p>
</button>
<button
class="p-2 relative rounded-md border border-solid text-left cursor-default"
class="p-2 relative rounded-md border border-solid justify-between items-start gap-2 flex flex-col text-start cursor-default"
:class="isActive('personal')"
@click="onUpdateVisibility('personal')"
>
<fluent-icon
v-if="macroVisibility === 'personal'"
icon="checkmark-circle"
type="solid"
class="absolute text-n-brand top-2 right-2"
/>
<p
class="block m-0 text-sm font-medium leading-[1.8] text-n-slate-12"
>
{{ $t('MACROS.EDITOR.VISIBILITY.PERSONAL.LABEL') }}
</p>
<p class="text-xs text-n-slate-11">
<div class="flex items-center gap-2 min-w-0 justify-between w-full">
<p class="block m-0 text-heading-3 text-n-slate-12 line-clamp-1">
{{ $t('MACROS.EDITOR.VISIBILITY.PERSONAL.LABEL') }}
</p>
<Icon
v-if="macroVisibility === 'personal'"
icon="i-lucide-circle-check-big"
class="text-n-brand size-4"
/>
</div>
<p class="text-n-slate-11 text-label-small">
{{ $t('MACROS.EDITOR.VISIBILITY.PERSONAL.DESCRIPTION') }}
</p>
</button>
</div>
<div
class="mt-2 flex items-start p-2 bg-n-slate-3 dark:bg-n-solid-3 rounded-md"
class="mt-2 flex items-start p-2 bg-n-alpha-1 gap-2 dark:bg-n-solid-3 rounded-md"
>
<fluent-icon icon="info" size="16" class="flex-shrink-0 mt-0.5" />
<p class="ml-2 rtl:ml-0 rtl:mr-2 mb-0 text-n-slate-11">
<Icon
icon="i-lucide-info"
class="flex-shrink-0 mt-0.5 size-4 text-n-slate-11"
/>
<p class="mb-0 text-n-slate-11 text-body-para">
{{ $t('MACROS.ORDER_INFO') }}
</p>
</div>
</div>
<div class="mt-auto w-full">
<div class="mt-4 w-full">
<NextButton
blue
solid
@@ -4,6 +4,7 @@ import Avatar from 'next/avatar/Avatar.vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import { BaseTableRow, BaseTableCell } from 'dashboard/components-next/table';
const props = defineProps({
macro: {
@@ -34,41 +35,71 @@ const visibilityLabel = computed(() => {
</script>
<template>
<tr>
<td class="py-4 ltr:pr-4 rtl:pl-4 truncate">{{ macro.name }}</td>
<td class="py-4 ltr:pr-4 rtl:pl-4">
<div v-if="macro.created_by" class="flex items-center">
<Avatar :name="createdByName" :size="24" rounded-full />
<span class="mx-2">{{ createdByName }}</span>
</div>
<div v-else>--</div>
</td>
<td class="py-4 ltr:pr-4 rtl:pl-4">
<div v-if="macro.updated_by" class="flex items-center">
<Avatar :name="updatedByName" :size="24" rounded-full />
<span class="mx-2">{{ updatedByName }}</span>
</div>
<div v-else>--</div>
</td>
<td class="py-4 ltr:pr-4 rtl:pl-4">{{ visibilityLabel }}</td>
<td class="py-4 flex justify-end gap-1">
<router-link :to="{ name: 'macros_edit', params: { macroId: macro.id } }">
<Button
v-tooltip.top="$t('MACROS.EDIT.TOOLTIP')"
icon="i-lucide-pen"
slate
xs
faded
/>
</router-link>
<Button
v-tooltip.top="$t('MACROS.DELETE.TOOLTIP')"
icon="i-lucide-trash-2"
xs
ruby
faded
@click="$emit('delete')"
/>
</td>
</tr>
<BaseTableRow :item="macro">
<template #default>
<BaseTableCell class="max-w-0 min-w-0">
<span class="text-body-main text-n-slate-12 truncate block">
{{ macro.name }}
</span>
</BaseTableCell>
<BaseTableCell class="max-w-0">
<div v-if="macro.created_by" class="flex items-center gap-2 min-w-0">
<Avatar
:name="createdByName"
:size="24"
rounded-full
class="flex-shrink-0"
/>
<span class="text-body-main text-n-slate-12 truncate">
{{ createdByName }}
</span>
</div>
<span v-else class="text-body-main text-n-slate-11">--</span>
</BaseTableCell>
<BaseTableCell class="max-w-0">
<div v-if="macro.updated_by" class="flex items-center gap-2 min-w-0">
<Avatar
:name="updatedByName"
:size="24"
rounded-full
class="flex-shrink-0"
/>
<span class="text-body-main text-n-slate-12 truncate">
{{ updatedByName }}
</span>
</div>
<span v-else class="text-body-main text-n-slate-11">--</span>
</BaseTableCell>
<BaseTableCell class="max-w-0">
<span class="text-body-main text-n-slate-12 whitespace-nowrap">
{{ visibilityLabel }}
</span>
</BaseTableCell>
<BaseTableCell align="end" class="w-24">
<div class="flex gap-3 justify-end flex-shrink-0">
<router-link
:to="{ name: 'macros_edit', params: { macroId: macro.id } }"
>
<Button
v-tooltip.top="$t('MACROS.EDIT.TOOLTIP')"
icon="i-woot-edit-pen"
slate
sm
/>
</router-link>
<Button
v-tooltip.top="$t('MACROS.DELETE.TOOLTIP')"
icon="i-woot-bin"
slate
sm
@click="$emit('delete')"
/>
</div>
</BaseTableCell>
</template>
</BaseTableRow>
</template>

Some files were not shown because too many files have changed in this diff Show More