Merge branch 'develop' into codex/fix-inbox-finish-setup-guidance
This commit is contained in:
+1
-1
@@ -16,7 +16,7 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-surface-1 px-6"
|
||||
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-surface-1"
|
||||
>
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive v-if="keepAlive">
|
||||
|
||||
@@ -191,7 +191,7 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<PageLayout
|
||||
:header-title="$t('CAPTAIN.DOCUMENTS.HEADER')"
|
||||
:header-title="$t('CAPTAIN.ASSISTANTS.SCENARIOS.TITLE')"
|
||||
:is-fetching="isFetching"
|
||||
:show-know-more="false"
|
||||
:show-pagination-footer="false"
|
||||
|
||||
@@ -31,6 +31,8 @@ const toggleStatus = async (status, snoozedUntil) => {
|
||||
const onCmdSnoozeConversation = snoozeType => {
|
||||
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
|
||||
showCustomSnoozeModal.value = true;
|
||||
} else if (typeof snoozeType === 'number') {
|
||||
toggleStatus(wootConstants.STATUS_TYPE.SNOOZED, snoozeType);
|
||||
} else {
|
||||
toggleStatus(
|
||||
wootConstants.STATUS_TYPE.SNOOZED,
|
||||
|
||||
@@ -4,16 +4,29 @@ import { ref, computed, watchEffect, onMounted } from 'vue';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useLocale } from 'shared/composables/useLocale';
|
||||
import { useAppearanceHotKeys } from 'dashboard/composables/commands/useAppearanceHotKeys';
|
||||
import { useInboxHotKeys } from 'dashboard/composables/commands/useInboxHotKeys';
|
||||
import { useGoToCommandHotKeys } from 'dashboard/composables/commands/useGoToCommandHotKeys';
|
||||
import { useBulkActionsHotKeys } from 'dashboard/composables/commands/useBulkActionsHotKeys';
|
||||
import { useConversationHotKeys } from 'dashboard/composables/commands/useConversationHotKeys';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { GENERAL_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import {
|
||||
GENERAL_EVENTS,
|
||||
SNOOZE_EVENTS,
|
||||
} from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { generateSnoozeSuggestions } from 'dashboard/helper/snoozeHelpers';
|
||||
import { ICON_SNOOZE_CONVERSATION } from 'dashboard/helper/commandbar/icons';
|
||||
import {
|
||||
CMD_SNOOZE_CONVERSATION,
|
||||
CMD_SNOOZE_NOTIFICATION,
|
||||
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
|
||||
} from 'dashboard/helper/commandbar/events';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { t, tm } = useI18n();
|
||||
const { resolvedLocale } = useLocale();
|
||||
|
||||
const ninjakeys = ref(null);
|
||||
|
||||
@@ -28,48 +41,168 @@ const { goToCommandHotKeys } = useGoToCommandHotKeys();
|
||||
const { bulkActionsHotKeys } = useBulkActionsHotKeys();
|
||||
const { conversationHotKeys } = useConversationHotKeys();
|
||||
|
||||
const placeholder = computed(() => t('COMMAND_BAR.SEARCH_PLACEHOLDER'));
|
||||
const SNOOZE_PARENT_IDS = [
|
||||
'snooze_conversation',
|
||||
'snooze_notification',
|
||||
'bulk_action_snooze_conversation',
|
||||
];
|
||||
const DYNAMIC_SNOOZE_PREFIX = 'dynamic_snooze_';
|
||||
|
||||
const hotKeys = computed(() => [
|
||||
...inboxHotKeys.value,
|
||||
...goToCommandHotKeys.value,
|
||||
...goToAppearanceHotKeys.value,
|
||||
...bulkActionsHotKeys.value,
|
||||
...conversationHotKeys.value,
|
||||
]);
|
||||
const CUSTOM_SNOOZE = wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME;
|
||||
|
||||
const dynamicSnoozeActions = ref([]);
|
||||
const currentCommandRoot = ref(null);
|
||||
|
||||
const placeholder = computed(() =>
|
||||
SNOOZE_PARENT_IDS.includes(currentCommandRoot.value)
|
||||
? t('COMMAND_BAR.SNOOZE_PLACEHOLDER')
|
||||
: t('COMMAND_BAR.SEARCH_PLACEHOLDER')
|
||||
);
|
||||
|
||||
const SNOOZE_PRESET_IDS = new Set(Object.values(wootConstants.SNOOZE_OPTIONS));
|
||||
|
||||
const hotKeys = computed(() => {
|
||||
const allActions = [
|
||||
...dynamicSnoozeActions.value,
|
||||
...inboxHotKeys.value,
|
||||
...goToCommandHotKeys.value,
|
||||
...goToAppearanceHotKeys.value,
|
||||
...bulkActionsHotKeys.value,
|
||||
...conversationHotKeys.value,
|
||||
];
|
||||
// When dynamic NLP snooze suggestions exist, hide all preset snooze actions to avoid duplication
|
||||
if (!dynamicSnoozeActions.value.length) return allActions;
|
||||
return allActions.filter(
|
||||
a => !SNOOZE_PRESET_IDS.has(a.id) || !SNOOZE_PARENT_IDS.includes(a.parent)
|
||||
);
|
||||
});
|
||||
|
||||
const setCommandBarData = () => {
|
||||
ninjakeys.value.data = hotKeys.value;
|
||||
};
|
||||
|
||||
const onSelected = item => {
|
||||
const {
|
||||
detail: { action: { title = null, section = null, id = null } = {} } = {},
|
||||
} = item;
|
||||
// Added this condition to prevent setting the selectedSnoozeType to null
|
||||
// When we select the "custom snooze" (CMD bar will close and the custom snooze modal will open)
|
||||
if (id === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
|
||||
selectedSnoozeType.value = wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME;
|
||||
} else {
|
||||
selectedSnoozeType.value = null;
|
||||
const SNOOZE_EVENT_MAP = {
|
||||
snooze_conversation: CMD_SNOOZE_CONVERSATION,
|
||||
snooze_notification: CMD_SNOOZE_NOTIFICATION,
|
||||
bulk_action_snooze_conversation: CMD_BULK_ACTION_SNOOZE_CONVERSATION,
|
||||
};
|
||||
|
||||
const SNOOZE_SECTION_MAP = {
|
||||
snooze_conversation: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
|
||||
snooze_notification: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
|
||||
bulk_action_snooze_conversation: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
|
||||
};
|
||||
|
||||
const snoozeTranslations = computed(() => {
|
||||
const raw = tm('SNOOZE_PARSER');
|
||||
if (!raw || typeof raw !== 'object') return {};
|
||||
return JSON.parse(JSON.stringify(raw));
|
||||
});
|
||||
|
||||
const buildDynamicSnoozeActions = (search, parentId) => {
|
||||
const suggestions = generateSnoozeSuggestions(search, new Date(), {
|
||||
translations: snoozeTranslations.value,
|
||||
locale: resolvedLocale.value,
|
||||
});
|
||||
if (!suggestions.length) return [];
|
||||
|
||||
const busEvent = SNOOZE_EVENT_MAP[parentId];
|
||||
const section = t(SNOOZE_SECTION_MAP[parentId]);
|
||||
|
||||
return suggestions.map((parsed, index) => ({
|
||||
id: `${DYNAMIC_SNOOZE_PREFIX}${index}`,
|
||||
title:
|
||||
parsed.label !== parsed.formattedDate
|
||||
? `${parsed.label} - ${parsed.formattedDate}`
|
||||
: parsed.formattedDate,
|
||||
parent: parentId,
|
||||
section,
|
||||
icon: ICON_SNOOZE_CONVERSATION,
|
||||
keywords: search,
|
||||
handler: () => {
|
||||
emitter.emit(busEvent, parsed.resolve());
|
||||
useTrack(SNOOZE_EVENTS.NLP_SNOOZE_APPLIED, { label: parsed.label });
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const resetSnoozeState = () => {
|
||||
currentCommandRoot.value = null;
|
||||
dynamicSnoozeActions.value = [];
|
||||
};
|
||||
|
||||
const patchNinjaKeysOpenClose = el => {
|
||||
if (!el || typeof el.open !== 'function' || typeof el.close !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
useTrack(GENERAL_EVENTS.COMMAND_BAR, {
|
||||
section,
|
||||
action: title,
|
||||
});
|
||||
const originalOpen = el.open.bind(el);
|
||||
const originalClose = el.close.bind(el);
|
||||
|
||||
el.open = (...args) => {
|
||||
const [options = {}] = args;
|
||||
currentCommandRoot.value = options.parent || null;
|
||||
dynamicSnoozeActions.value = [];
|
||||
return originalOpen(...args);
|
||||
};
|
||||
|
||||
el.close = (...args) => {
|
||||
resetSnoozeState();
|
||||
return originalClose(...args);
|
||||
};
|
||||
};
|
||||
|
||||
const onSelected = item => {
|
||||
const {
|
||||
detail: {
|
||||
action: { title = null, section = null, id = null, children = null } = {},
|
||||
} = {},
|
||||
} = item;
|
||||
|
||||
selectedSnoozeType.value = id === CUSTOM_SNOOZE ? id : null;
|
||||
|
||||
if (Array.isArray(children) && children.length) {
|
||||
currentCommandRoot.value = id;
|
||||
}
|
||||
|
||||
useTrack(GENERAL_EVENTS.COMMAND_BAR, { section, action: title });
|
||||
setCommandBarData();
|
||||
};
|
||||
|
||||
const onClosed = () => {
|
||||
// If the selectedSnoozeType is not "SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME (custom snooze)" then we set the context menu chat id to null
|
||||
// Else we do nothing and its handled in the ChatList.vue hideCustomSnoozeModal() method
|
||||
const onCommandBarChange = item => {
|
||||
const { detail: { search = '', actions = [] } = {} } = item;
|
||||
const normalizedSearch = search.trim();
|
||||
|
||||
if (actions.length > 0) {
|
||||
const uniqueParents = [
|
||||
...new Set(actions.map(action => action.parent).filter(Boolean)),
|
||||
];
|
||||
if (uniqueParents.length === 1) {
|
||||
currentCommandRoot.value = uniqueParents[0];
|
||||
} else {
|
||||
currentCommandRoot.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
selectedSnoozeType.value !== wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME
|
||||
!normalizedSearch ||
|
||||
!SNOOZE_PARENT_IDS.includes(currentCommandRoot.value || '')
|
||||
) {
|
||||
dynamicSnoozeActions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
dynamicSnoozeActions.value = buildDynamicSnoozeActions(
|
||||
normalizedSearch,
|
||||
currentCommandRoot.value
|
||||
);
|
||||
};
|
||||
|
||||
const onClosed = () => {
|
||||
if (selectedSnoozeType.value !== CUSTOM_SNOOZE) {
|
||||
store.dispatch('setContextMenuChatId', null);
|
||||
}
|
||||
resetSnoozeState();
|
||||
};
|
||||
|
||||
watchEffect(() => {
|
||||
@@ -78,7 +211,10 @@ watchEffect(() => {
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(setCommandBarData);
|
||||
onMounted(() => {
|
||||
setCommandBarData();
|
||||
patchNinjaKeysOpenClose(ninjakeys.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/attribute-hyphenation -->
|
||||
@@ -88,6 +224,7 @@ onMounted(setCommandBarData);
|
||||
noAutoLoadMdIcons
|
||||
hideBreadcrumbs
|
||||
:placeholder="placeholder"
|
||||
@change="onCommandBarChange"
|
||||
@selected="onSelected"
|
||||
@closed="onClosed"
|
||||
/>
|
||||
|
||||
@@ -154,7 +154,7 @@ onMounted(() => {
|
||||
t('COMPANIES.EMPTY_STATE.TITLE')
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-4 p-4">
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<CompaniesCard
|
||||
v-for="company in companies"
|
||||
:id="company.id"
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@ const handleAssignLabels = labels => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="sticky top-0 z-10 bg-gradient-to-b from-n-background from-90% to-transparent px-6 pt-1 pb-2"
|
||||
class="sticky top-0 z-10 bg-gradient-to-b from-n-surface-1 from-90% to-transparent pt-1 pb-2"
|
||||
>
|
||||
<BulkSelectBar
|
||||
v-model="selectionModel"
|
||||
|
||||
@@ -493,7 +493,7 @@ onMounted(async () => {
|
||||
{{ emptyStateMessage }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-4 px-6 pt-4 pb-6">
|
||||
<div v-else class="flex flex-col gap-4 pt-4 pb-6">
|
||||
<ContactsList
|
||||
:contacts="contacts"
|
||||
:selected-contact-ids="selectedContactIds"
|
||||
|
||||
@@ -308,17 +308,5 @@ onMounted(() => {
|
||||
.contact--profile {
|
||||
@apply pb-3 border-b border-solid border-n-weak;
|
||||
}
|
||||
|
||||
.conversation--actions .multiselect-wrap--small {
|
||||
.multiselect {
|
||||
@apply box-border pl-6;
|
||||
}
|
||||
|
||||
.multiselect__element {
|
||||
span {
|
||||
@apply w-full;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -211,7 +211,7 @@ export default {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="multiselect-wrap--small">
|
||||
<div>
|
||||
<ContactDetailsItem
|
||||
compact
|
||||
:title="$t('CONVERSATION_SIDEBAR.ASSIGNEE_LABEL')"
|
||||
@@ -242,7 +242,7 @@ export default {
|
||||
@select="onClickAssignAgent"
|
||||
/>
|
||||
</div>
|
||||
<div class="multiselect-wrap--small">
|
||||
<div>
|
||||
<ContactDetailsItem
|
||||
compact
|
||||
:title="$t('CONVERSATION_SIDEBAR.TEAM_LABEL')"
|
||||
@@ -261,7 +261,7 @@ export default {
|
||||
@select="onClickAssignTeam"
|
||||
/>
|
||||
</div>
|
||||
<div class="multiselect-wrap--small">
|
||||
<div>
|
||||
<ContactDetailsItem compact :title="$t('CONVERSATION.PRIORITY.TITLE')" />
|
||||
<MultiselectDropdown
|
||||
:options="priorityOptions"
|
||||
|
||||
@@ -11,11 +11,13 @@ import { isPhoneNumberValid } from 'shared/helpers/Validators';
|
||||
import parsePhoneNumber from 'libphonenumber-js';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
Avatar,
|
||||
ComboBox,
|
||||
},
|
||||
props: {
|
||||
contact: {
|
||||
@@ -56,12 +58,14 @@ export default {
|
||||
twitter: '',
|
||||
linkedin: '',
|
||||
github: '',
|
||||
telegram: '',
|
||||
},
|
||||
socialProfileKeys: [
|
||||
{ key: 'facebook', prefixURL: 'https://facebook.com/' },
|
||||
{ key: 'twitter', prefixURL: 'https://twitter.com/' },
|
||||
{ key: 'linkedin', prefixURL: 'https://linkedin.com/' },
|
||||
{ key: 'github', prefixURL: 'https://github.com/' },
|
||||
{ key: 'telegram', prefixURL: 'https://t.me/' },
|
||||
{ key: 'tiktok', prefixURL: 'https://tiktok.com/@' },
|
||||
],
|
||||
};
|
||||
@@ -133,6 +137,12 @@ export default {
|
||||
if (!name && !id) return '';
|
||||
return `${name} (${id})`;
|
||||
},
|
||||
onCountryChange(value) {
|
||||
const selected = this.countries.find(c => c.id === value);
|
||||
this.country = selected
|
||||
? { id: selected.id, name: selected.name }
|
||||
: { id: '', name: '' };
|
||||
},
|
||||
setDialCode() {
|
||||
if (
|
||||
this.phoneNumber !== '' &&
|
||||
@@ -167,12 +177,14 @@ export default {
|
||||
const {
|
||||
social_profiles: socialProfiles = {},
|
||||
screen_name: twitterScreenName,
|
||||
social_telegram_user_name: telegramUserName,
|
||||
} = additionalAttributes;
|
||||
this.socialProfileUserNames = {
|
||||
twitter: socialProfiles.twitter || twitterScreenName || '',
|
||||
facebook: socialProfiles.facebook || '',
|
||||
linkedin: socialProfiles.linkedin || '',
|
||||
github: socialProfiles.github || '',
|
||||
telegram: socialProfiles.telegram || telegramUserName || '',
|
||||
instagram: socialProfiles.instagram || '',
|
||||
tiktok: socialProfiles.tiktok || '',
|
||||
};
|
||||
@@ -363,26 +375,23 @@ export default {
|
||||
:label="$t('CONTACT_FORM.FORM.COMPANY_NAME.LABEL')"
|
||||
:placeholder="$t('CONTACT_FORM.FORM.COMPANY_NAME.PLACEHOLDER')"
|
||||
/>
|
||||
<div>
|
||||
<div class="w-full">
|
||||
<label>
|
||||
{{ $t('CONTACT_FORM.FORM.COUNTRY.LABEL') }}
|
||||
</label>
|
||||
<multiselect
|
||||
v-model="country"
|
||||
track-by="id"
|
||||
label="name"
|
||||
:placeholder="$t('CONTACT_FORM.FORM.COUNTRY.PLACEHOLDER')"
|
||||
selected-label
|
||||
:select-label="$t('CONTACT_FORM.FORM.COUNTRY.SELECT_PLACEHOLDER')"
|
||||
:deselect-label="$t('CONTACT_FORM.FORM.COUNTRY.REMOVE')"
|
||||
:custom-label="countryNameWithCode"
|
||||
:max-height="160"
|
||||
:options="countries"
|
||||
allow-empty
|
||||
:option-height="104"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full mb-4">
|
||||
<label>
|
||||
{{ $t('CONTACT_FORM.FORM.COUNTRY.LABEL') }}
|
||||
</label>
|
||||
<ComboBox
|
||||
:model-value="country.id"
|
||||
:options="
|
||||
countries.map(c => ({
|
||||
value: c.id,
|
||||
label: countryNameWithCode(c),
|
||||
}))
|
||||
"
|
||||
class="[&>div>button]:!bg-n-alpha-black2"
|
||||
:placeholder="$t('CONTACT_FORM.FORM.COUNTRY.PLACEHOLDER')"
|
||||
:search-placeholder="$t('CONTACT_FORM.FORM.COUNTRY.SELECT_PLACEHOLDER')"
|
||||
@update:model-value="onCountryChange"
|
||||
/>
|
||||
</div>
|
||||
<woot-input
|
||||
v-model="city"
|
||||
@@ -426,11 +435,3 @@ export default {
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
::v-deep {
|
||||
.multiselect .multiselect__tags .multiselect__single {
|
||||
@apply pl-0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -51,7 +51,6 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
showEditModal: false,
|
||||
showMergeModal: false,
|
||||
showDeleteModal: false,
|
||||
};
|
||||
},
|
||||
@@ -82,10 +81,14 @@ export default {
|
||||
screen_name: twitterScreenName,
|
||||
social_telegram_user_name: telegramUsername,
|
||||
} = this.additionalAttributes;
|
||||
|
||||
const telegram = socialProfiles?.telegram || telegramUsername || '';
|
||||
const twitter = socialProfiles?.twitter || twitterScreenName || '';
|
||||
|
||||
return {
|
||||
twitter: twitterScreenName,
|
||||
telegram: telegramUsername,
|
||||
...(socialProfiles || {}),
|
||||
twitter,
|
||||
telegram,
|
||||
};
|
||||
},
|
||||
// Delete Modal
|
||||
@@ -167,11 +170,8 @@ export default {
|
||||
);
|
||||
}
|
||||
},
|
||||
closeMergeModal() {
|
||||
this.showMergeModal = false;
|
||||
},
|
||||
openMergeModal() {
|
||||
this.showMergeModal = true;
|
||||
this.$refs.mergeModal?.open();
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -324,12 +324,7 @@ export default {
|
||||
:contact="contact"
|
||||
@cancel="toggleEditModal"
|
||||
/>
|
||||
<ContactMergeModal
|
||||
v-if="showMergeModal"
|
||||
:primary-contact="contact"
|
||||
:show="showMergeModal"
|
||||
@close="closeMergeModal"
|
||||
/>
|
||||
<ContactMergeModal ref="mergeModal" :primary-contact="contact" />
|
||||
</div>
|
||||
<woot-delete-modal
|
||||
v-if="showDeleteModal"
|
||||
|
||||
+9
-1
@@ -11,7 +11,10 @@ const store = useStore();
|
||||
|
||||
const pageNumber = ref(1);
|
||||
|
||||
const articles = useMapGetter('articles/allArticles');
|
||||
const allArticles = useMapGetter('articles/allArticles');
|
||||
const articlesSortedByPosition = useMapGetter(
|
||||
'articles/allArticlesSortedByPosition'
|
||||
);
|
||||
const categories = useMapGetter('categories/allCategories');
|
||||
const meta = useMapGetter('articles/getMeta');
|
||||
const portalMeta = useMapGetter('portals/getMeta');
|
||||
@@ -58,6 +61,11 @@ const isCategoryArticles = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
// Use position-sorted articles for category views and categories filter view (where drag reorder is enabled)
|
||||
const articles = computed(() =>
|
||||
isCategoryArticles.value ? articlesSortedByPosition.value : allArticles.value
|
||||
);
|
||||
|
||||
const fetchArticles = ({ pageNumber: pageNumberParam } = {}) => {
|
||||
store.dispatch('articles/index', {
|
||||
pageNumber: pageNumberParam || pageNumber.value,
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import CategoriesPage from 'dashboard/components-next/HelpCenter/Pages/CategoryP
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
|
||||
const categories = useMapGetter('categories/allCategories');
|
||||
const categories = useMapGetter('categories/allCategoriesSortedByPosition');
|
||||
|
||||
const selectedPortalSlug = computed(() => route.params.portalSlug);
|
||||
const getPortalBySlug = useMapGetter('portals/portalBySlug');
|
||||
|
||||
@@ -22,6 +22,7 @@ const allowedLocales = computed(() => {
|
||||
id: locale?.code,
|
||||
name: allLocales[locale?.code],
|
||||
code: locale?.code,
|
||||
isDraft: locale?.draft || false,
|
||||
articlesCount: locale?.articles_count || 0,
|
||||
categoriesCount: locale?.categories_count || 0,
|
||||
};
|
||||
|
||||
@@ -69,6 +69,8 @@ export default {
|
||||
onCmdSnoozeNotification(snoozeType) {
|
||||
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
|
||||
this.showCustomSnoozeModal = true;
|
||||
} else if (typeof snoozeType === 'number') {
|
||||
this.snoozeNotification(snoozeType);
|
||||
} else {
|
||||
const snoozedUntil = findSnoozeTime(snoozeType) || null;
|
||||
this.snoozeNotification(snoozedUntil);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -8,7 +8,6 @@ const props = defineProps({
|
||||
keepAlive: { type: Boolean, default: true },
|
||||
showBackButton: { type: Boolean, default: false },
|
||||
backUrl: { type: [String, Object], default: '' },
|
||||
fullWidth: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -19,27 +18,21 @@ const showSettingsHeader = computed(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-1 flex-col m-0 bg-n-surface-1 overflow-auto">
|
||||
<div
|
||||
class="mx-auto w-full flex flex-col flex-1"
|
||||
:class="{ 'max-w-6xl': !fullWidth }"
|
||||
>
|
||||
<SettingsHeader
|
||||
v-if="showSettingsHeader"
|
||||
:icon="icon"
|
||||
:header-title="t(headerTitle)"
|
||||
:show-back-button="showBackButton"
|
||||
:back-url="backUrl"
|
||||
class="sticky top-0 z-20"
|
||||
:class="{ 'max-w-6xl w-full mx-auto': fullWidth }"
|
||||
/>
|
||||
<div class="flex flex-col h-full m-0 bg-n-surface-1 w-full">
|
||||
<SettingsHeader
|
||||
v-if="showSettingsHeader"
|
||||
:icon="icon"
|
||||
:header-title="t(headerTitle)"
|
||||
:show-back-button="showBackButton"
|
||||
:back-url="backUrl"
|
||||
class="z-20 max-w-7xl w-full mx-auto"
|
||||
/>
|
||||
|
||||
<router-view v-slot="{ Component }" class="px-5 flex-1 overflow-hidden">
|
||||
<component :is="Component" v-if="!keepAlive" :key="$route.fullPath" />
|
||||
<keep-alive v-else>
|
||||
<component :is="Component" :key="$route.fullPath" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</div>
|
||||
<router-view v-slot="{ Component }" class="px-4 overflow-hidden">
|
||||
<component :is="Component" v-if="!keepAlive" :key="$route.fullPath" />
|
||||
<keep-alive v-else>
|
||||
<component :is="Component" :key="$route.fullPath" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -146,12 +146,13 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col max-w-2xl mx-auto w-full">
|
||||
<div class="flex flex-col w-full max-w-2xl ltr:mr-auto rtl:ml-auto">
|
||||
<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"
|
||||
@@ -159,6 +160,7 @@ export default {
|
||||
@submit.prevent="updateAccount"
|
||||
>
|
||||
<WithLabel
|
||||
name="account-name"
|
||||
:has-error="v$.name.$error"
|
||||
:label="$t('GENERAL_SETTINGS.FORM.NAME.LABEL')"
|
||||
:error-message="$t('GENERAL_SETTINGS.FORM.NAME.ERROR')"
|
||||
@@ -172,6 +174,7 @@ export default {
|
||||
/>
|
||||
</WithLabel>
|
||||
<WithLabel
|
||||
name="site-language"
|
||||
:has-error="v$.locale.$error"
|
||||
:label="$t('GENERAL_SETTINGS.FORM.LANGUAGE.LABEL')"
|
||||
:error-message="$t('GENERAL_SETTINGS.FORM.LANGUAGE.ERROR')"
|
||||
@@ -188,6 +191,7 @@ export default {
|
||||
</WithLabel>
|
||||
<WithLabel
|
||||
v-if="featureCustomReplyDomainEnabled"
|
||||
name="custom-domain"
|
||||
:label="$t('GENERAL_SETTINGS.FORM.DOMAIN.LABEL')"
|
||||
>
|
||||
<NextInput
|
||||
@@ -210,6 +214,7 @@ export default {
|
||||
</WithLabel>
|
||||
<WithLabel
|
||||
v-if="featureCustomReplyEmailEnabled"
|
||||
name="support-email"
|
||||
:label="$t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.LABEL')"
|
||||
>
|
||||
<NextInput
|
||||
|
||||
+2
-2
@@ -129,14 +129,14 @@ const toggleAutoResolve = async () => {
|
||||
>
|
||||
<div class="flex flex-col gap-2 items-start px-5 py-4">
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<h3 class="text-base font-medium text-n-slate-12">
|
||||
<h3 class="text-heading-2 text-n-slate-12">
|
||||
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.TITLE') }}
|
||||
</h3>
|
||||
<div class="flex justify-end">
|
||||
<Switch v-model="isEnabled" @change="toggleAutoResolve" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="mb-0 text-sm text-n-slate-11">
|
||||
<p class="mb-0 text-body-para text-n-slate-11">
|
||||
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.NOTE') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+14
-3
@@ -20,8 +20,16 @@ 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">
|
||||
<div
|
||||
v-if="
|
||||
title || beta || $slots.title || description || $slots.description
|
||||
"
|
||||
class="col-span-3"
|
||||
>
|
||||
<h4
|
||||
v-if="title || beta || $slots.title"
|
||||
class="text-heading-2 text-n-slate-12 flex items-center gap-2"
|
||||
>
|
||||
<slot name="title">{{ title }}</slot>
|
||||
<div
|
||||
v-if="beta"
|
||||
@@ -31,7 +39,10 @@ const { t } = useI18n();
|
||||
{{ t('GENERAL.BETA') }}
|
||||
</div>
|
||||
</h4>
|
||||
<p class="text-n-slate-11 text-sm mt-2">
|
||||
<p
|
||||
v-if="description || $slots.description"
|
||||
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,98 @@ 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 v-if="agentBots?.length" #count>
|
||||
<span class="text-body-main text-n-slate-11">
|
||||
{{ $t('AGENT_BOTS.COUNT', { n: agentBots.length }) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<Button
|
||||
icon="i-lucide-circle-plus"
|
||||
:label="$t('AGENT_BOTS.ADD.TITLE')"
|
||||
size="sm"
|
||||
@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"
|
||||
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
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
: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,112 +153,128 @@ 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 v-if="agentList?.length" #count>
|
||||
<span class="text-body-main text-n-slate-11">
|
||||
{{ $t('AGENT_MGMT.COUNT', { n: agentList.length }) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<Button
|
||||
icon="i-lucide-circle-plus"
|
||||
:label="$t('AGENT_MGMT.HEADER_BTN_TXT')"
|
||||
size="sm"
|
||||
@click="openAddPopup"
|
||||
/>
|
||||
</template>
|
||||
</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 border-t border-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
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
:is-loading="loading[agent.id]"
|
||||
@click="openDeletePopup(agent, index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<woot-modal v-model:show="showAddPopup" :on-close="hideAddPopup">
|
||||
|
||||
@@ -93,7 +93,7 @@ const handleClick = key => {
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div class="grid grid-cols-1 2xl:grid-cols-2 gap-6">
|
||||
<div class="grid grid-cols-1 2xl:grid-cols-2 gap-6 mt-4">
|
||||
<AssignmentCard
|
||||
v-for="item in agentAssignments"
|
||||
:key="item.key"
|
||||
|
||||
+2
-2
@@ -88,9 +88,9 @@ const handleSubmit = async formState => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsLayout class="xl:px-44">
|
||||
<SettingsLayout class="w-full max-w-2xl ltr:mr-auto rtl:ml-auto">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2 w-full justify-between">
|
||||
<div class="flex items-center gap-2 w-full justify-between mb-4 min-h-10">
|
||||
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+5
-2
@@ -251,9 +251,12 @@ watch(routeId, fetchPolicyData, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsLayout :is-loading="uiFlags.isFetchingItem" class="xl:px-44">
|
||||
<SettingsLayout
|
||||
:is-loading="uiFlags.isFetchingItem"
|
||||
class="w-full max-w-2xl ltr:mr-auto rtl:ml-auto"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2 w-full justify-between">
|
||||
<div class="flex items-center gap-2 w-full justify-between mb-4 min-h-10">
|
||||
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+2
-2
@@ -96,7 +96,7 @@ onMounted(() => {
|
||||
"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2 w-full justify-between">
|
||||
<div class="flex items-center gap-2 w-full justify-between min-h-10">
|
||||
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
|
||||
<Button icon="i-lucide-plus" md @click="onClickCreatePolicy">
|
||||
{{
|
||||
@@ -108,7 +108,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-4 pt-8">
|
||||
<div class="flex flex-col gap-4 pt-4">
|
||||
<AssignmentPolicyCard
|
||||
v-for="policy in agentAssignmentsPolicies"
|
||||
:key="policy.id"
|
||||
|
||||
+2
-2
@@ -67,9 +67,9 @@ const handleSubmit = async formState => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsLayout class="xl:px-44">
|
||||
<SettingsLayout class="w-full max-w-2xl ltr:mr-auto rtl:ml-auto">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2 w-full justify-between">
|
||||
<div class="flex items-center gap-2 w-full justify-between mb-4 min-h-10">
|
||||
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+5
-2
@@ -184,9 +184,12 @@ onMounted(() => store.dispatch('agents/get'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsLayout :is-loading="uiFlags.isFetchingItem" class="xl:px-44">
|
||||
<SettingsLayout
|
||||
:is-loading="uiFlags.isFetchingItem"
|
||||
class="w-full max-w-2xl ltr:mr-auto rtl:ml-auto"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2 w-full justify-between">
|
||||
<div class="flex items-center gap-2 w-full justify-between mb-4 min-h-10">
|
||||
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+2
-2
@@ -94,7 +94,7 @@ onMounted(() => {
|
||||
"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2 w-full justify-between">
|
||||
<div class="flex items-center gap-2 w-full justify-between min-h-10">
|
||||
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
|
||||
<Button icon="i-lucide-plus" md @click="onClickCreatePolicy">
|
||||
{{
|
||||
@@ -106,7 +106,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-4 pt-8">
|
||||
<div class="flex flex-col gap-4 pt-4">
|
||||
<AgentCapacityPolicyCard
|
||||
v-for="policy in agentCapacityPolicies"
|
||||
:key="policy.id"
|
||||
|
||||
+10
-3
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import BaseInfo from 'dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue';
|
||||
import RadioCard from 'dashboard/components-next/AssignmentPolicy/components/RadioCard.vue';
|
||||
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
|
||||
import FairDistribution from 'dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue';
|
||||
import DataTable from 'dashboard/components-next/AssignmentPolicy/components/DataTable.vue';
|
||||
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
|
||||
@@ -94,7 +94,8 @@ const createOption = (
|
||||
key,
|
||||
stateKey,
|
||||
disabled = false,
|
||||
disabledMessage = ''
|
||||
disabledMessage = '',
|
||||
disabledLabel = ''
|
||||
) => ({
|
||||
key,
|
||||
label: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.LABEL`),
|
||||
@@ -102,6 +103,7 @@ const createOption = (
|
||||
isActive: state[stateKey] === key,
|
||||
disabled,
|
||||
disabledMessage,
|
||||
disabledLabel,
|
||||
});
|
||||
|
||||
const assignmentOrderOptions = computed(() => {
|
||||
@@ -116,13 +118,17 @@ const assignmentOrderOptions = computed(() => {
|
||||
const disabledMessage = disabled
|
||||
? t(`${BASE_KEY}.FORM.ASSIGNMENT_ORDER.BALANCED.PREMIUM_MESSAGE`)
|
||||
: '';
|
||||
const disabledLabel = disabled
|
||||
? t(`${BASE_KEY}.FORM.ASSIGNMENT_ORDER.BALANCED.PREMIUM_BADGE`)
|
||||
: '';
|
||||
|
||||
return createOption(
|
||||
'ASSIGNMENT_ORDER',
|
||||
key,
|
||||
'assignmentOrder',
|
||||
disabled,
|
||||
disabledMessage
|
||||
disabledMessage,
|
||||
disabledLabel
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -217,6 +223,7 @@ defineExpose({
|
||||
:description="option.description"
|
||||
:is-active="option.isActive"
|
||||
:disabled="option.disabled"
|
||||
:disabled-label="option.disabledLabel"
|
||||
:disabled-message="option.disabledMessage"
|
||||
@select="state[section.key] = $event"
|
||||
/>
|
||||
|
||||
@@ -7,10 +7,12 @@ import { convertToAttributeSlug } from 'dashboard/helper/commons.js';
|
||||
import { ATTRIBUTE_MODELS, ATTRIBUTE_TYPES } from './constants';
|
||||
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
TagInput,
|
||||
},
|
||||
props: {
|
||||
onClose: {
|
||||
@@ -41,9 +43,8 @@ export default {
|
||||
regexCue: null,
|
||||
regexEnabled: false,
|
||||
values: [],
|
||||
options: [],
|
||||
show: true,
|
||||
isTouched: false,
|
||||
tagInputTouched: false,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -63,21 +64,21 @@ export default {
|
||||
option: this.$t(`ATTRIBUTES_MGMT.ATTRIBUTE_TYPES.${item.key}`),
|
||||
}));
|
||||
},
|
||||
isMultiselectInvalid() {
|
||||
return this.isTouched && this.values.length === 0;
|
||||
},
|
||||
isTagInputInvalid() {
|
||||
isTagInputEmpty() {
|
||||
return this.isAttributeTypeList && this.values.length === 0;
|
||||
},
|
||||
isTagInputInvalid() {
|
||||
return this.tagInputTouched && this.isTagInputEmpty;
|
||||
},
|
||||
attributeListValues() {
|
||||
return this.values.map(item => item.name);
|
||||
return this.values;
|
||||
},
|
||||
isButtonDisabled() {
|
||||
return (
|
||||
this.v$.displayName.$invalid ||
|
||||
this.v$.description.$invalid ||
|
||||
this.uiFlags.isCreating ||
|
||||
this.isTagInputInvalid
|
||||
this.isTagInputEmpty
|
||||
);
|
||||
},
|
||||
keyErrorMessage() {
|
||||
@@ -119,17 +120,14 @@ export default {
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
attributeType() {
|
||||
this.tagInputTouched = false;
|
||||
this.values = [];
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
addTagValue(tagValue) {
|
||||
const tag = {
|
||||
name: tagValue,
|
||||
};
|
||||
this.values.push(tag);
|
||||
this.$refs.tagInput.$el.focus();
|
||||
},
|
||||
onTouch() {
|
||||
this.isTouched = true;
|
||||
},
|
||||
onDisplayNameChange() {
|
||||
this.attributeKey = convertToAttributeSlug(this.displayName);
|
||||
},
|
||||
@@ -237,27 +235,25 @@ export default {
|
||||
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
<div v-if="isAttributeTypeList" class="multiselect--wrap">
|
||||
<label>
|
||||
<div v-if="isAttributeTypeList" class="mb-4">
|
||||
<label class="mb-1 block">
|
||||
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.LABEL') }}
|
||||
</label>
|
||||
<multiselect
|
||||
ref="tagInput"
|
||||
v-model="values"
|
||||
:placeholder="
|
||||
$t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.PLACEHOLDER')
|
||||
"
|
||||
label="name"
|
||||
track-by="name"
|
||||
:class="{ invalid: isMultiselectInvalid }"
|
||||
:options="options"
|
||||
multiple
|
||||
taggable
|
||||
@close="onTouch"
|
||||
@tag="addTagValue"
|
||||
/>
|
||||
<div
|
||||
class="rounded-xl border px-3 py-2"
|
||||
:class="isTagInputInvalid ? 'border-n-ruby-9' : 'border-n-weak'"
|
||||
>
|
||||
<TagInput
|
||||
v-model="values"
|
||||
:placeholder="
|
||||
$t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.PLACEHOLDER')
|
||||
"
|
||||
allow-create
|
||||
@blur="tagInputTouched = true"
|
||||
/>
|
||||
</div>
|
||||
<label
|
||||
v-show="isMultiselectInvalid"
|
||||
v-show="isTagInputInvalid"
|
||||
class="text-n-ruby-9 dark:text-n-ruby-9 text-sm font-normal mt-1"
|
||||
>
|
||||
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.ERROR') }}
|
||||
@@ -312,22 +308,4 @@ export default {
|
||||
padding: 0 0.5rem 0.5rem 0;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.multiselect--wrap {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
::v-deep {
|
||||
.multiselect {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.multiselect__content-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.multiselect--active .multiselect__tags {
|
||||
border-radius: 0.3125rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,10 +5,12 @@ import { required, minLength } from '@vuelidate/validators';
|
||||
import { getRegexp } from 'shared/helpers/Validators';
|
||||
import { ATTRIBUTE_TYPES } from './constants';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
TagInput,
|
||||
},
|
||||
props: {
|
||||
selectedAttribute: {
|
||||
@@ -35,8 +37,7 @@ export default {
|
||||
show: true,
|
||||
attributeKey: '',
|
||||
values: [],
|
||||
options: [],
|
||||
isTouched: true,
|
||||
tagInputTouched: false,
|
||||
};
|
||||
},
|
||||
validations: {
|
||||
@@ -65,20 +66,19 @@ export default {
|
||||
}));
|
||||
},
|
||||
setAttributeListValue() {
|
||||
return this.selectedAttribute.attribute_values.map(values => ({
|
||||
name: values,
|
||||
}));
|
||||
return this.selectedAttribute.attribute_values || [];
|
||||
},
|
||||
updatedAttributeListValues() {
|
||||
return this.values.map(item => item.name);
|
||||
return this.values;
|
||||
},
|
||||
isButtonDisabled() {
|
||||
return this.v$.description.$invalid || this.isMultiselectInvalid;
|
||||
return this.v$.description.$invalid || this.isTagInputEmpty;
|
||||
},
|
||||
isMultiselectInvalid() {
|
||||
return (
|
||||
this.isAttributeTypeList && this.isTouched && this.values.length === 0
|
||||
);
|
||||
isTagInputEmpty() {
|
||||
return this.isAttributeTypeList && this.values.length === 0;
|
||||
},
|
||||
isTagInputInvalid() {
|
||||
return this.tagInputTouched && this.isTagInputEmpty;
|
||||
},
|
||||
|
||||
pageTitle() {
|
||||
@@ -116,13 +116,6 @@ export default {
|
||||
onClose() {
|
||||
this.$emit('onClose');
|
||||
},
|
||||
addTagValue(tagValue) {
|
||||
const tag = {
|
||||
name: tagValue,
|
||||
};
|
||||
this.values.push(tag);
|
||||
this.$refs.tagInput.$el.focus();
|
||||
},
|
||||
setFormValues() {
|
||||
const regexPattern = this.selectedAttribute.regex_pattern
|
||||
? getRegexp(this.selectedAttribute.regex_pattern).source
|
||||
@@ -225,24 +218,25 @@ export default {
|
||||
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
<div v-if="isAttributeTypeList" class="multiselect--wrap">
|
||||
<label>
|
||||
<div v-if="isAttributeTypeList" class="mb-4">
|
||||
<label class="mb-1 block">
|
||||
{{ $t('ATTRIBUTES_MGMT.EDIT.TYPE.LIST.LABEL') }}
|
||||
</label>
|
||||
<multiselect
|
||||
ref="tagInput"
|
||||
v-model="values"
|
||||
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.PLACEHOLDER')"
|
||||
label="name"
|
||||
track-by="name"
|
||||
:class="{ invalid: isMultiselectInvalid }"
|
||||
:options="options"
|
||||
multiple
|
||||
taggable
|
||||
@tag="addTagValue"
|
||||
/>
|
||||
<div
|
||||
class="rounded-xl border px-3 py-2"
|
||||
:class="isTagInputInvalid ? 'border-n-ruby-9' : 'border-n-weak'"
|
||||
>
|
||||
<TagInput
|
||||
v-model="values"
|
||||
:placeholder="
|
||||
$t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.PLACEHOLDER')
|
||||
"
|
||||
allow-create
|
||||
@blur="tagInputTouched = true"
|
||||
/>
|
||||
</div>
|
||||
<label
|
||||
v-show="isMultiselectInvalid"
|
||||
v-show="isTagInputInvalid"
|
||||
class="text-n-ruby-9 dark:text-n-ruby-9 text-sm font-normal mt-1"
|
||||
>
|
||||
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.ERROR') }}
|
||||
@@ -297,22 +291,4 @@ export default {
|
||||
padding: 0 0.5rem 0.5rem 0;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.multiselect--wrap {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
::v-deep {
|
||||
.multiselect {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.multiselect__content-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.multiselect--active .multiselect__tags {
|
||||
border-radius: 0.3125rem;
|
||||
}
|
||||
}
|
||||
</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,31 +166,48 @@ 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 v-if="attributes?.length" #count>
|
||||
<span class="text-body-main text-n-slate-11 truncate min-w-0">
|
||||
{{ $t('ATTRIBUTES_MGMT.COUNT', { n: attributes.length }) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #tabs>
|
||||
<TabBar
|
||||
:tabs="tabsForTabBar"
|
||||
:initial-active-tab="selectedTabIndex"
|
||||
@tab-changed="onClickTabChange"
|
||||
/>
|
||||
</template>
|
||||
<template #actions>
|
||||
<Button
|
||||
icon="i-lucide-circle-plus"
|
||||
:label="$t('ATTRIBUTES_MGMT.HEADER_BTN_TXT')"
|
||||
size="sm"
|
||||
@click="openAddPopup"
|
||||
/>
|
||||
</template>
|
||||
</BaseSettingsHeader>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-6">
|
||||
<TabBar
|
||||
:tabs="tabsForTabBar"
|
||||
:initial-active-tab="selectedTabIndex"
|
||||
class="max-w-xl"
|
||||
@tab-changed="onClickTabChange"
|
||||
/>
|
||||
<div v-if="derivedAttributes.length" class="grid gap-3">
|
||||
<div class="flex flex-col gap-4">
|
||||
<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 divide-y divide-n-weak border-t border-n-weak"
|
||||
>
|
||||
<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>
|
||||
|
||||
+57
-324
@@ -1,21 +1,12 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import FilterInputBox from 'dashboard/components/widgets/FilterInput/Index.vue';
|
||||
import AutomationActionInput from 'dashboard/components/widgets/AutomationActionInput.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAutomation } from 'dashboard/composables/useAutomation';
|
||||
import { validateAutomation } from 'dashboard/helper/validations';
|
||||
import {
|
||||
generateAutomationPayload,
|
||||
getAttributes,
|
||||
getInputType,
|
||||
getOperators,
|
||||
getCustomAttributeType,
|
||||
showActionInput,
|
||||
} from 'dashboard/helper/automationHelper';
|
||||
import { AUTOMATION_RULE_EVENTS, AUTOMATION_ACTION_TYPES } from './constants';
|
||||
import AutomationRuleForm from './AutomationRuleForm.vue';
|
||||
|
||||
const start_value = {
|
||||
const emit = defineEmits(['saveAutomation']);
|
||||
|
||||
const START_VALUE = {
|
||||
name: null,
|
||||
description: null,
|
||||
event_name: 'conversation_created',
|
||||
@@ -36,318 +27,60 @@ const start_value = {
|
||||
],
|
||||
};
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FilterInputBox,
|
||||
AutomationActionInput,
|
||||
NextButton,
|
||||
},
|
||||
props: {
|
||||
onClose: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
emits: ['saveAutomation'],
|
||||
setup() {
|
||||
const {
|
||||
automation,
|
||||
automationTypes,
|
||||
onEventChange,
|
||||
getConditionDropdownValues,
|
||||
appendNewCondition,
|
||||
appendNewAction,
|
||||
removeFilter,
|
||||
removeAction,
|
||||
resetFilter,
|
||||
resetAction,
|
||||
getActionDropdownValues,
|
||||
manifestCustomAttributes,
|
||||
} = useAutomation(start_value);
|
||||
return {
|
||||
automation,
|
||||
automationTypes,
|
||||
onEventChange,
|
||||
getConditionDropdownValues,
|
||||
appendNewCondition,
|
||||
appendNewAction,
|
||||
removeFilter,
|
||||
removeAction,
|
||||
resetFilter,
|
||||
resetAction,
|
||||
getActionDropdownValues,
|
||||
manifestCustomAttributes,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
automationRuleEvent: AUTOMATION_RULE_EVENTS[0].key,
|
||||
automationMutated: false,
|
||||
show: true,
|
||||
showDeleteConfirmationModal: false,
|
||||
allCustomAttributes: [],
|
||||
mode: 'create',
|
||||
errors: {},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
accountId: 'getCurrentAccountId',
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
}),
|
||||
automationRuleEvents() {
|
||||
return AUTOMATION_RULE_EVENTS.map(event => ({
|
||||
...event,
|
||||
value: this.$t(`AUTOMATION.EVENTS.${event.value}`),
|
||||
}));
|
||||
},
|
||||
hasAutomationMutated() {
|
||||
if (
|
||||
this.automation.conditions[0].values ||
|
||||
this.automation.actions[0].action_params.length
|
||||
)
|
||||
return true;
|
||||
return false;
|
||||
},
|
||||
automationActionTypes() {
|
||||
const actionTypes = this.isFeatureEnabled('sla')
|
||||
? AUTOMATION_ACTION_TYPES
|
||||
: AUTOMATION_ACTION_TYPES.filter(({ key }) => key !== 'add_sla');
|
||||
const store = useStore();
|
||||
const formRef = ref(null);
|
||||
|
||||
return actionTypes.map(action => ({
|
||||
...action,
|
||||
label: this.$t(`AUTOMATION.ACTIONS.${action.label}`),
|
||||
}));
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('inboxes/get');
|
||||
this.$store.dispatch('agents/get');
|
||||
this.$store.dispatch('contacts/get');
|
||||
this.$store.dispatch('teams/get');
|
||||
this.$store.dispatch('labels/get');
|
||||
this.$store.dispatch('campaigns/get');
|
||||
this.allCustomAttributes = this.$store.getters['attributes/getAttributes'];
|
||||
this.manifestCustomAttributes();
|
||||
},
|
||||
methods: {
|
||||
getAttributes,
|
||||
getInputType,
|
||||
getOperators,
|
||||
getCustomAttributeType,
|
||||
showActionInput,
|
||||
isFeatureEnabled(flag) {
|
||||
return this.isFeatureEnabledonAccount(this.accountId, flag);
|
||||
},
|
||||
emitSaveAutomation() {
|
||||
this.errors = validateAutomation(this.automation);
|
||||
if (Object.keys(this.errors).length === 0) {
|
||||
const automation = generateAutomationPayload(this.automation);
|
||||
this.$emit('saveAutomation', automation, this.mode);
|
||||
}
|
||||
},
|
||||
getTranslatedAttributes(type, event) {
|
||||
return getAttributes(type, event).map(attribute => {
|
||||
// Skip translation
|
||||
// 1. If customAttributeType key is present then its rendering attributes from API
|
||||
// 2. If contact_custom_attribute or conversation_custom_attribute is present then its rendering section title
|
||||
const skipTranslation =
|
||||
attribute.customAttributeType ||
|
||||
[
|
||||
'contact_custom_attribute',
|
||||
'conversation_custom_attribute',
|
||||
].includes(attribute.key);
|
||||
const {
|
||||
automation,
|
||||
automationTypes,
|
||||
onEventChange,
|
||||
getConditionDropdownValues,
|
||||
appendNewCondition,
|
||||
appendNewAction,
|
||||
removeFilter,
|
||||
removeAction,
|
||||
resetAction,
|
||||
getActionDropdownValues,
|
||||
manifestCustomAttributes,
|
||||
} = useAutomation(START_VALUE);
|
||||
|
||||
return {
|
||||
...attribute,
|
||||
name: skipTranslation
|
||||
? attribute.name
|
||||
: this.$t(`AUTOMATION.ATTRIBUTES.${attribute.name}`),
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
const open = () => {
|
||||
automation.value = structuredClone(START_VALUE);
|
||||
manifestCustomAttributes();
|
||||
formRef.value?.open();
|
||||
};
|
||||
const close = () => formRef.value?.close();
|
||||
|
||||
const onSave = (payload, mode) => {
|
||||
emit('saveAutomation', payload, mode);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('inboxes/get');
|
||||
store.dispatch('agents/get');
|
||||
store.dispatch('contacts/get');
|
||||
store.dispatch('teams/get');
|
||||
store.dispatch('labels/get');
|
||||
store.dispatch('campaigns/get');
|
||||
});
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<woot-modal-header :header-title="$t('AUTOMATION.ADD.TITLE')" />
|
||||
<div class="flex flex-col modal-content">
|
||||
<div class="w-full">
|
||||
<woot-input
|
||||
v-model="automation.name"
|
||||
:label="$t('AUTOMATION.ADD.FORM.NAME.LABEL')"
|
||||
type="text"
|
||||
:class="{ error: errors.name }"
|
||||
:error="errors.name ? $t('AUTOMATION.ADD.FORM.NAME.ERROR') : ''"
|
||||
:placeholder="$t('AUTOMATION.ADD.FORM.NAME.PLACEHOLDER')"
|
||||
/>
|
||||
<woot-input
|
||||
v-model="automation.description"
|
||||
:label="$t('AUTOMATION.ADD.FORM.DESC.LABEL')"
|
||||
type="text"
|
||||
:class="{ error: errors.description }"
|
||||
:error="
|
||||
errors.description ? $t('AUTOMATION.ADD.FORM.DESC.ERROR') : ''
|
||||
"
|
||||
:placeholder="$t('AUTOMATION.ADD.FORM.DESC.PLACEHOLDER')"
|
||||
/>
|
||||
<div class="mb-6">
|
||||
<label :class="{ error: errors.event_name }">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EVENT.LABEL') }}
|
||||
<select
|
||||
v-model="automation.event_name"
|
||||
class="m-0"
|
||||
@change="onEventChange(automation)"
|
||||
>
|
||||
<option
|
||||
v-for="event in automationRuleEvents"
|
||||
:key="event.key"
|
||||
:value="event.key"
|
||||
>
|
||||
{{ event.value }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-if="errors.event_name" class="message">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EVENT.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
<p
|
||||
v-if="hasAutomationMutated"
|
||||
class="text-xs text-right text-n-teal-10 pt-1"
|
||||
>
|
||||
{{ $t('AUTOMATION.FORM.RESET_MESSAGE') }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- // Conditions Start -->
|
||||
<section>
|
||||
<label>
|
||||
{{ $t('AUTOMATION.ADD.FORM.CONDITIONS.LABEL') }}
|
||||
</label>
|
||||
<div
|
||||
class="w-full p-4 mb-4 border border-solid rounded-lg bg-n-slate-2 dark:bg-n-solid-2 border-n-strong"
|
||||
>
|
||||
<FilterInputBox
|
||||
v-for="(condition, i) in automation.conditions"
|
||||
:key="i"
|
||||
v-model="automation.conditions[i]"
|
||||
:filter-attributes="
|
||||
getTranslatedAttributes(automationTypes, automation.event_name)
|
||||
"
|
||||
:input-type="
|
||||
getInputType(
|
||||
allCustomAttributes,
|
||||
automationTypes,
|
||||
automation,
|
||||
automation.conditions[i].attribute_key
|
||||
)
|
||||
"
|
||||
:operators="
|
||||
getOperators(
|
||||
allCustomAttributes,
|
||||
automationTypes,
|
||||
automation,
|
||||
mode,
|
||||
automation.conditions[i].attribute_key
|
||||
)
|
||||
"
|
||||
:dropdown-values="
|
||||
getConditionDropdownValues(
|
||||
automation.conditions[i].attribute_key
|
||||
)
|
||||
"
|
||||
:show-query-operator="i !== automation.conditions.length - 1"
|
||||
:custom-attribute-type="
|
||||
getCustomAttributeType(
|
||||
automationTypes,
|
||||
automation,
|
||||
automation.conditions[i].attribute_key
|
||||
)
|
||||
"
|
||||
:error-message="
|
||||
errors[`condition_${i}`]
|
||||
? $t(`AUTOMATION.ERRORS.${errors[`condition_${i}`]}`)
|
||||
: ''
|
||||
"
|
||||
@reset-filter="resetFilter(i, automation.conditions[i])"
|
||||
@remove-filter="removeFilter(i)"
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<NextButton
|
||||
icon="i-lucide-plus"
|
||||
blue
|
||||
faded
|
||||
sm
|
||||
:label="$t('AUTOMATION.ADD.CONDITION_BUTTON_LABEL')"
|
||||
@click="appendNewCondition"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- // Conditions End -->
|
||||
<!-- // Actions Start -->
|
||||
<section>
|
||||
<label>
|
||||
{{ $t('AUTOMATION.ADD.FORM.ACTIONS.LABEL') }}
|
||||
</label>
|
||||
<div
|
||||
class="w-full p-4 mb-4 border border-solid rounded-lg bg-n-slate-2 dark:bg-n-solid-2 border-n-strong"
|
||||
>
|
||||
<AutomationActionInput
|
||||
v-for="(action, i) in automation.actions"
|
||||
:key="i"
|
||||
v-model="automation.actions[i]"
|
||||
:action-types="automationActionTypes"
|
||||
:dropdown-values="
|
||||
getActionDropdownValues(automation.actions[i].action_name)
|
||||
"
|
||||
:show-action-input="
|
||||
showActionInput(
|
||||
automationActionTypes,
|
||||
automation.actions[i].action_name
|
||||
)
|
||||
"
|
||||
:error-message="
|
||||
errors[`action_${i}`]
|
||||
? $t(`AUTOMATION.ERRORS.${errors[`action_${i}`]}`)
|
||||
: ''
|
||||
"
|
||||
@reset-action="resetAction(i)"
|
||||
@remove-action="removeAction(i)"
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<NextButton
|
||||
icon="i-lucide-plus"
|
||||
blue
|
||||
faded
|
||||
sm
|
||||
:label="$t('AUTOMATION.ADD.ACTION_BUTTON_LABEL')"
|
||||
@click="appendNewAction"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- // Actions End -->
|
||||
<div class="w-full">
|
||||
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
|
||||
<NextButton
|
||||
faded
|
||||
slate
|
||||
type="reset"
|
||||
:label="$t('AUTOMATION.ADD.CANCEL_BUTTON_TEXT')"
|
||||
@click.prevent="onClose"
|
||||
/>
|
||||
<NextButton
|
||||
solid
|
||||
blue
|
||||
type="submit"
|
||||
:label="$t('AUTOMATION.ADD.SUBMIT')"
|
||||
@click="emitSaveAutomation"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AutomationRuleForm
|
||||
ref="formRef"
|
||||
v-model:automation="automation"
|
||||
mode="create"
|
||||
:automation-types="automationTypes"
|
||||
:get-condition-dropdown-values="getConditionDropdownValues"
|
||||
:get-action-dropdown-values="getActionDropdownValues"
|
||||
:append-new-condition="appendNewCondition"
|
||||
:append-new-action="appendNewAction"
|
||||
:remove-filter="removeFilter"
|
||||
:remove-action="removeAction"
|
||||
:reset-action="resetAction"
|
||||
:on-event-change="onEventChange"
|
||||
@save="onSave"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
<script setup>
|
||||
import { ref, computed, h, useTemplateRef, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useOperators } from 'dashboard/components-next/filter/operators';
|
||||
import ConditionRow from 'dashboard/components-next/filter/ConditionRow.vue';
|
||||
import AutomationActionInput from 'dashboard/components/widgets/AutomationActionInput.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import {
|
||||
generateAutomationPayload,
|
||||
getAttributes,
|
||||
getFileName,
|
||||
showActionInput,
|
||||
} from 'dashboard/helper/automationHelper';
|
||||
import { validateAutomation } from 'dashboard/helper/validations';
|
||||
import { AUTOMATION_RULE_EVENTS, AUTOMATION_ACTION_TYPES } from './constants';
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: value => ['create', 'edit'].includes(value),
|
||||
},
|
||||
automationTypes: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
getConditionDropdownValues: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
getActionDropdownValues: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
appendNewCondition: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
appendNewAction: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
removeFilter: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
removeAction: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
resetAction: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
onEventChange: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['save']);
|
||||
const automation = defineModel('automation', { type: Object, default: null });
|
||||
|
||||
const INPUT_TYPE_MAP = {
|
||||
multi_select: 'multiSelect',
|
||||
search_select: 'searchSelect',
|
||||
plain_text: 'plainText',
|
||||
comma_separated_plain_text: 'plainText',
|
||||
date: 'date',
|
||||
};
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isCloudFeatureEnabled } = useAccount();
|
||||
const { operators } = useOperators();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const conditionsRef = useTemplateRef('conditionsRef');
|
||||
const errors = ref({});
|
||||
|
||||
const isEditMode = computed(() => props.mode === 'edit');
|
||||
|
||||
const titleKey = computed(() =>
|
||||
isEditMode.value ? 'AUTOMATION.EDIT.TITLE' : 'AUTOMATION.ADD.TITLE'
|
||||
);
|
||||
const cancelKey = computed(() =>
|
||||
isEditMode.value
|
||||
? 'AUTOMATION.EDIT.CANCEL_BUTTON_TEXT'
|
||||
: 'AUTOMATION.ADD.CANCEL_BUTTON_TEXT'
|
||||
);
|
||||
const submitKey = computed(() =>
|
||||
isEditMode.value ? 'AUTOMATION.EDIT.SUBMIT' : 'AUTOMATION.ADD.SUBMIT'
|
||||
);
|
||||
|
||||
const getTranslatedAttributes = (type, event) => {
|
||||
return getAttributes(type, event).map(attribute => {
|
||||
const skipTranslation =
|
||||
attribute.customAttributeType ||
|
||||
['contact_custom_attribute', 'conversation_custom_attribute'].includes(
|
||||
attribute.key
|
||||
);
|
||||
return {
|
||||
...attribute,
|
||||
name: skipTranslation
|
||||
? attribute.name
|
||||
: t(`AUTOMATION.ATTRIBUTES.${attribute.name}`),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const eventName = computed(() => automation.value?.event_name);
|
||||
|
||||
const filterTypes = computed(() => {
|
||||
const event = eventName.value;
|
||||
if (!event || !props.automationTypes[event]) return [];
|
||||
|
||||
const attributes = getTranslatedAttributes(props.automationTypes, event);
|
||||
|
||||
return attributes.map(attr => {
|
||||
if (attr.disabled) {
|
||||
return { value: attr.key, label: attr.name, disabled: true };
|
||||
}
|
||||
|
||||
const mappedInputType = INPUT_TYPE_MAP[attr.inputType] || 'plainText';
|
||||
const options = props.getConditionDropdownValues(attr.key) || [];
|
||||
|
||||
const filterOperators = (attr.filterOperators || []).map(op => {
|
||||
const enriched = operators.value[op.value];
|
||||
if (enriched) return enriched;
|
||||
return {
|
||||
value: op.value,
|
||||
label: t(`FILTER.OPERATOR_LABELS.${op.value}`),
|
||||
hasInput: true,
|
||||
inputOverride: null,
|
||||
icon: h('span', { class: 'i-ph-equals-bold !text-n-blue-11' }),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
attributeKey: attr.key,
|
||||
value: attr.key,
|
||||
attributeName: attr.name,
|
||||
label: attr.name,
|
||||
inputType: mappedInputType,
|
||||
options,
|
||||
filterOperators,
|
||||
dataType: 'text',
|
||||
attributeModel: attr.customAttributeType || 'standard',
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const automationRuleEvents = computed(() =>
|
||||
AUTOMATION_RULE_EVENTS.map(event => ({
|
||||
...event,
|
||||
value: t(`AUTOMATION.EVENTS.${event.value}`),
|
||||
}))
|
||||
);
|
||||
|
||||
const hasAutomationMutated = computed(() => {
|
||||
return Boolean(
|
||||
automation.value?.conditions[0]?.values ||
|
||||
automation.value?.actions[0]?.action_params?.length
|
||||
);
|
||||
});
|
||||
|
||||
const automationActionTypes = computed(() => {
|
||||
const actionTypes = isCloudFeatureEnabled('sla')
|
||||
? AUTOMATION_ACTION_TYPES
|
||||
: AUTOMATION_ACTION_TYPES.filter(({ key }) => key !== 'add_sla');
|
||||
|
||||
return actionTypes.map(action => ({
|
||||
...action,
|
||||
label: t(`AUTOMATION.ACTIONS.${action.label}`),
|
||||
}));
|
||||
});
|
||||
|
||||
const hasConditionErrors = computed(() =>
|
||||
Object.keys(errors.value).some(key => key.startsWith('condition_'))
|
||||
);
|
||||
|
||||
const hasActionErrors = computed(() =>
|
||||
Object.keys(errors.value).some(key => key.startsWith('action_'))
|
||||
);
|
||||
|
||||
watch(
|
||||
() => automation.value,
|
||||
() => {
|
||||
if (Object.keys(errors.value).length) {
|
||||
errors.value = {};
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
const isConditionsValid = () => {
|
||||
if (!conditionsRef.value) return true;
|
||||
return conditionsRef.value.every(condition => condition.validate());
|
||||
};
|
||||
|
||||
const resetValidation = () => {
|
||||
errors.value = {};
|
||||
conditionsRef.value?.forEach(c => c.resetValidation());
|
||||
};
|
||||
|
||||
const syncCustomAttributeTypes = () => {
|
||||
automation.value.conditions.forEach(condition => {
|
||||
const filterType = filterTypes.value.find(
|
||||
ft => ft.attributeKey === condition.attribute_key
|
||||
);
|
||||
condition.custom_attribute_type =
|
||||
filterType?.attributeModel === 'standard'
|
||||
? ''
|
||||
: filterType?.attributeModel || '';
|
||||
});
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
resetValidation();
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
resetValidation();
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
const emitSaveAutomation = () => {
|
||||
syncCustomAttributeTypes();
|
||||
const conditionsValid = isConditionsValid();
|
||||
errors.value = validateAutomation(automation.value);
|
||||
if (Object.keys(errors.value).length === 0 && conditionsValid) {
|
||||
const payload = generateAutomationPayload(automation.value);
|
||||
emit('save', payload, props.mode);
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
width="3xl"
|
||||
position="top"
|
||||
:title="$t(titleKey)"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
overflow-y-auto
|
||||
>
|
||||
<div v-if="automation" class="flex flex-col w-full">
|
||||
<woot-input
|
||||
v-model="automation.name"
|
||||
:label="$t('AUTOMATION.ADD.FORM.NAME.LABEL')"
|
||||
type="text"
|
||||
:class="{ error: errors.name }"
|
||||
:error="errors.name ? $t('AUTOMATION.ADD.FORM.NAME.ERROR') : ''"
|
||||
:placeholder="$t('AUTOMATION.ADD.FORM.NAME.PLACEHOLDER')"
|
||||
/>
|
||||
<woot-input
|
||||
v-model="automation.description"
|
||||
:label="$t('AUTOMATION.ADD.FORM.DESC.LABEL')"
|
||||
type="text"
|
||||
:class="{ error: errors.description }"
|
||||
:error="errors.description ? $t('AUTOMATION.ADD.FORM.DESC.ERROR') : ''"
|
||||
:placeholder="$t('AUTOMATION.ADD.FORM.DESC.PLACEHOLDER')"
|
||||
/>
|
||||
<div class="mb-6">
|
||||
<label :class="{ error: errors.event_name }">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EVENT.LABEL') }}
|
||||
<select
|
||||
v-model="automation.event_name"
|
||||
class="m-0"
|
||||
@change="onEventChange()"
|
||||
>
|
||||
<option
|
||||
v-for="event in automationRuleEvents"
|
||||
:key="event.key"
|
||||
:value="event.key"
|
||||
>
|
||||
{{ event.value }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-if="errors.event_name" class="message">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EVENT.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
<p
|
||||
v-if="!isEditMode && hasAutomationMutated"
|
||||
class="text-xs text-right text-n-teal-10 pt-1"
|
||||
>
|
||||
{{ $t('AUTOMATION.FORM.RESET_MESSAGE') }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Conditions Start -->
|
||||
<section class="mb-5">
|
||||
<label>
|
||||
{{ $t('AUTOMATION.ADD.FORM.CONDITIONS.LABEL') }}
|
||||
</label>
|
||||
<ul
|
||||
class="grid gap-4 list-none p-3 mb-4 outline outline-1 rounded-xl -outline-offset-1"
|
||||
:class="
|
||||
hasConditionErrors
|
||||
? 'outline-n-ruby-5 bg-n-ruby-2/50'
|
||||
: 'outline-n-weak dark:outline-n-strong'
|
||||
"
|
||||
>
|
||||
<template v-for="(condition, i) in automation.conditions" :key="i">
|
||||
<ConditionRow
|
||||
v-if="i === 0"
|
||||
ref="conditionsRef"
|
||||
v-model:attribute-key="automation.conditions[i].attribute_key"
|
||||
v-model:filter-operator="automation.conditions[i].filter_operator"
|
||||
v-model:values="automation.conditions[i].values"
|
||||
:filter-types="filterTypes"
|
||||
:show-query-operator="false"
|
||||
@remove="removeFilter(i)"
|
||||
/>
|
||||
<ConditionRow
|
||||
v-else
|
||||
ref="conditionsRef"
|
||||
v-model:attribute-key="automation.conditions[i].attribute_key"
|
||||
v-model:filter-operator="automation.conditions[i].filter_operator"
|
||||
v-model:query-operator="
|
||||
automation.conditions[i - 1].query_operator
|
||||
"
|
||||
v-model:values="automation.conditions[i].values"
|
||||
:filter-types="filterTypes"
|
||||
show-query-operator
|
||||
@remove="removeFilter(i)"
|
||||
/>
|
||||
</template>
|
||||
<div>
|
||||
<NextButton
|
||||
icon="i-lucide-plus"
|
||||
blue
|
||||
faded
|
||||
sm
|
||||
:label="$t('AUTOMATION.ADD.CONDITION_BUTTON_LABEL')"
|
||||
@click="appendNewCondition"
|
||||
/>
|
||||
</div>
|
||||
</ul>
|
||||
</section>
|
||||
<!-- Conditions End -->
|
||||
<!-- Actions Start -->
|
||||
<section>
|
||||
<label>
|
||||
{{ $t('AUTOMATION.ADD.FORM.ACTIONS.LABEL') }}
|
||||
</label>
|
||||
<ul
|
||||
class="grid list-none p-3 mb-4 outline outline-1 rounded-xl -outline-offset-1 border-solid"
|
||||
:class="
|
||||
hasActionErrors
|
||||
? 'outline-n-ruby-5 bg-n-ruby-2/50'
|
||||
: 'outline-n-weak dark:outline-n-strong'
|
||||
"
|
||||
>
|
||||
<AutomationActionInput
|
||||
v-for="(action, i) in automation.actions"
|
||||
:key="i"
|
||||
v-model="automation.actions[i]"
|
||||
:action-types="automationActionTypes"
|
||||
dropdown-max-height="max-h-[7.5rem]"
|
||||
:dropdown-values="getActionDropdownValues(action.action_name)"
|
||||
:show-action-input="
|
||||
showActionInput(automationActionTypes, action.action_name)
|
||||
"
|
||||
:error-message="
|
||||
errors[`action_${i}`]
|
||||
? $t(`AUTOMATION.ERRORS.${errors[`action_${i}`]}`)
|
||||
: ''
|
||||
"
|
||||
:initial-file-name="
|
||||
isEditMode ? getFileName(action, automation.files) : ''
|
||||
"
|
||||
@reset-action="resetAction(i)"
|
||||
@remove-action="removeAction(i)"
|
||||
/>
|
||||
<div class="pt-2">
|
||||
<NextButton
|
||||
icon="i-lucide-plus"
|
||||
blue
|
||||
faded
|
||||
sm
|
||||
:label="$t('AUTOMATION.ADD.ACTION_BUTTON_LABEL')"
|
||||
@click="appendNewAction"
|
||||
/>
|
||||
</div>
|
||||
</ul>
|
||||
</section>
|
||||
<!-- Actions End -->
|
||||
<div class="w-full mt-8">
|
||||
<div class="flex flex-row justify-end w-full gap-2 px-0 py-4">
|
||||
<NextButton
|
||||
faded
|
||||
slate
|
||||
type="reset"
|
||||
:label="$t(cancelKey)"
|
||||
@click.prevent="close"
|
||||
/>
|
||||
<NextButton
|
||||
solid
|
||||
blue
|
||||
type="submit"
|
||||
:label="$t(submitKey)"
|
||||
@click="emitSaveAutomation"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
+56
-43
@@ -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,59 @@ 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.CLONE.TOOLTIP')"
|
||||
icon="i-woot-clone"
|
||||
sm
|
||||
slate
|
||||
:is-loading="loading"
|
||||
@click="$emit('clone', automation)"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.top="$t('AUTOMATION.FORM.DELETE')"
|
||||
:is-loading="loading"
|
||||
icon="i-woot-bin"
|
||||
slate
|
||||
sm
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
@click="$emit('delete', automation)"
|
||||
/>
|
||||
</div>
|
||||
</BaseTableCell>
|
||||
</template>
|
||||
</BaseTableRow>
|
||||
</template>
|
||||
|
||||
+68
-333
@@ -1,345 +1,80 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAutomation } from 'dashboard/composables/useAutomation';
|
||||
import { useEditableAutomation } from 'dashboard/composables/useEditableAutomation';
|
||||
import FilterInputBox from 'dashboard/components/widgets/FilterInput/Index.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import AutomationActionInput from 'dashboard/components/widgets/AutomationActionInput.vue';
|
||||
import {
|
||||
getFileName,
|
||||
generateAutomationPayload,
|
||||
getAttributes,
|
||||
getInputType,
|
||||
getOperators,
|
||||
getCustomAttributeType,
|
||||
showActionInput,
|
||||
} from 'dashboard/helper/automationHelper';
|
||||
import { validateAutomation } from 'dashboard/helper/validations';
|
||||
import AutomationRuleForm from './AutomationRuleForm.vue';
|
||||
import { AUTOMATION_ACTION_TYPES } from './constants';
|
||||
|
||||
import { AUTOMATION_RULE_EVENTS, AUTOMATION_ACTION_TYPES } from './constants';
|
||||
const props = defineProps({
|
||||
selectedResponse: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FilterInputBox,
|
||||
NextButton,
|
||||
AutomationActionInput,
|
||||
},
|
||||
props: {
|
||||
onClose: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
selectedResponse: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
emits: ['saveAutomation'],
|
||||
setup() {
|
||||
const {
|
||||
automation,
|
||||
const emit = defineEmits(['saveAutomation']);
|
||||
|
||||
const allCustomAttributes = useMapGetter('attributes/getAttributes');
|
||||
const formRef = ref(null);
|
||||
|
||||
const {
|
||||
automation,
|
||||
automationTypes,
|
||||
onEventChange,
|
||||
getConditionDropdownValues,
|
||||
appendNewCondition,
|
||||
appendNewAction,
|
||||
removeFilter,
|
||||
removeAction,
|
||||
resetAction,
|
||||
getActionDropdownValues,
|
||||
manifestCustomAttributes,
|
||||
} = useAutomation();
|
||||
|
||||
const { formatAutomation } = useEditableAutomation();
|
||||
|
||||
const open = () => formRef.value?.open();
|
||||
const close = () => formRef.value?.close();
|
||||
|
||||
const onSave = (payload, mode) => {
|
||||
emit('saveAutomation', payload, mode);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.selectedResponse,
|
||||
value => {
|
||||
if (!value?.conditions) return;
|
||||
|
||||
manifestCustomAttributes();
|
||||
|
||||
automation.value = formatAutomation(
|
||||
value,
|
||||
allCustomAttributes.value,
|
||||
automationTypes,
|
||||
onEventChange,
|
||||
getConditionDropdownValues,
|
||||
appendNewCondition,
|
||||
appendNewAction,
|
||||
removeFilter,
|
||||
removeAction,
|
||||
resetFilter,
|
||||
resetAction,
|
||||
getActionDropdownValues,
|
||||
manifestCustomAttributes,
|
||||
} = useAutomation();
|
||||
const { formatAutomation } = useEditableAutomation();
|
||||
return {
|
||||
automation,
|
||||
automationTypes,
|
||||
onEventChange,
|
||||
getConditionDropdownValues,
|
||||
appendNewCondition,
|
||||
appendNewAction,
|
||||
removeFilter,
|
||||
removeAction,
|
||||
resetFilter,
|
||||
resetAction,
|
||||
getActionDropdownValues,
|
||||
formatAutomation,
|
||||
manifestCustomAttributes,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
automationRuleEvent: AUTOMATION_RULE_EVENTS[0].key,
|
||||
automationMutated: false,
|
||||
show: true,
|
||||
showDeleteConfirmationModal: false,
|
||||
allCustomAttributes: [],
|
||||
mode: 'edit',
|
||||
errors: {},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
accountId: 'getCurrentAccountId',
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
}),
|
||||
automationRuleEvents() {
|
||||
return AUTOMATION_RULE_EVENTS.map(event => ({
|
||||
...event,
|
||||
value: this.$t(`AUTOMATION.EVENTS.${event.value}`),
|
||||
}));
|
||||
},
|
||||
hasAutomationMutated() {
|
||||
if (
|
||||
this.automation.conditions[0].values ||
|
||||
this.automation.actions[0].action_params.length
|
||||
)
|
||||
return true;
|
||||
return false;
|
||||
},
|
||||
automationActionTypes() {
|
||||
const actionTypes = this.isFeatureEnabled('sla')
|
||||
? AUTOMATION_ACTION_TYPES
|
||||
: AUTOMATION_ACTION_TYPES.filter(({ key }) => key !== 'add_sla');
|
||||
|
||||
return actionTypes.map(action => ({
|
||||
...action,
|
||||
label: this.$t(`AUTOMATION.ACTIONS.${action.label}`),
|
||||
}));
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.manifestCustomAttributes();
|
||||
this.allCustomAttributes = this.$store.getters['attributes/getAttributes'];
|
||||
|
||||
this.automation = this.formatAutomation(
|
||||
this.selectedResponse,
|
||||
this.allCustomAttributes,
|
||||
this.automationTypes,
|
||||
this.automationActionTypes
|
||||
AUTOMATION_ACTION_TYPES
|
||||
);
|
||||
},
|
||||
methods: {
|
||||
getFileName,
|
||||
getAttributes,
|
||||
getInputType,
|
||||
getOperators,
|
||||
getCustomAttributeType,
|
||||
showActionInput,
|
||||
isFeatureEnabled(flag) {
|
||||
return this.isFeatureEnabledonAccount(this.accountId, flag);
|
||||
},
|
||||
emitSaveAutomation() {
|
||||
this.errors = validateAutomation(this.automation);
|
||||
if (Object.keys(this.errors).length === 0) {
|
||||
const automation = generateAutomationPayload(this.automation);
|
||||
this.$emit('saveAutomation', automation, this.mode);
|
||||
}
|
||||
},
|
||||
getTranslatedAttributes(type, event) {
|
||||
return getAttributes(type, event).map(attribute => {
|
||||
// Skip translation
|
||||
// 1. If customAttributeType key is present then its rendering attributes from API
|
||||
// 2. If contact_custom_attribute or conversation_custom_attribute is present then its rendering section title
|
||||
const skipTranslation =
|
||||
attribute.customAttributeType ||
|
||||
[
|
||||
'contact_custom_attribute',
|
||||
'conversation_custom_attribute',
|
||||
].includes(attribute.key);
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
return {
|
||||
...attribute,
|
||||
name: skipTranslation
|
||||
? attribute.name
|
||||
: this.$t(`AUTOMATION.ATTRIBUTES.${attribute.name}`),
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<woot-modal-header :header-title="$t('AUTOMATION.EDIT.TITLE')" />
|
||||
<div class="flex flex-col modal-content">
|
||||
<div v-if="automation" class="w-full">
|
||||
<woot-input
|
||||
v-model="automation.name"
|
||||
:label="$t('AUTOMATION.ADD.FORM.NAME.LABEL')"
|
||||
type="text"
|
||||
:class="{ error: errors.name }"
|
||||
:error="errors.name ? $t('AUTOMATION.ADD.FORM.NAME.ERROR') : ''"
|
||||
:placeholder="$t('AUTOMATION.ADD.FORM.NAME.PLACEHOLDER')"
|
||||
/>
|
||||
<woot-input
|
||||
v-model="automation.description"
|
||||
:label="$t('AUTOMATION.ADD.FORM.DESC.LABEL')"
|
||||
type="text"
|
||||
:class="{ error: errors.description }"
|
||||
:error="
|
||||
errors.description ? $t('AUTOMATION.ADD.FORM.DESC.ERROR') : ''
|
||||
"
|
||||
:placeholder="$t('AUTOMATION.ADD.FORM.DESC.PLACEHOLDER')"
|
||||
/>
|
||||
<div class="event_wrapper">
|
||||
<label :class="{ error: errors.event_name }">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EVENT.LABEL') }}
|
||||
<select
|
||||
v-model="automation.event_name"
|
||||
@change="onEventChange(automation)"
|
||||
>
|
||||
<option
|
||||
v-for="event in automationRuleEvents"
|
||||
:key="event.key"
|
||||
:value="event.key"
|
||||
>
|
||||
{{ event.value }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-if="errors.event_name" class="message">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EVENT.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<!-- // Conditions Start -->
|
||||
<section>
|
||||
<label>
|
||||
{{ $t('AUTOMATION.ADD.FORM.CONDITIONS.LABEL') }}
|
||||
</label>
|
||||
<div
|
||||
class="w-full p-4 mb-4 border border-solid rounded-lg bg-n-slate-2 dark:bg-n-solid-2 border-n-strong"
|
||||
>
|
||||
<FilterInputBox
|
||||
v-for="(condition, i) in automation.conditions"
|
||||
:key="i"
|
||||
v-model="automation.conditions[i]"
|
||||
:filter-attributes="
|
||||
getTranslatedAttributes(automationTypes, automation.event_name)
|
||||
"
|
||||
:input-type="
|
||||
getInputType(
|
||||
allCustomAttributes,
|
||||
automationTypes,
|
||||
automation,
|
||||
automation.conditions[i].attribute_key
|
||||
)
|
||||
"
|
||||
:operators="
|
||||
getOperators(
|
||||
allCustomAttributes,
|
||||
automationTypes,
|
||||
automation,
|
||||
mode,
|
||||
automation.conditions[i].attribute_key
|
||||
)
|
||||
"
|
||||
:dropdown-values="
|
||||
getConditionDropdownValues(
|
||||
automation.conditions[i].attribute_key
|
||||
)
|
||||
"
|
||||
:custom-attribute-type="
|
||||
getCustomAttributeType(
|
||||
automationTypes,
|
||||
automation,
|
||||
automation.conditions[i].attribute_key
|
||||
)
|
||||
"
|
||||
:show-query-operator="i !== automation.conditions.length - 1"
|
||||
:error-message="
|
||||
errors[`condition_${i}`]
|
||||
? $t(`AUTOMATION.ERRORS.${errors[`condition_${i}`]}`)
|
||||
: ''
|
||||
"
|
||||
@reset-filter="resetFilter(i, automation.conditions[i])"
|
||||
@remove-filter="removeFilter(i)"
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<NextButton
|
||||
icon="i-lucide-plus"
|
||||
blue
|
||||
faded
|
||||
sm
|
||||
:label="$t('AUTOMATION.ADD.CONDITION_BUTTON_LABEL')"
|
||||
@click="appendNewCondition"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- // Conditions End -->
|
||||
<!-- // Actions Start -->
|
||||
<section>
|
||||
<label>
|
||||
{{ $t('AUTOMATION.ADD.FORM.ACTIONS.LABEL') }}
|
||||
</label>
|
||||
<div
|
||||
class="w-full p-4 mb-4 border border-solid rounded-lg bg-n-slate-2 dark:bg-n-solid-2 border-n-strong"
|
||||
>
|
||||
<AutomationActionInput
|
||||
v-for="(action, i) in automation.actions"
|
||||
:key="i"
|
||||
v-model="automation.actions[i]"
|
||||
:action-types="automationActionTypes"
|
||||
:dropdown-values="getActionDropdownValues(action.action_name)"
|
||||
:show-action-input="
|
||||
showActionInput(automationActionTypes, action.action_name)
|
||||
"
|
||||
:error-message="
|
||||
errors[`action_${i}`]
|
||||
? $t(`AUTOMATION.ERRORS.${errors[`action_${i}`]}`)
|
||||
: ''
|
||||
"
|
||||
:initial-file-name="getFileName(action, automation.files)"
|
||||
@reset-action="resetAction(i)"
|
||||
@remove-action="removeAction(i)"
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<NextButton
|
||||
icon="i-lucide-plus"
|
||||
blue
|
||||
faded
|
||||
sm
|
||||
:label="$t('AUTOMATION.ADD.ACTION_BUTTON_LABEL')"
|
||||
@click="appendNewAction"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- // Actions End -->
|
||||
<div class="w-full">
|
||||
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
|
||||
<NextButton
|
||||
faded
|
||||
slate
|
||||
type="reset"
|
||||
:label="$t('AUTOMATION.EDIT.CANCEL_BUTTON_TEXT')"
|
||||
@click.prevent="onClose"
|
||||
/>
|
||||
<NextButton
|
||||
solid
|
||||
blue
|
||||
type="submit"
|
||||
:label="$t('AUTOMATION.EDIT.SUBMIT')"
|
||||
@click="emitSaveAutomation"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AutomationRuleForm
|
||||
ref="formRef"
|
||||
v-model:automation="automation"
|
||||
mode="edit"
|
||||
:automation-types="automationTypes"
|
||||
:get-condition-dropdown-values="getConditionDropdownValues"
|
||||
:get-action-dropdown-values="getActionDropdownValues"
|
||||
:append-new-condition="appendNewCondition"
|
||||
:append-new-action="appendNewAction"
|
||||
:remove-filter="removeFilter"
|
||||
:remove-action="removeAction"
|
||||
:reset-action="resetAction"
|
||||
:on-event-change="onEventChange"
|
||||
@save="onSave"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.event_wrapper {
|
||||
select {
|
||||
@apply m-0;
|
||||
}
|
||||
.info-message {
|
||||
@apply text-xs text-n-teal-10 text-right;
|
||||
}
|
||||
|
||||
@apply mb-6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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();
|
||||
@@ -16,16 +18,23 @@ const { t } = useI18n();
|
||||
const confirmDialog = ref(null);
|
||||
|
||||
const loading = ref({});
|
||||
const showAddPopup = ref(false);
|
||||
const showEditPopup = ref(false);
|
||||
const addDialogRef = ref(null);
|
||||
const editDialogRef = ref(null);
|
||||
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);
|
||||
|
||||
@@ -57,18 +66,18 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
const openAddPopup = () => {
|
||||
showAddPopup.value = true;
|
||||
addDialogRef.value?.open();
|
||||
};
|
||||
const hideAddPopup = () => {
|
||||
showAddPopup.value = false;
|
||||
addDialogRef.value?.close();
|
||||
};
|
||||
|
||||
const openEditPopup = response => {
|
||||
selectedAutomation.value = response;
|
||||
showEditPopup.value = true;
|
||||
selectedAutomation.value = { ...response };
|
||||
editDialogRef.value?.open();
|
||||
};
|
||||
const hideEditPopup = () => {
|
||||
showEditPopup.value = false;
|
||||
editDialogRef.value?.close();
|
||||
};
|
||||
|
||||
const openDeletePopup = response => {
|
||||
@@ -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,34 +190,38 @@ 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 v-if="records?.length" #count>
|
||||
<span class="text-body-main text-n-slate-11">
|
||||
{{ $t('AUTOMATION.COUNT', { n: records.length }) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<Button
|
||||
icon="i-lucide-circle-plus"
|
||||
:label="$t('AUTOMATION.HEADER_BTN_TXT')"
|
||||
size="sm"
|
||||
@click="openAddPopup"
|
||||
/>
|
||||
</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 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,21 +230,11 @@ const tableHeaders = computed(() => {
|
||||
@edit="openEditPopup"
|
||||
@delete="openDeletePopup"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</template>
|
||||
|
||||
<woot-modal
|
||||
v-model:show="showAddPopup"
|
||||
size="medium"
|
||||
:on-close="hideAddPopup"
|
||||
>
|
||||
<AddAutomationRule
|
||||
v-if="showAddPopup"
|
||||
:on-close="hideAddPopup"
|
||||
@save-automation="submitAutomation"
|
||||
/>
|
||||
</woot-modal>
|
||||
<AddAutomationRule ref="addDialogRef" @save-automation="submitAutomation" />
|
||||
|
||||
<woot-delete-modal
|
||||
v-model:show="showDeleteConfirmationPopup"
|
||||
@@ -244,18 +247,11 @@ const tableHeaders = computed(() => {
|
||||
:reject-text="deleteRejectText"
|
||||
/>
|
||||
|
||||
<woot-modal
|
||||
v-model:show="showEditPopup"
|
||||
size="medium"
|
||||
:on-close="hideEditPopup"
|
||||
>
|
||||
<EditAutomationRule
|
||||
v-if="showEditPopup"
|
||||
:on-close="hideEditPopup"
|
||||
:selected-response="selectedAutomation"
|
||||
@save-automation="submitAutomation"
|
||||
/>
|
||||
</woot-modal>
|
||||
<EditAutomationRule
|
||||
ref="editDialogRef"
|
||||
:selected-response="selectedAutomation"
|
||||
@save-automation="submitAutomation"
|
||||
/>
|
||||
<woot-confirm-modal
|
||||
ref="confirmDialog"
|
||||
:title="toggleModalTitle"
|
||||
|
||||
@@ -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,20 @@ 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, [
|
||||
{ name: 'short_code', weight: 4 },
|
||||
'content',
|
||||
]);
|
||||
});
|
||||
const uiFlags = computed(() => getters.getUIFlags.value);
|
||||
|
||||
const deleteConfirmText = computed(
|
||||
@@ -114,103 +133,119 @@ 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 v-if="records?.length" #count>
|
||||
<span class="text-body-main text-n-slate-11">
|
||||
{{ $t('CANNED_MGMT.COUNT', { n: records.length }) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<Button
|
||||
:label="$t('CANNED_MGMT.HEADER_BTN_TXT')"
|
||||
size="sm"
|
||||
@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
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
: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 +270,5 @@ const tableHeaders = computed(() => {
|
||||
:confirm-text="deleteConfirmText"
|
||||
:reject-text="deleteRejectText"
|
||||
/>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
|
||||
+62
-51
@@ -1,9 +1,10 @@
|
||||
<script setup>
|
||||
import { 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,42 @@ 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 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
|
||||
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"
|
||||
/>
|
||||
</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">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
<div
|
||||
v-if="title"
|
||||
class="flex items-center justify-between w-full gap-4 min-h-8 mb-2"
|
||||
>
|
||||
<h1 class="text-heading-1 text-n-slate-12">
|
||||
{{ title }}
|
||||
</h1>
|
||||
</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-1.5 text-n-slate-11"
|
||||
>
|
||||
<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,7 +72,7 @@ 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-11 hover:underline"
|
||||
class="items-center hidden gap-1 text-sm font-medium sm:inline-flex w-fit text-n-blue-11 hover:underline mb-2"
|
||||
>
|
||||
{{ linkText }}
|
||||
<Icon
|
||||
@@ -95,21 +82,45 @@ const openInNewTab = url => {
|
||||
</a>
|
||||
</CustomBrandPolicyWrapper>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="searchPlaceholder || slots.actions || slots.tabs"
|
||||
class="gap-3 flex justify-between sm:mt-4 min-w-0"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-start justify-start w-full gap-3 sm:hidden"
|
||||
v-if="slots.tabs || searchPlaceholder"
|
||||
class="flex items-center gap-3"
|
||||
:class="{
|
||||
'hidden sm:flex': !slots.tabs,
|
||||
}"
|
||||
>
|
||||
<slot name="tabs" />
|
||||
<Input
|
||||
v-if="searchPlaceholder"
|
||||
v-model="searchQuery"
|
||||
:placeholder="searchPlaceholder"
|
||||
class="group w-56 min-w-0 hidden sm:flex [&>input]:ltr:!pl-8 [&>input]:rtl:!pr-8 [&>input]:!rounded-[0.625rem]"
|
||||
size="sm"
|
||||
type="search"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="i-lucide-search"
|
||||
class="absolute top-1/2 -translate-y-1/2 text-n-slate-11 group-focus-within:text-n-brand size-3.5 ltr:left-2.5 rtl:right-2.5"
|
||||
/>
|
||||
</template>
|
||||
</Input>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-3 min-w-0"
|
||||
:class="{ 'flex-row-reverse sm:flex-row': !slots.tabs }"
|
||||
>
|
||||
<slot name="count" />
|
||||
<div
|
||||
v-if="slots.count"
|
||||
class="w-px h-3 rounded-lg bg-n-weak ltr:ml-1 ltr:mr-2 rtl:ml-2 rtl:mr-1 flex-shrink-0"
|
||||
/>
|
||||
<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>
|
||||
|
||||
@@ -39,7 +39,7 @@ const showRequiredAttributes = computed(() => {
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-6 mt-4">
|
||||
<AutoResolve v-if="showAutoResolutionConfig" />
|
||||
<ConversationRequiredAttributes :is-enabled="showRequiredAttributes" />
|
||||
</div>
|
||||
|
||||
@@ -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,15 +138,22 @@ 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 v-if="records?.length" #count>
|
||||
<span class="text-body-main text-n-slate-11">
|
||||
{{ $t('CUSTOM_ROLE.COUNT', { n: records.length }) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<Button
|
||||
icon="i-lucide-circle-plus"
|
||||
:label="$t('CUSTOM_ROLE.HEADER_BTN_TXT')"
|
||||
size="sm"
|
||||
:disabled="isBehindAPaywall"
|
||||
@click="openAddModal"
|
||||
/>
|
||||
@@ -147,26 +163,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
|
||||
|
||||
+1
-1
@@ -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 }}
|
||||
|
||||
+47
-39
@@ -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,50 @@ 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
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
:is-loading="loading[customRole.id]"
|
||||
@click="emit('delete', customRole)"
|
||||
/>
|
||||
</div>
|
||||
</BaseTableCell>
|
||||
</template>
|
||||
</BaseTableRow>
|
||||
</template>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import InboxMembersAPI from '../../../../api/inboxMembers';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
|
||||
import router from '../../../index';
|
||||
import PageHeader from '../SettingsSubPageHeader.vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
@@ -13,11 +14,12 @@ export default {
|
||||
components: {
|
||||
PageHeader,
|
||||
NextButton,
|
||||
TagInput,
|
||||
},
|
||||
validations: {
|
||||
selectedAgents: {
|
||||
selectedAgentIds: {
|
||||
isEmpty() {
|
||||
return !!this.selectedAgents.length;
|
||||
return !!this.selectedAgentIds.length;
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -26,7 +28,7 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedAgents: [],
|
||||
selectedAgentIds: [],
|
||||
isCreating: false,
|
||||
};
|
||||
},
|
||||
@@ -34,18 +36,43 @@ export default {
|
||||
...mapGetters({
|
||||
agentList: 'agents/getAgents',
|
||||
}),
|
||||
selectedAgentNames() {
|
||||
return this.selectedAgentIds.map(
|
||||
id => this.agentList.find(a => a.id === id)?.name ?? ''
|
||||
);
|
||||
},
|
||||
agentMenuItems() {
|
||||
return this.agentList
|
||||
.filter(({ id }) => !this.selectedAgentIds.includes(id))
|
||||
.map(({ id, name, thumbnail, avatar_url }) => ({
|
||||
label: name,
|
||||
value: id,
|
||||
action: 'select',
|
||||
thumbnail: { name, src: thumbnail || avatar_url || '' },
|
||||
}));
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('agents/get');
|
||||
},
|
||||
methods: {
|
||||
handleAgentAdd({ value }) {
|
||||
if (!this.selectedAgentIds.includes(value)) {
|
||||
this.selectedAgentIds.push(value);
|
||||
}
|
||||
},
|
||||
handleAgentRemove(index) {
|
||||
this.selectedAgentIds.splice(index, 1);
|
||||
},
|
||||
async addAgents() {
|
||||
this.isCreating = true;
|
||||
const inboxId = this.$route.params.inbox_id;
|
||||
const selectedAgents = this.selectedAgents.map(x => x.id);
|
||||
|
||||
try {
|
||||
await InboxMembersAPI.update({ inboxId, agentList: selectedAgents });
|
||||
await InboxMembersAPI.update({
|
||||
inboxId,
|
||||
agentList: this.selectedAgentIds,
|
||||
});
|
||||
router.replace({
|
||||
name: 'settings_inbox_finish',
|
||||
params: {
|
||||
@@ -72,25 +99,23 @@ export default {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="w-full">
|
||||
<label :class="{ error: v$.selectedAgents.$error }">
|
||||
<div class="w-full mb-4">
|
||||
<label :class="{ error: v$.selectedAgentIds.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.AGENTS.TITLE') }}
|
||||
<multiselect
|
||||
v-model="selectedAgents"
|
||||
:options="agentList"
|
||||
track-by="id"
|
||||
label="name"
|
||||
multiple
|
||||
:close-on-select="false"
|
||||
:clear-on-select="false"
|
||||
hide-selected
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
|
||||
:placeholder="$t('INBOX_MGMT.ADD.AGENTS.PICK_AGENTS')"
|
||||
@select="v$.selectedAgents.$touch"
|
||||
/>
|
||||
<span v-if="v$.selectedAgents.$error" class="message">
|
||||
<div
|
||||
class="rounded-xl outline outline-1 -outline-offset-1 outline-n-weak hover:outline-n-strong px-2 py-2"
|
||||
>
|
||||
<TagInput
|
||||
:model-value="selectedAgentNames"
|
||||
:placeholder="$t('INBOX_MGMT.ADD.AGENTS.PICK_AGENTS')"
|
||||
:menu-items="agentMenuItems"
|
||||
show-dropdown
|
||||
skip-label-dedup
|
||||
@add="handleAgentAdd"
|
||||
@remove="handleAgentRemove"
|
||||
/>
|
||||
</div>
|
||||
<span v-if="v$.selectedAgentIds.$error" class="message">
|
||||
{{ $t('INBOX_MGMT.ADD.AGENTS.VALIDATION_ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -92,7 +92,7 @@ const channelList = computed(() => {
|
||||
key: 'voice',
|
||||
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.VOICE.TITLE'),
|
||||
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.VOICE.DESCRIPTION'),
|
||||
icon: 'i-ri-phone-fill',
|
||||
icon: 'i-woot-voice',
|
||||
});
|
||||
|
||||
return channels;
|
||||
@@ -116,17 +116,15 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full p-8 overflow-auto">
|
||||
<div
|
||||
class="grid max-w-3xl grid-cols-1 xs:grid-cols-2 mx-0 gap-6 sm:grid-cols-3"
|
||||
>
|
||||
<ChannelItem
|
||||
v-for="channel in channelList"
|
||||
:key="channel.key"
|
||||
:channel="channel"
|
||||
:enabled-features="enabledFeatures"
|
||||
@channel-item-click="initChannelAuth"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="grid max-w-3xl grid-cols-1 xs:grid-cols-2 mx-0 gap-6 sm:grid-cols-3 p-8"
|
||||
>
|
||||
<ChannelItem
|
||||
v-for="channel in channelList"
|
||||
:key="channel.key"
|
||||
:channel="channel"
|
||||
:enabled-features="enabledFeatures"
|
||||
@channel-item-click="initChannelAuth"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import SettingsSection from 'dashboard/components/SettingsSection.vue';
|
||||
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength } from '@vuelidate/validators';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SettingsSection,
|
||||
SettingsFieldSection,
|
||||
NextButton,
|
||||
},
|
||||
props: {
|
||||
@@ -95,75 +95,73 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-8">
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.IMAP.TITLE')"
|
||||
:sub-title="$t('INBOX_MGMT.IMAP.SUBTITLE')"
|
||||
:note="$t('INBOX_MGMT.IMAP.NOTE_TEXT')"
|
||||
>
|
||||
<form @submit.prevent="updateInbox">
|
||||
<label for="toggle-imap-enable">
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.IMAP.TITLE')"
|
||||
:help-text="$t('INBOX_MGMT.IMAP.NOTE_TEXT')"
|
||||
class="[&>div]:!items-start [&>div>label]:mt-1 mb-4"
|
||||
>
|
||||
<form @submit.prevent="updateInbox">
|
||||
<label for="toggle-imap-enable">
|
||||
<input
|
||||
v-model="isIMAPEnabled"
|
||||
type="checkbox"
|
||||
class="ltr:mr-1 rtl:ml-1"
|
||||
name="toggle-imap-enable"
|
||||
/>
|
||||
{{ $t('INBOX_MGMT.IMAP.TOGGLE_AVAILABILITY') }}
|
||||
</label>
|
||||
<p>{{ $t('INBOX_MGMT.IMAP.TOGGLE_HELP') }}</p>
|
||||
<div v-if="isIMAPEnabled" class="mb-6">
|
||||
<woot-input
|
||||
v-model="address"
|
||||
:class="{ error: v$.address.$error }"
|
||||
class="w-full"
|
||||
:label="$t('INBOX_MGMT.IMAP.ADDRESS.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.IMAP.ADDRESS.PLACE_HOLDER')"
|
||||
@blur="v$.address.$touch"
|
||||
/>
|
||||
<woot-input
|
||||
v-model="port"
|
||||
type="number"
|
||||
:class="{ error: v$.port.$error }"
|
||||
class="w-full"
|
||||
:label="$t('INBOX_MGMT.IMAP.PORT.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.IMAP.PORT.PLACE_HOLDER')"
|
||||
@blur="v$.port.$touch"
|
||||
/>
|
||||
<woot-input
|
||||
v-model="login"
|
||||
:class="{ error: v$.login.$error }"
|
||||
class="w-full"
|
||||
:label="$t('INBOX_MGMT.IMAP.LOGIN.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.IMAP.LOGIN.PLACE_HOLDER')"
|
||||
@blur="v$.login.$touch"
|
||||
/>
|
||||
<woot-input
|
||||
v-model="password"
|
||||
:class="{ error: v$.password.$error }"
|
||||
class="w-full"
|
||||
:label="$t('INBOX_MGMT.IMAP.PASSWORD.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.IMAP.PASSWORD.PLACE_HOLDER')"
|
||||
type="password"
|
||||
@blur="v$.password.$touch"
|
||||
/>
|
||||
<label for="toggle-enable-ssl">
|
||||
<input
|
||||
v-model="isIMAPEnabled"
|
||||
v-model="isSSLEnabled"
|
||||
type="checkbox"
|
||||
class="ltr:mr-2 rtl:ml-2"
|
||||
name="toggle-imap-enable"
|
||||
name="toggle-enable-ssl"
|
||||
/>
|
||||
{{ $t('INBOX_MGMT.IMAP.TOGGLE_AVAILABILITY') }}
|
||||
{{ $t('INBOX_MGMT.IMAP.ENABLE_SSL') }}
|
||||
</label>
|
||||
<p>{{ $t('INBOX_MGMT.IMAP.TOGGLE_HELP') }}</p>
|
||||
<div v-if="isIMAPEnabled" class="mb-6">
|
||||
<woot-input
|
||||
v-model="address"
|
||||
:class="{ error: v$.address.$error }"
|
||||
class="max-w-[75%] w-full"
|
||||
:label="$t('INBOX_MGMT.IMAP.ADDRESS.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.IMAP.ADDRESS.PLACE_HOLDER')"
|
||||
@blur="v$.address.$touch"
|
||||
/>
|
||||
<woot-input
|
||||
v-model="port"
|
||||
type="number"
|
||||
:class="{ error: v$.port.$error }"
|
||||
class="max-w-[75%] w-full"
|
||||
:label="$t('INBOX_MGMT.IMAP.PORT.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.IMAP.PORT.PLACE_HOLDER')"
|
||||
@blur="v$.port.$touch"
|
||||
/>
|
||||
<woot-input
|
||||
v-model="login"
|
||||
:class="{ error: v$.login.$error }"
|
||||
class="max-w-[75%] w-full"
|
||||
:label="$t('INBOX_MGMT.IMAP.LOGIN.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.IMAP.LOGIN.PLACE_HOLDER')"
|
||||
@blur="v$.login.$touch"
|
||||
/>
|
||||
<woot-input
|
||||
v-model="password"
|
||||
:class="{ error: v$.password.$error }"
|
||||
class="max-w-[75%] w-full"
|
||||
:label="$t('INBOX_MGMT.IMAP.PASSWORD.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.IMAP.PASSWORD.PLACE_HOLDER')"
|
||||
type="password"
|
||||
@blur="v$.password.$touch"
|
||||
/>
|
||||
<label for="toggle-enable-ssl">
|
||||
<input
|
||||
v-model="isSSLEnabled"
|
||||
type="checkbox"
|
||||
class="ltr:mr-2 rtl:ml-2"
|
||||
name="toggle-enable-ssl"
|
||||
/>
|
||||
{{ $t('INBOX_MGMT.IMAP.ENABLE_SSL') }}
|
||||
</label>
|
||||
</div>
|
||||
<NextButton
|
||||
type="submit"
|
||||
:label="$t('INBOX_MGMT.IMAP.UPDATE')"
|
||||
:is-loading="uiFlags.isUpdatingIMAP"
|
||||
:disabled="(v$.$invalid && isIMAPEnabled) || uiFlags.isUpdatingIMAP"
|
||||
/>
|
||||
</form>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
</div>
|
||||
<NextButton
|
||||
type="submit"
|
||||
:label="$t('INBOX_MGMT.IMAP.UPDATE')"
|
||||
:is-loading="uiFlags.isUpdatingIMAP"
|
||||
:disabled="(v$.$invalid && isIMAPEnabled) || uiFlags.isUpdatingIMAP"
|
||||
/>
|
||||
</form>
|
||||
</SettingsFieldSection>
|
||||
</template>
|
||||
|
||||
@@ -59,17 +59,17 @@ const items = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-2 flex flex-col gap-6 mb-8">
|
||||
<div class="mx-auto flex flex-col gap-6 mb-8 max-w-7xl w-full !px-6">
|
||||
<PageHeader class="block lg:hidden !mb-0" :header-title="pageTitle" />
|
||||
<div
|
||||
class="grid grid-cols-1 lg:grid-cols-8 lg:divide-x lg:divide-n-weak rounded-xl border border-n-weak min-h-[52rem]"
|
||||
class="grid grid-cols-1 lg:grid-cols-8 lg:divide-x lg:divide-n-weak rounded-xl border border-n-weak h-full min-h-[50dvh]"
|
||||
>
|
||||
<woot-wizard
|
||||
class="hidden lg:block col-span-2 h-fit py-8 px-6"
|
||||
:global-config="globalConfig"
|
||||
:items="items"
|
||||
/>
|
||||
<div class="col-span-6 overflow-hidden">
|
||||
<div class="col-span-6 flex flex-col overflow-y-auto">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,87 +88,94 @@ 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 v-if="inboxesList?.length" #count>
|
||||
<span class="text-body-main text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.COUNT', { n: inboxesList.length }) }}
|
||||
</span>
|
||||
</template>
|
||||
<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')" size="sm" />
|
||||
</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-12 p-2 ring ring-n-solid-1 border border-n-strong shadow-sm"
|
||||
>
|
||||
<Avatar
|
||||
:src="inbox.avatar_url"
|
||||
:name="inbox.name"
|
||||
:size="30"
|
||||
rounded-full
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="size-12 flex justify-center items-center bg-n-alpha-3 rounded-full p-2 ring ring-n-solid-1 border border-n-strong shadow-sm"
|
||||
>
|
||||
<ChannelIcon class="size-5 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 border-t border-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
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
@click="openDelete(inbox)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<woot-confirm-delete-modal
|
||||
|
||||
+50
-67
@@ -1,38 +1,38 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import Draggable from 'vuedraggable';
|
||||
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
export default {
|
||||
components: { Draggable, ToggleSwitch },
|
||||
props: {
|
||||
preChatFields: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
emits: ['update', 'dragEnd'],
|
||||
data() {
|
||||
return {
|
||||
preChatFieldOptions: this.preChatFields,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
preChatFields() {
|
||||
this.preChatFieldOptions = this.preChatFields;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
isFieldEditable(item) {
|
||||
return !item.enabled;
|
||||
},
|
||||
handlePreChatFieldOptions(event, type, item) {
|
||||
this.$emit('update', event, type, item);
|
||||
},
|
||||
onDragEnd() {
|
||||
this.$emit('dragEnd', this.preChatFieldOptions);
|
||||
},
|
||||
const props = defineProps({
|
||||
preChatFields: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update', 'dragEnd']);
|
||||
|
||||
const preChatFieldOptions = ref(props.preChatFields);
|
||||
|
||||
const isFieldEditable = item => {
|
||||
return !item.enabled;
|
||||
};
|
||||
|
||||
const handlePreChatFieldOptions = (event, type, item) => {
|
||||
emit('update', event, type, item);
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
emit('dragEnd', preChatFieldOptions.value);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.preChatFields,
|
||||
newFields => {
|
||||
preChatFieldOptions.value = newFields;
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -43,81 +43,64 @@ export default {
|
||||
@end="onDragEnd"
|
||||
>
|
||||
<template #item="{ element: item }">
|
||||
<tr class="border-b border-n-weak">
|
||||
<td class="pre-chat-field"><fluent-icon icon="drag" /></td>
|
||||
<td class="pre-chat-field">
|
||||
<tr>
|
||||
<td class="py-4 ltr:pl-4 ltr:pr-3 rtl:pl-3 rtl:pr-4 text-body-main">
|
||||
<Icon
|
||||
icon="i-woot-drag-indicator"
|
||||
class="size-4 text-n-slate-11 mt-1 cursor-move"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-4 ltr:pr-3 rtl:pl-3 text-body-main">
|
||||
<ToggleSwitch
|
||||
:model-value="item['enabled']"
|
||||
@change="handlePreChatFieldOptions($event, 'enabled', item)"
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
class="pre-chat-field"
|
||||
:class="{ 'disabled-text': !item['enabled'] }"
|
||||
class="py-4 ltr:pr-3 rtl:pl-3 text-body-main"
|
||||
:class="{ 'text-n-slate-11': !item['enabled'] }"
|
||||
>
|
||||
{{ item.name }}
|
||||
</td>
|
||||
<td
|
||||
class="pre-chat-field"
|
||||
:class="{ 'disabled-text': !item['enabled'] }"
|
||||
class="py-4 ltr:pr-3 rtl:pl-3 text-body-main"
|
||||
:class="{ 'text-n-slate-11': !item['enabled'] }"
|
||||
>
|
||||
{{ item.type }}
|
||||
</td>
|
||||
<td class="pre-chat-field">
|
||||
<td class="py-4 ltr:pr-3 rtl:pl-3 text-body-main">
|
||||
<input
|
||||
v-model="item['required']"
|
||||
type="checkbox"
|
||||
:value="`${item.name}-required`"
|
||||
:disabled="!item['enabled']"
|
||||
class="m-0"
|
||||
@click="handlePreChatFieldOptions($event, 'required', item)"
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
class="pre-chat-field"
|
||||
:class="{ 'disabled-text': !item['enabled'] }"
|
||||
class="py-4 ltr:pr-3 rtl:pl-3 text-body-main"
|
||||
:class="{ 'text-n-slate-11': !item['enabled'] }"
|
||||
>
|
||||
<input
|
||||
v-model="item.label"
|
||||
type="text"
|
||||
:disabled="isFieldEditable(item)"
|
||||
class="w-full text-sm !mb-0 px-2 py-1 border border-n-weak rounded"
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
class="pre-chat-field"
|
||||
:class="{ 'disabled-text': !item['enabled'] }"
|
||||
class="py-4 ltr:pr-4 rtl:pl-4 text-body-main"
|
||||
:class="{ 'text-n-slate-11': !item['enabled'] }"
|
||||
>
|
||||
<input
|
||||
v-model="item.placeholder"
|
||||
type="text"
|
||||
:disabled="isFieldEditable(item)"
|
||||
class="w-full text-sm !mb-0 px-2 py-1 border border-n-weak rounded"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</Draggable>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pre-chat-field {
|
||||
@apply py-4 px-2 text-n-slate-12;
|
||||
|
||||
svg {
|
||||
@apply flex items-center;
|
||||
}
|
||||
}
|
||||
.disabled-text {
|
||||
@apply text-n-slate-11;
|
||||
}
|
||||
|
||||
table {
|
||||
thead th {
|
||||
@apply normal-case;
|
||||
}
|
||||
input {
|
||||
@apply text-sm mb-0;
|
||||
}
|
||||
}
|
||||
checkbox {
|
||||
@apply m-0;
|
||||
}
|
||||
</style>
|
||||
|
||||
+120
-144
@@ -1,159 +1,135 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import PreChatFields from './PreChatFields.vue';
|
||||
import { getPreChatFields, standardFieldKeys } from 'dashboard/helper/preChat';
|
||||
import { getPreChatFields } from 'dashboard/helper/preChat';
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
PreChatFields,
|
||||
WootMessageEditor,
|
||||
NextButton,
|
||||
const props = defineProps({
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
props: {
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
preChatFormEnabled: false,
|
||||
preChatMessage: '',
|
||||
preChatFields: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
uiFlags: 'inboxes/getUIFlags',
|
||||
customAttributes: 'attributes/getAttributes',
|
||||
}),
|
||||
preChatFieldOptions() {
|
||||
const { pre_chat_form_options: preChatFormOptions } = this.inbox;
|
||||
return getPreChatFields({
|
||||
preChatFormOptions,
|
||||
customAttributes: this.customAttributes,
|
||||
});
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
inbox() {
|
||||
this.setDefaults();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setDefaults();
|
||||
},
|
||||
methods: {
|
||||
setDefaults() {
|
||||
const { pre_chat_form_enabled: preChatFormEnabled } = this.inbox;
|
||||
this.preChatFormEnabled = preChatFormEnabled;
|
||||
const {
|
||||
pre_chat_message: preChatMessage,
|
||||
pre_chat_fields: preChatFields,
|
||||
} = this.preChatFieldOptions || {};
|
||||
this.preChatMessage = preChatMessage;
|
||||
this.preChatFields = preChatFields;
|
||||
},
|
||||
isFieldEditable(item) {
|
||||
return !!standardFieldKeys[item.name] || !item.enabled;
|
||||
},
|
||||
handlePreChatFieldOptions(event, type, item) {
|
||||
this.preChatFields.forEach((field, index) => {
|
||||
if (field.name === item.name) {
|
||||
this.preChatFields[index][type] = !item[type];
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
changePreChatFieldFieldsOrder(updatedPreChatFieldOptions) {
|
||||
this.preChatFields = updatedPreChatFieldOptions;
|
||||
},
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
async updateInbox() {
|
||||
try {
|
||||
const payload = {
|
||||
id: this.inbox.id,
|
||||
formData: false,
|
||||
channel: {
|
||||
pre_chat_form_enabled: this.preChatFormEnabled,
|
||||
pre_chat_form_options: {
|
||||
pre_chat_message: this.preChatMessage,
|
||||
pre_chat_fields: this.preChatFields,
|
||||
},
|
||||
},
|
||||
};
|
||||
await this.$store.dispatch('inboxes/updateInbox', payload);
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
}
|
||||
},
|
||||
},
|
||||
const uiFlags = useMapGetter('inboxes/getUIFlags');
|
||||
const customAttributes = useMapGetter('attributes/getAttributes');
|
||||
|
||||
const preChatFormEnabled = ref(false);
|
||||
const preChatMessage = ref('');
|
||||
const preChatFields = ref([]);
|
||||
|
||||
const preChatFieldOptions = computed(() => {
|
||||
const { pre_chat_form_options: preChatFormOptions } = props.inbox;
|
||||
return getPreChatFields({
|
||||
preChatFormOptions,
|
||||
customAttributes: customAttributes.value,
|
||||
});
|
||||
});
|
||||
|
||||
const tableHeaders = computed(() => [
|
||||
'',
|
||||
'',
|
||||
t('INBOX_MGMT.PRE_CHAT_FORM.SET_FIELDS_HEADER.KEY'),
|
||||
t('INBOX_MGMT.PRE_CHAT_FORM.SET_FIELDS_HEADER.TYPE'),
|
||||
t('INBOX_MGMT.PRE_CHAT_FORM.SET_FIELDS_HEADER.REQUIRED'),
|
||||
t('INBOX_MGMT.PRE_CHAT_FORM.SET_FIELDS_HEADER.LABEL'),
|
||||
t('INBOX_MGMT.PRE_CHAT_FORM.SET_FIELDS_HEADER.PLACE_HOLDER'),
|
||||
]);
|
||||
|
||||
const setDefaults = () => {
|
||||
const { pre_chat_form_enabled: formEnabled } = props.inbox;
|
||||
preChatFormEnabled.value = formEnabled;
|
||||
const { pre_chat_message: message, pre_chat_fields: fields } =
|
||||
preChatFieldOptions.value || {};
|
||||
preChatMessage.value = message;
|
||||
preChatFields.value = fields;
|
||||
};
|
||||
|
||||
const handlePreChatFieldOptions = (event, type, item) => {
|
||||
preChatFields.value.forEach((field, index) => {
|
||||
if (field.name === item.name) {
|
||||
preChatFields.value[index][type] = !item[type];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const changePreChatFieldFieldsOrder = updatedPreChatFieldOptions => {
|
||||
preChatFields.value = updatedPreChatFieldOptions;
|
||||
};
|
||||
|
||||
const updateInbox = async () => {
|
||||
try {
|
||||
const payload = {
|
||||
id: props.inbox.id,
|
||||
formData: false,
|
||||
channel: {
|
||||
pre_chat_form_enabled: preChatFormEnabled.value,
|
||||
pre_chat_form_options: {
|
||||
pre_chat_message: preChatMessage.value,
|
||||
pre_chat_fields: preChatFields.value,
|
||||
},
|
||||
},
|
||||
};
|
||||
await store.dispatch('inboxes/updateInbox', payload);
|
||||
useAlert(t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => props.inbox, setDefaults);
|
||||
|
||||
onMounted(() => {
|
||||
setDefaults();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-8 my-2 text-base">
|
||||
<div class="mx-0 mt-6 mb-3">
|
||||
{{ $t('INBOX_MGMT.PRE_CHAT_FORM.DESCRIPTION') }}
|
||||
<div class="mx-6">
|
||||
<SettingsToggleSection
|
||||
v-model="preChatFormEnabled"
|
||||
:header="$t('INBOX_MGMT.PRE_CHAT_FORM.ENABLE.LABEL')"
|
||||
:description="$t('INBOX_MGMT.PRE_CHAT_FORM.DESCRIPTION')"
|
||||
>
|
||||
<template v-if="preChatFormEnabled" #editor>
|
||||
<WootMessageEditor
|
||||
v-model="preChatMessage"
|
||||
:placeholder="
|
||||
$t('INBOX_MGMT.PRE_CHAT_FORM.PRE_CHAT_MESSAGE.PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</SettingsToggleSection>
|
||||
<div v-if="preChatFormEnabled" class="flex items-center my-8 py-1">
|
||||
<div class="flex-1 h-px bg-n-weak" />
|
||||
<span class="text-body-main text-n-slate-11 px-2">
|
||||
{{ $t('INBOX_MGMT.PRE_CHAT_FORM.SET_FIELDS') }}
|
||||
</span>
|
||||
<div class="flex-1 h-px bg-n-weak" />
|
||||
</div>
|
||||
<form class="flex flex-col" @submit.prevent="updateInbox">
|
||||
<label class="w-1/4">
|
||||
{{ $t('INBOX_MGMT.PRE_CHAT_FORM.ENABLE.LABEL') }}
|
||||
<select v-model="preChatFormEnabled">
|
||||
<option :value="true">
|
||||
{{ $t('INBOX_MGMT.PRE_CHAT_FORM.ENABLE.OPTIONS.ENABLED') }}
|
||||
</option>
|
||||
<option :value="false">
|
||||
{{ $t('INBOX_MGMT.PRE_CHAT_FORM.ENABLE.OPTIONS.DISABLED') }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<div v-if="preChatFormEnabled">
|
||||
<div>
|
||||
<label>
|
||||
{{ $t('INBOX_MGMT.PRE_CHAT_FORM.PRE_CHAT_MESSAGE.LABEL') }}
|
||||
</label>
|
||||
<WootMessageEditor
|
||||
v-model="preChatMessage"
|
||||
class="message-editor"
|
||||
:placeholder="
|
||||
$t('INBOX_MGMT.PRE_CHAT_FORM.PRE_CHAT_MESSAGE.PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<label>{{ $t('INBOX_MGMT.PRE_CHAT_FORM.SET_FIELDS') }}</label>
|
||||
<table class="table w-full table-striped mt-4">
|
||||
<thead class="thead-dark">
|
||||
<tr
|
||||
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 scope="col" />
|
||||
<th scope="col" />
|
||||
<th scope="col">
|
||||
{{ $t('INBOX_MGMT.PRE_CHAT_FORM.SET_FIELDS_HEADER.KEY') }}
|
||||
</th>
|
||||
<th scope="col">
|
||||
{{ $t('INBOX_MGMT.PRE_CHAT_FORM.SET_FIELDS_HEADER.TYPE') }}
|
||||
</th>
|
||||
<th scope="col">
|
||||
{{
|
||||
$t('INBOX_MGMT.PRE_CHAT_FORM.SET_FIELDS_HEADER.REQUIRED')
|
||||
}}
|
||||
</th>
|
||||
<th scope="col">
|
||||
{{ $t('INBOX_MGMT.PRE_CHAT_FORM.SET_FIELDS_HEADER.LABEL') }}
|
||||
</th>
|
||||
<th scope="col">
|
||||
{{
|
||||
$t(
|
||||
'INBOX_MGMT.PRE_CHAT_FORM.SET_FIELDS_HEADER.PLACE_HOLDER'
|
||||
)
|
||||
}}
|
||||
<div class="w-full">
|
||||
<table
|
||||
class="min-w-full table-auto outline outline-1 -outline-offset-1 outline-n-weak rounded-xl"
|
||||
>
|
||||
<thead>
|
||||
<tr class="border-b border-n-weak">
|
||||
<th
|
||||
v-for="(header, index) in tableHeaders"
|
||||
:key="index"
|
||||
class="py-3 ltr:pr-4 rtl:pl-4 text-start text-heading-3 text-n-slate-12"
|
||||
>
|
||||
{{ header }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -165,8 +141,8 @@ export default {
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-auto my-4">
|
||||
<NextButton
|
||||
<div class="w-full flex justify-end items-center py-4 mt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE_PRE_CHAT_FORM_SETTINGS')"
|
||||
:is-loading="uiFlags.isUpdating"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import SettingsSection from 'dashboard/components/SettingsSection.vue';
|
||||
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength } from '@vuelidate/validators';
|
||||
import InputRadioGroup from './components/InputRadioGroup.vue';
|
||||
@@ -10,7 +10,7 @@ import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SettingsSection,
|
||||
SettingsFieldSection,
|
||||
InputRadioGroup,
|
||||
SingleSelectDropdown,
|
||||
NextButton,
|
||||
@@ -147,7 +147,9 @@ export default {
|
||||
await this.$store.dispatch('inboxes/updateInboxSMTP', payload);
|
||||
useAlert(this.$t('INBOX_MGMT.SMTP.EDIT.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('INBOX_MGMT.SMTP.EDIT.ERROR_MESSAGE'));
|
||||
useAlert(
|
||||
error.message || this.$t('INBOX_MGMT.SMTP.EDIT.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -155,91 +157,91 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-8">
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.SMTP.TITLE')"
|
||||
:sub-title="$t('INBOX_MGMT.SMTP.SUBTITLE')"
|
||||
>
|
||||
<form @submit.prevent="updateInbox">
|
||||
<label for="toggle-enable-smtp">
|
||||
<input
|
||||
v-model="isSMTPEnabled"
|
||||
type="checkbox"
|
||||
name="toggle-enable-smtp"
|
||||
/>
|
||||
{{ $t('INBOX_MGMT.SMTP.TOGGLE_AVAILABILITY') }}
|
||||
</label>
|
||||
<p>{{ $t('INBOX_MGMT.SMTP.TOGGLE_HELP') }}</p>
|
||||
<div v-if="isSMTPEnabled" class="mb-6">
|
||||
<woot-input
|
||||
v-model="address"
|
||||
:class="{ error: v$.address.$error }"
|
||||
class="max-w-[75%] w-full"
|
||||
:label="$t('INBOX_MGMT.SMTP.ADDRESS.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.SMTP.ADDRESS.PLACE_HOLDER')"
|
||||
@blur="v$.address.$touch"
|
||||
/>
|
||||
<woot-input
|
||||
v-model="port"
|
||||
type="number"
|
||||
:class="{ error: v$.port.$error }"
|
||||
class="max-w-[75%] w-full"
|
||||
:label="$t('INBOX_MGMT.SMTP.PORT.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.SMTP.PORT.PLACE_HOLDER')"
|
||||
@blur="v$.port.$touch"
|
||||
/>
|
||||
<woot-input
|
||||
v-model="login"
|
||||
:class="{ error: v$.login.$error }"
|
||||
class="max-w-[75%] w-full"
|
||||
:label="$t('INBOX_MGMT.SMTP.LOGIN.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.SMTP.LOGIN.PLACE_HOLDER')"
|
||||
@blur="v$.login.$touch"
|
||||
/>
|
||||
<woot-input
|
||||
v-model="password"
|
||||
:class="{ error: v$.password.$error }"
|
||||
class="max-w-[75%] w-full"
|
||||
:label="$t('INBOX_MGMT.SMTP.PASSWORD.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.SMTP.PASSWORD.PLACE_HOLDER')"
|
||||
type="password"
|
||||
@blur="v$.password.$touch"
|
||||
/>
|
||||
<woot-input
|
||||
v-model="domain"
|
||||
:class="{ error: v$.domain.$error }"
|
||||
class="max-w-[75%] w-full"
|
||||
:label="$t('INBOX_MGMT.SMTP.DOMAIN.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.SMTP.DOMAIN.PLACE_HOLDER')"
|
||||
@blur="v$.domain.$touch"
|
||||
/>
|
||||
<InputRadioGroup
|
||||
:label="$t('INBOX_MGMT.SMTP.ENCRYPTION')"
|
||||
:items="encryptionProtocols"
|
||||
:action="handleEncryptionChange"
|
||||
/>
|
||||
<SingleSelectDropdown
|
||||
class="max-w-[75%] w-full"
|
||||
:label="$t('INBOX_MGMT.SMTP.OPEN_SSL_VERIFY_MODE')"
|
||||
:selected="openSSLVerifyMode"
|
||||
:options="openSSLVerifyModes"
|
||||
:action="handleSSLModeChange"
|
||||
/>
|
||||
<SingleSelectDropdown
|
||||
class="max-w-[75%] w-full"
|
||||
:label="$t('INBOX_MGMT.SMTP.AUTH_MECHANISM')"
|
||||
:selected="authMechanism"
|
||||
:options="authMechanisms"
|
||||
:action="handleAuthMechanismChange"
|
||||
/>
|
||||
</div>
|
||||
<NextButton
|
||||
type="submit"
|
||||
:label="$t('INBOX_MGMT.SMTP.UPDATE')"
|
||||
:is-loading="uiFlags.isUpdatingSMTP"
|
||||
:disabled="(v$.$invalid && isSMTPEnabled) || uiFlags.isUpdatingSMTP"
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.SMTP.TITLE')"
|
||||
:help-text="$t('INBOX_MGMT.SMTP.SUBTITLE')"
|
||||
class="[&>div]:!items-start [&>div>label]:mt-1 mb-4"
|
||||
>
|
||||
<form @submit.prevent="updateInbox">
|
||||
<label for="toggle-enable-smtp">
|
||||
<input
|
||||
v-model="isSMTPEnabled"
|
||||
type="checkbox"
|
||||
name="toggle-enable-smtp"
|
||||
class="ltr:mr-1 rtl:ml-1"
|
||||
/>
|
||||
</form>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
{{ $t('INBOX_MGMT.SMTP.TOGGLE_AVAILABILITY') }}
|
||||
</label>
|
||||
<p>{{ $t('INBOX_MGMT.SMTP.TOGGLE_HELP') }}</p>
|
||||
<div v-if="isSMTPEnabled" class="mb-6">
|
||||
<woot-input
|
||||
v-model="address"
|
||||
:class="{ error: v$.address.$error }"
|
||||
class="w-full"
|
||||
:label="$t('INBOX_MGMT.SMTP.ADDRESS.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.SMTP.ADDRESS.PLACE_HOLDER')"
|
||||
@blur="v$.address.$touch"
|
||||
/>
|
||||
<woot-input
|
||||
v-model="port"
|
||||
type="number"
|
||||
:class="{ error: v$.port.$error }"
|
||||
class="w-full"
|
||||
:label="$t('INBOX_MGMT.SMTP.PORT.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.SMTP.PORT.PLACE_HOLDER')"
|
||||
@blur="v$.port.$touch"
|
||||
/>
|
||||
<woot-input
|
||||
v-model="login"
|
||||
:class="{ error: v$.login.$error }"
|
||||
class="w-full"
|
||||
:label="$t('INBOX_MGMT.SMTP.LOGIN.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.SMTP.LOGIN.PLACE_HOLDER')"
|
||||
@blur="v$.login.$touch"
|
||||
/>
|
||||
<woot-input
|
||||
v-model="password"
|
||||
:class="{ error: v$.password.$error }"
|
||||
class="w-full"
|
||||
:label="$t('INBOX_MGMT.SMTP.PASSWORD.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.SMTP.PASSWORD.PLACE_HOLDER')"
|
||||
type="password"
|
||||
@blur="v$.password.$touch"
|
||||
/>
|
||||
<woot-input
|
||||
v-model="domain"
|
||||
:class="{ error: v$.domain.$error }"
|
||||
class="w-full"
|
||||
:label="$t('INBOX_MGMT.SMTP.DOMAIN.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.SMTP.DOMAIN.PLACE_HOLDER')"
|
||||
@blur="v$.domain.$touch"
|
||||
/>
|
||||
<InputRadioGroup
|
||||
:label="$t('INBOX_MGMT.SMTP.ENCRYPTION')"
|
||||
:items="encryptionProtocols"
|
||||
:action="handleEncryptionChange"
|
||||
/>
|
||||
<SingleSelectDropdown
|
||||
class="w-full"
|
||||
:label="$t('INBOX_MGMT.SMTP.OPEN_SSL_VERIFY_MODE')"
|
||||
:selected="openSSLVerifyMode"
|
||||
:options="openSSLVerifyModes"
|
||||
:action="handleSSLModeChange"
|
||||
/>
|
||||
<SingleSelectDropdown
|
||||
class="w-full"
|
||||
:label="$t('INBOX_MGMT.SMTP.AUTH_MECHANISM')"
|
||||
:selected="authMechanism"
|
||||
:options="authMechanisms"
|
||||
:action="handleAuthMechanismChange"
|
||||
/>
|
||||
</div>
|
||||
<NextButton
|
||||
type="submit"
|
||||
:label="$t('INBOX_MGMT.SMTP.UPDATE')"
|
||||
:is-loading="uiFlags.isUpdatingSMTP"
|
||||
:disabled="(v$.$invalid && isSMTPEnabled) || uiFlags.isUpdatingSMTP"
|
||||
/>
|
||||
</form>
|
||||
</SettingsFieldSection>
|
||||
</template>
|
||||
|
||||
@@ -1,443 +0,0 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Widget from 'dashboard/modules/widget-preview/components/Widget.vue';
|
||||
import InputRadioGroup from './components/InputRadioGroup.vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Widget,
|
||||
InputRadioGroup,
|
||||
NextButton,
|
||||
Editor,
|
||||
Avatar,
|
||||
},
|
||||
props: {
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
return { v$: useVuelidate() };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isWidgetPreview: true,
|
||||
color: '#1f93ff',
|
||||
websiteName: '',
|
||||
welcomeHeading: '',
|
||||
welcomeTagline: '',
|
||||
replyTime: 'in_a_few_minutes',
|
||||
avatarFile: null,
|
||||
avatarUrl: '',
|
||||
widgetBubblePosition: 'right',
|
||||
widgetBubbleLauncherTitle: this.$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_LAUNCHER_TITLE.DEFAULT'
|
||||
),
|
||||
widgetBubbleType: 'standard',
|
||||
widgetBubblePositions: [
|
||||
{
|
||||
id: 'left',
|
||||
title: this.$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_POSITION.LEFT'
|
||||
),
|
||||
checked: false,
|
||||
},
|
||||
{
|
||||
id: 'right',
|
||||
title: this.$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_POSITION.RIGHT'
|
||||
),
|
||||
checked: true,
|
||||
},
|
||||
],
|
||||
widgetBubbleTypes: [
|
||||
{
|
||||
id: 'standard',
|
||||
title: this.$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_TYPE.STANDARD'
|
||||
),
|
||||
checked: true,
|
||||
},
|
||||
{
|
||||
id: 'expanded_bubble',
|
||||
title: this.$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_TYPE.EXPANDED_BUBBLE'
|
||||
),
|
||||
checked: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
uiFlags: 'inboxes/getUIFlags',
|
||||
}),
|
||||
storageKey() {
|
||||
return `${LOCAL_STORAGE_KEYS.WIDGET_BUILDER}${this.inbox.id}`;
|
||||
},
|
||||
widgetScript() {
|
||||
let options = {
|
||||
position: this.widgetBubblePosition,
|
||||
type: this.widgetBubbleType,
|
||||
launcherTitle: this.widgetBubbleLauncherTitle,
|
||||
};
|
||||
let script = this.inbox.web_widget_script;
|
||||
return (
|
||||
script.substring(0, 13) +
|
||||
this.$t('INBOX_MGMT.WIDGET_BUILDER.SCRIPT_SETTINGS', {
|
||||
options: JSON.stringify(options),
|
||||
}) +
|
||||
script.substring(13)
|
||||
);
|
||||
},
|
||||
getWidgetViewOptions() {
|
||||
return [
|
||||
{
|
||||
id: 'preview',
|
||||
title: this.$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_VIEW_OPTION.PREVIEW'
|
||||
),
|
||||
checked: true,
|
||||
},
|
||||
{
|
||||
id: 'script',
|
||||
title: this.$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_VIEW_OPTION.SCRIPT'
|
||||
),
|
||||
checked: false,
|
||||
},
|
||||
];
|
||||
},
|
||||
getReplyTimeOptions() {
|
||||
return [
|
||||
{
|
||||
key: 'in_a_few_minutes',
|
||||
value: 'in_a_few_minutes',
|
||||
text: this.$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.REPLY_TIME.IN_A_FEW_MINUTES'
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'in_a_few_hours',
|
||||
value: 'in_a_few_hours',
|
||||
text: this.$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.REPLY_TIME.IN_A_FEW_HOURS'
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'in_a_day',
|
||||
value: 'in_a_day',
|
||||
text: this.$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.REPLY_TIME.IN_A_DAY'
|
||||
),
|
||||
},
|
||||
];
|
||||
},
|
||||
websiteNameValidationErrorMsg() {
|
||||
return this.v$.websiteName.$error
|
||||
? this.$t('INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WEBSITE_NAME.ERROR')
|
||||
: '';
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setDefaults();
|
||||
},
|
||||
validations: {
|
||||
websiteName: { required },
|
||||
},
|
||||
methods: {
|
||||
setDefaults() {
|
||||
// Widget Settings
|
||||
const {
|
||||
name,
|
||||
welcome_title,
|
||||
welcome_tagline,
|
||||
widget_color,
|
||||
reply_time,
|
||||
avatar_url,
|
||||
} = this.inbox;
|
||||
this.websiteName = name;
|
||||
this.welcomeHeading = welcome_title;
|
||||
this.welcomeTagline = welcome_tagline;
|
||||
this.color = widget_color;
|
||||
this.replyTime = reply_time;
|
||||
this.avatarUrl = avatar_url;
|
||||
|
||||
const savedInformation = this.getSavedInboxInformation();
|
||||
if (savedInformation) {
|
||||
this.widgetBubblePositions = this.widgetBubblePositions.map(item => {
|
||||
if (item.id === savedInformation.position) {
|
||||
item.checked = true;
|
||||
this.widgetBubblePosition = item.id;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
this.widgetBubbleTypes = this.widgetBubbleTypes.map(item => {
|
||||
if (item.id === savedInformation.type) {
|
||||
item.checked = true;
|
||||
this.widgetBubbleType = item.id;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
this.widgetBubbleLauncherTitle =
|
||||
savedInformation.launcherTitle || 'Chat with us';
|
||||
}
|
||||
},
|
||||
handleWidgetBubblePositionChange(item) {
|
||||
this.widgetBubblePosition = item.id;
|
||||
},
|
||||
handleWidgetBubbleTypeChange(item) {
|
||||
this.widgetBubbleType = item.id;
|
||||
},
|
||||
handleWidgetViewChange(item) {
|
||||
this.isWidgetPreview = item.id === 'preview';
|
||||
},
|
||||
handleImageUpload({ file, url }) {
|
||||
this.avatarFile = file;
|
||||
this.avatarUrl = url;
|
||||
},
|
||||
async handleAvatarDelete() {
|
||||
try {
|
||||
await this.$store.dispatch('inboxes/deleteInboxAvatar', this.inbox.id);
|
||||
this.avatarFile = null;
|
||||
this.avatarUrl = '';
|
||||
useAlert(
|
||||
this.$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.AVATAR.DELETE.API.SUCCESS_MESSAGE'
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error.message
|
||||
? error.message
|
||||
: this.$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.AVATAR.DELETE.API.ERROR_MESSAGE'
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
async updateWidget() {
|
||||
const bubbleSettings = {
|
||||
position: this.widgetBubblePosition,
|
||||
launcherTitle: this.widgetBubbleLauncherTitle,
|
||||
type: this.widgetBubbleType,
|
||||
};
|
||||
|
||||
LocalStorage.set(this.storageKey, bubbleSettings);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
id: this.inbox.id,
|
||||
name: this.websiteName,
|
||||
channel: {
|
||||
widget_color: this.color,
|
||||
welcome_title: this.welcomeHeading,
|
||||
welcome_tagline: this.welcomeTagline,
|
||||
reply_time: this.replyTime,
|
||||
},
|
||||
};
|
||||
if (this.avatarFile) {
|
||||
payload.avatar = this.avatarFile;
|
||||
}
|
||||
await this.$store.dispatch('inboxes/updateInbox', payload);
|
||||
useAlert(
|
||||
this.$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.UPDATE.API.SUCCESS_MESSAGE'
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error.message ||
|
||||
this.$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.UPDATE.API.ERROR_MESSAGE'
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
getSavedInboxInformation() {
|
||||
return LocalStorage.get(this.storageKey);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-8">
|
||||
<div class="flex p-2.5">
|
||||
<div class="w-100 lg:w-[40%]">
|
||||
<div class="min-h-full py-4 overflow-y-scroll px-px">
|
||||
<form @submit.prevent="updateWidget">
|
||||
<div class="flex flex-col mb-4 items-start gap-1 w-full">
|
||||
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{
|
||||
$t('INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.AVATAR.LABEL')
|
||||
}}
|
||||
</label>
|
||||
<Avatar
|
||||
:src="avatarUrl"
|
||||
:size="72"
|
||||
icon-name="i-ri-global-fill"
|
||||
name=""
|
||||
allow-upload
|
||||
rounded-full
|
||||
@upload="handleImageUpload"
|
||||
@delete="handleAvatarDelete"
|
||||
/>
|
||||
</div>
|
||||
<woot-input
|
||||
v-model="websiteName"
|
||||
:class="{ error: v$.websiteName.$error }"
|
||||
:label="
|
||||
$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WEBSITE_NAME.LABEL'
|
||||
)
|
||||
"
|
||||
:placeholder="
|
||||
$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WEBSITE_NAME.PLACE_HOLDER'
|
||||
)
|
||||
"
|
||||
:error="websiteNameValidationErrorMsg"
|
||||
@blur="v$.websiteName.$touch"
|
||||
/>
|
||||
<woot-input
|
||||
v-model="welcomeHeading"
|
||||
:label="
|
||||
$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WELCOME_HEADING.LABEL'
|
||||
)
|
||||
"
|
||||
:placeholder="
|
||||
$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WELCOME_HEADING.PLACE_HOLDER'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<Editor
|
||||
v-model="welcomeTagline"
|
||||
:label="
|
||||
$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WELCOME_TAGLINE.LABEL'
|
||||
)
|
||||
"
|
||||
:placeholder="
|
||||
$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WELCOME_TAGLINE.PLACE_HOLDER'
|
||||
)
|
||||
"
|
||||
:max-length="255"
|
||||
channel-type="Context::InboxSettings"
|
||||
class="mb-4"
|
||||
/>
|
||||
<label>
|
||||
{{
|
||||
$t('INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.REPLY_TIME.LABEL')
|
||||
}}
|
||||
<select v-model="replyTime">
|
||||
<option
|
||||
v-for="option in getReplyTimeOptions"
|
||||
:key="option.key"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.text }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{{
|
||||
$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_COLOR_LABEL'
|
||||
)
|
||||
}}
|
||||
<woot-color-picker v-model="color" />
|
||||
</label>
|
||||
<InputRadioGroup
|
||||
name="widget-bubble-position"
|
||||
:label="
|
||||
$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_POSITION_LABEL'
|
||||
)
|
||||
"
|
||||
:items="widgetBubblePositions"
|
||||
:action="handleWidgetBubblePositionChange"
|
||||
/>
|
||||
<InputRadioGroup
|
||||
name="widget-bubble-type"
|
||||
:label="
|
||||
$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_TYPE_LABEL'
|
||||
)
|
||||
"
|
||||
:items="widgetBubbleTypes"
|
||||
:action="handleWidgetBubbleTypeChange"
|
||||
/>
|
||||
<woot-input
|
||||
v-model="widgetBubbleLauncherTitle"
|
||||
:label="
|
||||
$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_LAUNCHER_TITLE.LABEL'
|
||||
)
|
||||
"
|
||||
:placeholder="
|
||||
$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_LAUNCHER_TITLE.PLACE_HOLDER'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<NextButton
|
||||
type="submit"
|
||||
class="mt-4"
|
||||
:label="
|
||||
$t(
|
||||
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.UPDATE.BUTTON_TEXT'
|
||||
)
|
||||
"
|
||||
:is-loading="uiFlags.isUpdating"
|
||||
:disabled="v$.$invalid || uiFlags.isUpdating"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-100 lg:w-3/5">
|
||||
<InputRadioGroup
|
||||
name="widget-view-options"
|
||||
class="text-center"
|
||||
:items="getWidgetViewOptions"
|
||||
:action="handleWidgetViewChange"
|
||||
/>
|
||||
<div
|
||||
v-if="isWidgetPreview"
|
||||
class="flex flex-col items-center justify-end min-h-[40.625rem] mx-5 mb-5 p-2.5 bg-n-slate-3 rounded-lg"
|
||||
>
|
||||
<Widget
|
||||
:welcome-heading="welcomeHeading"
|
||||
:welcome-tagline="welcomeTagline"
|
||||
:website-name="websiteName"
|
||||
:logo="avatarUrl"
|
||||
is-online
|
||||
:reply-time="replyTime"
|
||||
:color="color"
|
||||
:widget-bubble-position="widgetBubblePosition"
|
||||
:widget-bubble-launcher-title="widgetBubbleLauncherTitle"
|
||||
:widget-bubble-type="widgetBubbleType"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="mx-5 p-2.5 bg-n-slate-3 rounded-lg dark:bg-n-solid-3"
|
||||
>
|
||||
<woot-code :script="widgetScript" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -12,6 +12,7 @@ import PageHeader from '../../SettingsSubPageHeader.vue';
|
||||
import router from '../../../../index';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
|
||||
import { loadScript } from 'dashboard/helper/DOMHelpers';
|
||||
import * as Sentry from '@sentry/vue';
|
||||
@@ -21,6 +22,7 @@ export default {
|
||||
LoadingState,
|
||||
PageHeader,
|
||||
NextButton,
|
||||
ComboBox,
|
||||
},
|
||||
setup() {
|
||||
const { accountId } = useAccount();
|
||||
@@ -67,6 +69,12 @@ export default {
|
||||
getSelectablePages() {
|
||||
return this.pageList.filter(item => !item.exists);
|
||||
},
|
||||
comboBoxPageOptions() {
|
||||
return this.getSelectablePages.map(({ id, name }) => ({
|
||||
value: id,
|
||||
label: name,
|
||||
}));
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
@@ -94,9 +102,16 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
setPageName({ name }) {
|
||||
setPageName(pageId) {
|
||||
const page = this.pageList.find(p => p.id === pageId);
|
||||
if (page) {
|
||||
this.selectedPage = page;
|
||||
this.pageName = page.name;
|
||||
} else {
|
||||
this.selectedPage = { name: null, id: null };
|
||||
this.pageName = '';
|
||||
}
|
||||
this.v$.selectedPage.$touch();
|
||||
this.pageName = name;
|
||||
},
|
||||
|
||||
initChannelAuth(channel) {
|
||||
@@ -245,23 +260,20 @@ export default {
|
||||
/>
|
||||
</div>
|
||||
<div class="w-3/5">
|
||||
<div class="w-full">
|
||||
<div class="w-full mb-2">
|
||||
<div class="input-wrap" :class="{ error: v$.selectedPage.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.FB.CHOOSE_PAGE') }}
|
||||
<multiselect
|
||||
v-model="selectedPage"
|
||||
close-on-select
|
||||
allow-empty
|
||||
:options="getSelectablePages"
|
||||
track-by="id"
|
||||
label="name"
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
|
||||
<span class="text-n-slate-12 text-start">
|
||||
{{ $t('INBOX_MGMT.ADD.FB.CHOOSE_PAGE') }}
|
||||
</span>
|
||||
<ComboBox
|
||||
:model-value="selectedPage.id"
|
||||
:options="comboBoxPageOptions"
|
||||
:placeholder="$t('INBOX_MGMT.ADD.FB.PICK_A_VALUE')"
|
||||
selected-label
|
||||
@select="setPageName"
|
||||
:has-error="v$.selectedPage.$error"
|
||||
class="[&>div>button]:!bg-n-alpha-black2 mt-1"
|
||||
@update:model-value="setPageName"
|
||||
/>
|
||||
<span v-if="v$.selectedPage.$error" class="message">
|
||||
<span v-if="v$.selectedPage.$error" class="message mt-0.5">
|
||||
{{ $t('INBOX_MGMT.ADD.FB.CHOOSE_PLACEHOLDER') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
+1
-4
@@ -44,8 +44,5 @@ async function requestAuthorization() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<InboxReconnectionRequired
|
||||
class="mx-8 mt-5"
|
||||
@reauthorize="requestAuthorization"
|
||||
/>
|
||||
<InboxReconnectionRequired class="mx-6" @reauthorize="requestAuthorization" />
|
||||
</template>
|
||||
|
||||
+1
-4
@@ -30,8 +30,5 @@ async function requestAuthorization() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<InboxReconnectionRequired
|
||||
class="mx-8 mt-5"
|
||||
@reauthorize="requestAuthorization"
|
||||
/>
|
||||
<InboxReconnectionRequired class="mx-6" @reauthorize="requestAuthorization" />
|
||||
</template>
|
||||
|
||||
+1
-4
@@ -37,8 +37,5 @@ async function requestAuthorization() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<InboxReconnectionRequired
|
||||
class="mx-8 mt-5"
|
||||
@reauthorize="requestAuthorization"
|
||||
/>
|
||||
<InboxReconnectionRequired class="mx-6" @reauthorize="requestAuthorization" />
|
||||
</template>
|
||||
|
||||
+1
-4
@@ -30,8 +30,5 @@ async function requestAuthorization() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<InboxReconnectionRequired
|
||||
class="mx-8 mt-5"
|
||||
@reauthorize="requestAuthorization"
|
||||
/>
|
||||
<InboxReconnectionRequired class="mx-6" @reauthorize="requestAuthorization" />
|
||||
</template>
|
||||
|
||||
+1
-1
@@ -201,7 +201,7 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<InboxReconnectionRequired
|
||||
class="mx-8 mt-5"
|
||||
class="mx-6"
|
||||
:is-loading="isRequestingAuthorization"
|
||||
:action-label="actionLabel"
|
||||
:description="description"
|
||||
|
||||
+90
-11
@@ -10,8 +10,14 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
isRegisteringWebhook: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['registerWebhook']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const QUALITY_COLORS = {
|
||||
@@ -133,21 +139,43 @@ const formatModeDisplay = mode =>
|
||||
const getModeStatusTextColor = mode => MODE_COLORS[mode] || 'text-n-slate-12';
|
||||
|
||||
const getStatusTextColor = status => STATUS_COLORS[status] || 'text-n-slate-12';
|
||||
|
||||
const showWebhookSection = computed(
|
||||
() => props.healthData?.webhook_configuration !== undefined
|
||||
);
|
||||
|
||||
const webhookUrl = computed(
|
||||
() =>
|
||||
props.healthData?.webhook_configuration?.whatsapp_business_account ||
|
||||
props.healthData?.webhook_configuration?.application
|
||||
);
|
||||
|
||||
const webhookConfigured = computed(() => !!webhookUrl.value);
|
||||
|
||||
const webhookUrlMismatch = computed(
|
||||
() =>
|
||||
webhookConfigured.value &&
|
||||
webhookUrl.value !== props.healthData?.expected_webhook_url
|
||||
);
|
||||
|
||||
const handleRegisterWebhook = () => {
|
||||
emit('registerWebhook');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="gap-4 pt-8 mx-8">
|
||||
<div class="gap-4 mx-6">
|
||||
<div
|
||||
class="px-5 py-5 space-y-6 rounded-xl border shadow-sm border-n-weak bg-n-solid-2"
|
||||
class="px-5 py-5 space-y-6 rounded-xl outline outline-1 -outline-offset-1 outline-n-weak bg-n-solid-2"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col gap-5 justify-between items-start w-full md:flex-row"
|
||||
>
|
||||
<div>
|
||||
<span class="text-base font-medium text-n-slate-12">
|
||||
<span class="text-heading-3 text-n-slate-12">
|
||||
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.TITLE') }}
|
||||
</span>
|
||||
<p class="mt-1 text-sm text-n-slate-11">
|
||||
<p class="mt-1 text-body-main text-n-slate-11">
|
||||
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -169,7 +197,7 @@ const getStatusTextColor = status => STATUS_COLORS[status] || 'text-n-slate-12';
|
||||
class="flex flex-col gap-2 p-4 rounded-lg border border-n-weak bg-n-solid-1"
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="text-sm font-medium text-n-slate-11">
|
||||
<span class="text-body-main font-medium text-n-slate-11">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<Icon
|
||||
@@ -181,36 +209,85 @@ const getStatusTextColor = status => STATUS_COLORS[status] || 'text-n-slate-12';
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
v-if="item.type === 'quality'"
|
||||
class="inline-flex items-center px-2 py-0.5 min-h-6 text-xs font-medium rounded-md bg-n-alpha-2"
|
||||
class="inline-flex items-center px-2 py-0.5 min-h-6 text-label-small rounded-md bg-n-alpha-2"
|
||||
:class="getQualityRatingTextColor(item.value)"
|
||||
>
|
||||
{{ item.value }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="item.type === 'status'"
|
||||
class="inline-flex items-center px-2 py-0.5 min-h-6 text-xs font-medium rounded-md bg-n-alpha-2"
|
||||
class="inline-flex items-center px-2 py-0.5 min-h-6 text-label-small rounded-md bg-n-alpha-2"
|
||||
:class="getStatusTextColor(item.value)"
|
||||
>
|
||||
{{ formatStatusDisplay(item.value) }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="item.type === 'mode'"
|
||||
class="inline-flex items-center px-2 py-0.5 min-h-6 text-xs font-medium rounded-md bg-n-alpha-2"
|
||||
class="inline-flex items-center px-2 py-0.5 min-h-6 text-label-small rounded-md bg-n-alpha-2"
|
||||
:class="getModeStatusTextColor(item.value)"
|
||||
>
|
||||
{{ formatModeDisplay(item.value) }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="item.type === 'tier'"
|
||||
class="text-sm font-medium text-n-slate-12"
|
||||
class="text-label text-n-slate-12"
|
||||
>
|
||||
{{ formatTierDisplay(item.value) }}
|
||||
</span>
|
||||
<span v-else class="text-sm font-medium text-n-slate-12">{{
|
||||
<span v-else class="text-label text-n-slate-12">{{
|
||||
item.value
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Webhook configuration card -->
|
||||
<div
|
||||
v-if="showWebhookSection"
|
||||
class="flex flex-col gap-2 p-4 rounded-lg border border-n-weak bg-n-solid-1"
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="text-body-main font-medium text-n-slate-11">
|
||||
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.TITLE') }}
|
||||
</span>
|
||||
<Icon
|
||||
v-tooltip.top="t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.DESCRIPTION')"
|
||||
icon="i-lucide-info"
|
||||
class="flex-shrink-0 w-4 h-4 cursor-help text-n-slate-9"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span
|
||||
v-if="webhookConfigured && !webhookUrlMismatch"
|
||||
class="inline-flex items-center gap-1.5 px-2 py-0.5 min-h-6 text-label-small rounded-md bg-n-alpha-2 text-n-teal-11"
|
||||
>
|
||||
<Icon icon="i-lucide-check-circle" class="w-3.5 h-3.5" />
|
||||
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.CONFIGURED_SUCCESS') }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex items-center gap-1.5 px-2 py-0.5 min-h-6 text-label-small rounded-md bg-n-alpha-2 text-n-amber-11"
|
||||
>
|
||||
<Icon icon="i-lucide-alert-triangle" class="w-3.5 h-3.5" />
|
||||
{{
|
||||
webhookUrlMismatch
|
||||
? t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.URL_MISMATCH')
|
||||
: t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.ACTION_REQUIRED')
|
||||
}}
|
||||
</span>
|
||||
<ButtonV4
|
||||
v-if="!webhookConfigured || webhookUrlMismatch"
|
||||
sm
|
||||
solid
|
||||
blue
|
||||
:loading="isRegisteringWebhook"
|
||||
:disabled="isRegisteringWebhook"
|
||||
class="flex-shrink-0"
|
||||
@click="handleRegisterWebhook"
|
||||
>
|
||||
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.REGISTER_BUTTON') }}
|
||||
</ButtonV4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="pt-8">
|
||||
@@ -219,7 +296,9 @@ const getStatusTextColor = status => STATUS_COLORS[status] || 'text-n-slate-12';
|
||||
>
|
||||
<div>
|
||||
<Icon icon="i-lucide-activity" class="mb-2 w-8 h-8" />
|
||||
<p class="text-sm">{{ t('INBOX_MGMT.ACCOUNT_HEALTH.NO_DATA') }}</p>
|
||||
<p class="text-body-main text-n-slate-11">
|
||||
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.NO_DATA') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+47
-46
@@ -1,15 +1,17 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import SettingsSection from 'dashboard/components/SettingsSection.vue';
|
||||
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
|
||||
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import SelectInput from 'dashboard/components-next/select/Select.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
LoadingState,
|
||||
SettingsSection,
|
||||
SettingsFieldSection,
|
||||
NextButton,
|
||||
SelectInput,
|
||||
},
|
||||
props: {
|
||||
inbox: {
|
||||
@@ -27,8 +29,13 @@ export default {
|
||||
agentBots: 'agentBots/getBots',
|
||||
uiFlags: 'agentBots/getUIFlags',
|
||||
}),
|
||||
currentInboxId() {
|
||||
return this.inbox?.id || this.$route.params.inboxId;
|
||||
},
|
||||
activeAgentBot() {
|
||||
return this.$store.getters['agentBots/getActiveAgentBot'](this.inbox.id);
|
||||
return this.$store.getters['agentBots/getActiveAgentBot'](
|
||||
this.currentInboxId
|
||||
);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
@@ -37,11 +44,14 @@ export default {
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('agentBots/get');
|
||||
this.$store.dispatch('agentBots/fetchAgentBotInbox', this.inbox.id);
|
||||
this.fetchBotData();
|
||||
},
|
||||
|
||||
methods: {
|
||||
fetchBotData() {
|
||||
this.$store.dispatch('agentBots/get');
|
||||
this.$store.dispatch('agentBots/fetchAgentBotInbox', this.currentInboxId);
|
||||
},
|
||||
async updateActiveAgentBot() {
|
||||
try {
|
||||
await this.$store.dispatch('agentBots/setAgentBotInbox', {
|
||||
@@ -74,51 +84,42 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-8">
|
||||
<div class="mx-6 max-w-4xl">
|
||||
<LoadingState v-if="uiFlags.isFetching || uiFlags.isFetchingAgentBot" />
|
||||
<form
|
||||
v-else
|
||||
class="flex flex-wrap mx-0"
|
||||
@submit.prevent="updateActiveAgentBot"
|
||||
>
|
||||
<SettingsSection
|
||||
:title="$t('AGENT_BOTS.BOT_CONFIGURATION.TITLE')"
|
||||
:sub-title="$t('AGENT_BOTS.BOT_CONFIGURATION.DESC')"
|
||||
<form v-else @submit.prevent="updateActiveAgentBot">
|
||||
<SettingsFieldSection
|
||||
:label="$t('AGENT_BOTS.BOT_CONFIGURATION.TITLE')"
|
||||
:help-text="$t('AGENT_BOTS.BOT_CONFIGURATION.DESC')"
|
||||
class="[&>div]:!items-start"
|
||||
>
|
||||
<div>
|
||||
<label>
|
||||
<select v-model="selectedAgentBotId">
|
||||
<option value="" disabled selected>
|
||||
{{ $t('AGENT_BOTS.BOT_CONFIGURATION.SELECT_PLACEHOLDER') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="agentBot in agentBots"
|
||||
:key="agentBot.id"
|
||||
:value="agentBot.id"
|
||||
<SelectInput
|
||||
v-model="selectedAgentBotId"
|
||||
:placeholder="$t('AGENT_BOTS.BOT_CONFIGURATION.SELECT_PLACEHOLDER')"
|
||||
:options="agentBots.map(bot => ({ value: bot.id, label: bot.name }))"
|
||||
/>
|
||||
<template #extra>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-8 mt-3">
|
||||
<div class="col-span-1 lg:col-span-2 invisible" />
|
||||
<div class="col-span-1 lg:col-span-6 flex gap-2 mx-1">
|
||||
<NextButton
|
||||
type="submit"
|
||||
:label="$t('AGENT_BOTS.BOT_CONFIGURATION.SUBMIT')"
|
||||
:is-loading="uiFlags.isSettingAgentBot"
|
||||
/>
|
||||
<NextButton
|
||||
type="button"
|
||||
:disabled="!selectedAgentBotId"
|
||||
:is-loading="uiFlags.isDisconnecting"
|
||||
faded
|
||||
ruby
|
||||
@click="disconnectBot"
|
||||
>
|
||||
{{ agentBot.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="button-container space-x-2">
|
||||
<NextButton
|
||||
type="submit"
|
||||
:label="$t('AGENT_BOTS.BOT_CONFIGURATION.SUBMIT')"
|
||||
:is-loading="uiFlags.isSettingAgentBot"
|
||||
/>
|
||||
<NextButton
|
||||
type="button"
|
||||
:disabled="!selectedAgentBotId"
|
||||
:is-loading="uiFlags.isDisconnecting"
|
||||
faded
|
||||
ruby
|
||||
@click="disconnectBot"
|
||||
>
|
||||
{{ $t('AGENT_BOTS.BOT_CONFIGURATION.DISCONNECT') }}
|
||||
</NextButton>
|
||||
{{ $t('AGENT_BOTS.BOT_CONFIGURATION.DISCONNECT') }}
|
||||
</NextButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</template>
|
||||
</SettingsFieldSection>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+71
-90
@@ -2,11 +2,26 @@
|
||||
import parse from 'date-fns/parse';
|
||||
import differenceInMinutes from 'date-fns/differenceInMinutes';
|
||||
import { generateTimeSlots } from '../helpers/businessHour';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import NextSelect from 'dashboard/components-next/select/Select.vue';
|
||||
|
||||
const timeSlots = generateTimeSlots(30);
|
||||
|
||||
const groupByPeriod = slots =>
|
||||
['AM', 'PM']
|
||||
.map(period => ({
|
||||
label: period,
|
||||
options: slots
|
||||
.filter(s => s.endsWith(period))
|
||||
.map(s => ({ value: s, label: s })),
|
||||
}))
|
||||
.filter(g => g.options.length);
|
||||
|
||||
export default {
|
||||
components: {},
|
||||
components: {
|
||||
Icon,
|
||||
NextSelect,
|
||||
},
|
||||
props: {
|
||||
dayName: {
|
||||
type: String,
|
||||
@@ -23,12 +38,10 @@ export default {
|
||||
emits: ['update'],
|
||||
computed: {
|
||||
fromTimeSlots() {
|
||||
return timeSlots;
|
||||
return groupByPeriod(timeSlots);
|
||||
},
|
||||
toTimeSlots() {
|
||||
return timeSlots.filter(slot => {
|
||||
return slot !== '12:00 AM';
|
||||
});
|
||||
return groupByPeriod(timeSlots.filter(slot => slot !== '12:00 AM'));
|
||||
},
|
||||
isDayEnabled: {
|
||||
get() {
|
||||
@@ -135,99 +148,67 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="day-wrap flex py-2 gap-1 items-center px-0 min-h-[3rem] box-content border-b border-solid border-n-weak"
|
||||
>
|
||||
<div class="checkbox-wrap flex items-center">
|
||||
<input
|
||||
v-model="isDayEnabled"
|
||||
name="enable-day"
|
||||
class="m-0"
|
||||
type="checkbox"
|
||||
:title="$t('INBOX_MGMT.BUSINESS_HOURS.DAY.ENABLE')"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="day flex items-center py-0 px-3 text-sm font-medium flex-shrink-0 min-w-28"
|
||||
>
|
||||
<span>{{ dayName }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="isDayEnabled"
|
||||
class="flex flex-col flex-shrink-0 flex-grow relative"
|
||||
>
|
||||
<div class="flex items-center flex-shrink-0 flex-grow">
|
||||
<div class="checkbox-wrap flex items-center open-all-day mr-6">
|
||||
<input
|
||||
v-model="isOpenAllDay"
|
||||
name="enable-open-all-day"
|
||||
class="enable-checkbox text-sm font-medium"
|
||||
type="checkbox"
|
||||
:title="$t('INBOX_MGMT.BUSINESS_HOURS.ALL_DAY')"
|
||||
<tr>
|
||||
<td class="ltr:pl-4 ltr:pr-3 rtl:pl-3 rtl:pr-4">
|
||||
<div class="flex items-center gap-2 min-h-16">
|
||||
<input
|
||||
v-model="isDayEnabled"
|
||||
name="enable-day"
|
||||
class="m-0"
|
||||
type="checkbox"
|
||||
:title="$t('INBOX_MGMT.BUSINESS_HOURS.DAY.ENABLE')"
|
||||
/>
|
||||
<span class="text-body-main text-n-slate-12 font-medium">
|
||||
{{ dayName }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 ltr:pr-3 rtl:pl-3">
|
||||
<div v-if="isDayEnabled" class="flex flex-col gap-1.5">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="isOpenAllDay"
|
||||
name="enable-open-all-day"
|
||||
class="m-0"
|
||||
type="checkbox"
|
||||
:title="$t('INBOX_MGMT.BUSINESS_HOURS.ALL_DAY')"
|
||||
/>
|
||||
<span class="text-body-main text-n-slate-12">{{
|
||||
$t('INBOX_MGMT.BUSINESS_HOURS.ALL_DAY')
|
||||
}}</span>
|
||||
</div>
|
||||
<NextSelect
|
||||
v-model="fromTime"
|
||||
:groups="fromTimeSlots"
|
||||
:placeholder="$t('INBOX_MGMT.BUSINESS_HOURS.DAY.CHOOSE')"
|
||||
:disabled="isOpenAllDay"
|
||||
/>
|
||||
<div class="flex items-center">
|
||||
<Icon icon="i-lucide-minus size-4" />
|
||||
</div>
|
||||
<NextSelect
|
||||
v-model="toTime"
|
||||
:groups="toTimeSlots"
|
||||
:placeholder="$t('INBOX_MGMT.BUSINESS_HOURS.DAY.CHOOSE')"
|
||||
:disabled="isOpenAllDay"
|
||||
/>
|
||||
<span class="text-sm font-medium ml-1">{{
|
||||
$t('INBOX_MGMT.BUSINESS_HOURS.ALL_DAY')
|
||||
}}</span>
|
||||
</div>
|
||||
<multiselect
|
||||
v-model="fromTime"
|
||||
:options="fromTimeSlots"
|
||||
deselect-label=""
|
||||
select-label=""
|
||||
selected-label=""
|
||||
:placeholder="$t('INBOX_MGMT.BUSINESS_HOURS.DAY.CHOOSE')"
|
||||
:allow-empty="false"
|
||||
:disabled="isOpenAllDay"
|
||||
/>
|
||||
<div class="separator-icon flex items-center py-0 px-3">
|
||||
<fluent-icon icon="subtract" type="solid" size="16" />
|
||||
</div>
|
||||
<multiselect
|
||||
v-model="toTime"
|
||||
:options="toTimeSlots"
|
||||
deselect-label=""
|
||||
select-label=""
|
||||
selected-label=""
|
||||
:placeholder="$t('INBOX_MGMT.BUSINESS_HOURS.DAY.CHOOSE')"
|
||||
:allow-empty="false"
|
||||
:disabled="isOpenAllDay"
|
||||
/>
|
||||
<span v-if="hasError" class="error text-label-small text-n-ruby-9">
|
||||
{{ $t('INBOX_MGMT.BUSINESS_HOURS.DAY.VALIDATION_ERROR') }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="hasError" class="date-error pt-1">
|
||||
<span class="error text-xs text-n-ruby-9">{{
|
||||
$t('INBOX_MGMT.BUSINESS_HOURS.DAY.VALIDATION_ERROR')
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center flex-shrink-0 flex-grow text-sm text-n-slate-11"
|
||||
>
|
||||
<span>
|
||||
<span v-else class="text-body-main text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.BUSINESS_HOURS.DAY.UNAVAILABLE') }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
</td>
|
||||
<td class="py-3 ltr:pr-3 rtl:pl-3">
|
||||
<span
|
||||
v-if="isDayEnabled && !hasError"
|
||||
class="label bg-n-brand/10 dark:bg-n-brand/30 text-n-blue-11 text-xs inline-block px-2 py-1 rounded-lg cursor-default whitespace-nowrap"
|
||||
class="label bg-n-blue-3 text-n-blue-11 text-label-small inline-block px-2 py-1 rounded-lg cursor-default whitespace-nowrap"
|
||||
>
|
||||
{{ totalHours }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.day-wrap::v-deep .multiselect {
|
||||
@apply m-0 w-[7.5rem];
|
||||
|
||||
> .multiselect__tags {
|
||||
@apply pl-3;
|
||||
|
||||
.multiselect__single {
|
||||
@apply text-sm leading-6 py-2 px-0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<script setup>
|
||||
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
|
||||
|
||||
defineProps({
|
||||
lockToSingleConversation: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['update']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col sm:flex-row md:flex-col xl:flex-row items-start gap-4 mt-3 min-w-0"
|
||||
>
|
||||
<RadioCard
|
||||
id="disabled"
|
||||
:label="$t('INBOX_MGMT.EDIT.LOCK_TO_SINGLE_CONVERSATION.DISABLED')"
|
||||
:description="
|
||||
$t('INBOX_MGMT.EDIT.LOCK_TO_SINGLE_CONVERSATION.DISABLED_DESCRIPTION')
|
||||
"
|
||||
:is-active="!lockToSingleConversation"
|
||||
class="flex-1"
|
||||
@select="$emit('update', false)"
|
||||
/>
|
||||
|
||||
<RadioCard
|
||||
id="enabled"
|
||||
:label="$t('INBOX_MGMT.EDIT.LOCK_TO_SINGLE_CONVERSATION.ENABLED')"
|
||||
:description="
|
||||
$t('INBOX_MGMT.EDIT.LOCK_TO_SINGLE_CONVERSATION.ENABLED_DESCRIPTION')
|
||||
"
|
||||
:is-active="lockToSingleConversation"
|
||||
class="flex-1"
|
||||
@select="$emit('update', true)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+87
-96
@@ -1,114 +1,105 @@
|
||||
<script>
|
||||
import PreviewCard from 'dashboard/components/ui/PreviewCard.vue';
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
PreviewCard,
|
||||
Avatar,
|
||||
const props = defineProps({
|
||||
senderNameType: {
|
||||
type: String,
|
||||
default: 'friendly',
|
||||
},
|
||||
props: {
|
||||
senderNameType: {
|
||||
type: String,
|
||||
default: 'friendly',
|
||||
},
|
||||
businessName: {
|
||||
type: String,
|
||||
default: '',
|
||||
businessName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isWebsiteChannel: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const senderNameKeyOptions = computed(() => [
|
||||
{
|
||||
key: 'friendly',
|
||||
heading: t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.FRIENDLY.TITLE'),
|
||||
content: t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.FRIENDLY.SUBTITLE'),
|
||||
preview: {
|
||||
senderName: 'Smith',
|
||||
businessName: 'Chatwoot',
|
||||
email: '<support@yourbusiness.com>',
|
||||
},
|
||||
},
|
||||
emits: ['update'],
|
||||
data() {
|
||||
return {
|
||||
senderNameKeyOptions: [
|
||||
{
|
||||
key: 'friendly',
|
||||
heading: this.$t(
|
||||
'INBOX_MGMT.EDIT.SENDER_NAME_SECTION.FRIENDLY.TITLE'
|
||||
),
|
||||
content: this.$t(
|
||||
'INBOX_MGMT.EDIT.SENDER_NAME_SECTION.FRIENDLY.SUBTITLE'
|
||||
),
|
||||
preview: {
|
||||
senderName: 'Smith',
|
||||
businessName: 'Chatwoot',
|
||||
email: '<support@yourbusiness.com>',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'professional',
|
||||
heading: this.$t(
|
||||
'INBOX_MGMT.EDIT.SENDER_NAME_SECTION.PROFESSIONAL.TITLE'
|
||||
),
|
||||
content: this.$t(
|
||||
'INBOX_MGMT.EDIT.SENDER_NAME_SECTION.PROFESSIONAL.SUBTITLE'
|
||||
),
|
||||
preview: {
|
||||
senderName: '',
|
||||
businessName: 'Chatwoot ',
|
||||
email: '<support@yourbusiness.com>',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
isKeyOptionFriendly(key) {
|
||||
return key === 'friendly';
|
||||
},
|
||||
userName(keyOption) {
|
||||
return this.isKeyOptionFriendly(keyOption.key)
|
||||
? keyOption.preview.senderName
|
||||
: keyOption.preview.businessName;
|
||||
},
|
||||
toggleSenderNameType(key) {
|
||||
this.$emit('update', key);
|
||||
{
|
||||
key: 'professional',
|
||||
heading: t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.PROFESSIONAL.TITLE'),
|
||||
content: t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.PROFESSIONAL.SUBTITLE'),
|
||||
preview: {
|
||||
senderName: '',
|
||||
businessName: 'Chatwoot',
|
||||
email: '<support@yourbusiness.com>',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const isKeyOptionFriendly = key => key === 'friendly';
|
||||
|
||||
const userName = keyOption =>
|
||||
isKeyOptionFriendly(keyOption.key)
|
||||
? keyOption.preview.senderName
|
||||
: keyOption.preview.businessName;
|
||||
|
||||
const toggleSenderNameType = key => {
|
||||
emit('update', key);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col lg:flex-row items-start lg:items-center gap-4">
|
||||
<button
|
||||
<div
|
||||
class="flex flex-col items-start gap-4 mt-3 min-w-0"
|
||||
:class="
|
||||
isWebsiteChannel ? 'sm:flex-row md:flex-col xl:flex-row' : 'sm:flex-row'
|
||||
"
|
||||
>
|
||||
<RadioCard
|
||||
v-for="keyOption in senderNameKeyOptions"
|
||||
:id="keyOption.key"
|
||||
:key="keyOption.key"
|
||||
class="text-n-slate-12 cursor-pointer p-0"
|
||||
@click="toggleSenderNameType(keyOption.key)"
|
||||
:label="keyOption.heading"
|
||||
:description="keyOption.content"
|
||||
:is-active="keyOption.key === props.senderNameType"
|
||||
class="flex-1 !gap-2"
|
||||
@select="toggleSenderNameType"
|
||||
>
|
||||
<PreviewCard
|
||||
:heading="keyOption.heading"
|
||||
:content="keyOption.content"
|
||||
:active="keyOption.key === senderNameType"
|
||||
>
|
||||
<div class="flex flex-col items-start p-3 gap-2">
|
||||
<span class="text-xs">
|
||||
{{ $t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.FOR_EG') }}
|
||||
</span>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Avatar :name="userName(keyOption)" :size="32" rounded-full />
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<div class="items-center flex flex-row gap-0.5 max-w-[18rem]">
|
||||
<span
|
||||
v-if="isKeyOptionFriendly(keyOption.key)"
|
||||
class="text-xs font-semibold leading-tight"
|
||||
>
|
||||
{{ keyOption.preview.senderName }}
|
||||
</span>
|
||||
<span v-if="isKeyOptionFriendly(keyOption.key)" class="text-xs">
|
||||
{{ $t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.FRIENDLY.FROM') }}
|
||||
</span>
|
||||
<span
|
||||
class="text-xs font-semibold leading-tight overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ businessName || keyOption.preview.businessName }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs">{{ keyOption.preview.email }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar :name="userName(keyOption)" :size="30" />
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center gap-1">
|
||||
<span
|
||||
v-if="isKeyOptionFriendly(keyOption.key)"
|
||||
class="text-body-main text-n-slate-12"
|
||||
>
|
||||
{{ keyOption.preview.senderName }}
|
||||
</span>
|
||||
<span
|
||||
v-if="isKeyOptionFriendly(keyOption.key)"
|
||||
class="text-body-main text-n-slate-11"
|
||||
>
|
||||
{{ t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.FRIENDLY.FROM') }}
|
||||
</span>
|
||||
<span class="text-body-main text-n-slate-12">
|
||||
{{ props.businessName || keyOption.preview.businessName }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-label-small text-n-slate-11">
|
||||
{{ keyOption.preview.email }}
|
||||
</span>
|
||||
</div>
|
||||
</PreviewCard>
|
||||
</button>
|
||||
</div>
|
||||
</RadioCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+97
-76
@@ -2,7 +2,8 @@
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import SettingsSection from 'dashboard/components/SettingsSection.vue';
|
||||
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
|
||||
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||
import BusinessDay from './BusinessDay.vue';
|
||||
import {
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
timeZoneOptions,
|
||||
} from '../helpers/businessHour';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
|
||||
const DEFAULT_TIMEZONE = {
|
||||
label: 'Pacific Time (US & Canada) (GMT-07:00)',
|
||||
@@ -20,10 +22,12 @@ const DEFAULT_TIMEZONE = {
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SettingsSection,
|
||||
SettingsToggleSection,
|
||||
SettingsFieldSection,
|
||||
BusinessDay,
|
||||
NextButton,
|
||||
WootMessageEditor,
|
||||
ComboBox,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
props: {
|
||||
@@ -58,6 +62,15 @@ export default {
|
||||
timeZones() {
|
||||
return [...timeZoneOptions()];
|
||||
},
|
||||
timeZoneValue: {
|
||||
get() {
|
||||
return this.timeZone.value;
|
||||
},
|
||||
set(value) {
|
||||
const match = this.timeZones.find(tz => tz.value === value);
|
||||
if (match) this.timeZone = match;
|
||||
},
|
||||
},
|
||||
isRichEditorEnabled() {
|
||||
if (
|
||||
this.isATwilioChannel ||
|
||||
@@ -121,96 +134,104 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-8">
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.BUSINESS_HOURS.TITLE')"
|
||||
:sub-title="$t('INBOX_MGMT.BUSINESS_HOURS.SUBTITLE')"
|
||||
<div class="mx-6">
|
||||
<SettingsToggleSection
|
||||
v-model="isBusinessHoursEnabled"
|
||||
:header="$t('INBOX_MGMT.BUSINESS_HOURS.TOGGLE_AVAILABILITY')"
|
||||
:description="$t('INBOX_MGMT.BUSINESS_HOURS.TOGGLE_HELP')"
|
||||
>
|
||||
<form @submit.prevent="updateInbox">
|
||||
<label for="toggle-business-hours" class="toggle-input-wrap">
|
||||
<input
|
||||
v-model="isBusinessHoursEnabled"
|
||||
type="checkbox"
|
||||
class="ltr:mr-2 rtl:ml-2"
|
||||
name="toggle-business-hours"
|
||||
/>
|
||||
{{ $t('INBOX_MGMT.BUSINESS_HOURS.TOGGLE_AVAILABILITY') }}
|
||||
</label>
|
||||
<p class="mb-4 text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.BUSINESS_HOURS.TOGGLE_HELP') }}
|
||||
</p>
|
||||
<div v-if="isBusinessHoursEnabled" class="mb-6">
|
||||
<div>
|
||||
<label class="unavailable-input-wrap">
|
||||
{{ $t('INBOX_MGMT.BUSINESS_HOURS.UNAVAILABLE_MESSAGE_LABEL') }}
|
||||
</label>
|
||||
<div
|
||||
v-if="isRichEditorEnabled"
|
||||
class="px-4 py-0 mx-0 mt-0 mb-4 rounded-lg outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 bg-n-alpha-black2"
|
||||
>
|
||||
<WootMessageEditor
|
||||
v-model="unavailableMessage"
|
||||
enable-variables
|
||||
is-format-mode
|
||||
:min-height="4"
|
||||
/>
|
||||
</div>
|
||||
<textarea v-else v-model="unavailableMessage" type="text" />
|
||||
</div>
|
||||
<div class="timezone-input-wrap">
|
||||
<label>
|
||||
{{ $t('INBOX_MGMT.BUSINESS_HOURS.TIMEZONE_LABEL') }}
|
||||
</label>
|
||||
<multiselect
|
||||
v-model="timeZone"
|
||||
:options="timeZones"
|
||||
deselect-label=""
|
||||
select-label=""
|
||||
selected-label=""
|
||||
track-by="value"
|
||||
label="label"
|
||||
close-on-select
|
||||
:placeholder="$t('INBOX_MGMT.BUSINESS_HOURS.DAY.CHOOSE')"
|
||||
:allow-empty="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
{{ $t('INBOX_MGMT.BUSINESS_HOURS.WEEKLY_TITLE') }}
|
||||
</label>
|
||||
<BusinessDay
|
||||
v-for="timeSlot in timeSlots"
|
||||
:key="timeSlot.day"
|
||||
:day-name="dayNames[timeSlot.day]"
|
||||
:time-slot="timeSlot"
|
||||
@update="data => onSlotUpdate(timeSlot.day, data)"
|
||||
<template v-if="isBusinessHoursEnabled" #editor>
|
||||
<div class="mb-4">
|
||||
<WootMessageEditor
|
||||
v-if="isRichEditorEnabled"
|
||||
v-model="unavailableMessage"
|
||||
enable-variables
|
||||
is-format-mode
|
||||
:placeholder="
|
||||
$t('INBOX_MGMT.BUSINESS_HOURS.UNAVAILABLE_MESSAGE_LABEL')
|
||||
"
|
||||
:min-height="4"
|
||||
/>
|
||||
<textarea v-else v-model="unavailableMessage" type="text" />
|
||||
</div>
|
||||
</template>
|
||||
</SettingsToggleSection>
|
||||
|
||||
<div v-if="isBusinessHoursEnabled" class="flex items-center my-8 py-1">
|
||||
<div class="flex-1 h-px bg-n-weak" />
|
||||
<span class="text-body-main text-n-slate-11 px-2">
|
||||
{{ $t('INBOX_MGMT.BUSINESS_HOURS.WEEKLY_TITLE') }}
|
||||
</span>
|
||||
<div class="flex-1 h-px bg-n-weak" />
|
||||
</div>
|
||||
|
||||
<SettingsFieldSection
|
||||
v-if="isBusinessHoursEnabled"
|
||||
:label="$t('INBOX_MGMT.BUSINESS_HOURS.TIMEZONE_LABEL')"
|
||||
>
|
||||
<ComboBox
|
||||
v-model="timeZoneValue"
|
||||
:options="timeZones"
|
||||
:placeholder="$t('INBOX_MGMT.BUSINESS_HOURS.DAY.CHOOSE')"
|
||||
class="[&>div>button]:!bg-n-alpha-black2"
|
||||
/>
|
||||
</SettingsFieldSection>
|
||||
|
||||
<form class="flex flex-col" @submit.prevent="updateInbox">
|
||||
<div v-if="isBusinessHoursEnabled" class="mt-2">
|
||||
<div class="w-full">
|
||||
<table
|
||||
class="min-w-full table-auto outline outline-1 -outline-offset-1 outline-n-weak rounded-xl"
|
||||
>
|
||||
<thead>
|
||||
<tr class="border-b border-n-weak">
|
||||
<th
|
||||
class="py-3 ltr:pl-4 ltr:pr-3 rtl:pl-3 rtl:pr-4 text-start text-heading-3 text-n-slate-12"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.BUSINESS_HOURS.DAY.DAY') }}
|
||||
</th>
|
||||
<th
|
||||
class="py-3 ltr:pr-3 rtl:pl-3 text-start text-heading-3 text-n-slate-12"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.BUSINESS_HOURS.DAY.AVAILABILITY') }}
|
||||
</th>
|
||||
<th
|
||||
class="py-3 ltr:pr-3 rtl:pl-3 text-start text-heading-3 text-n-slate-12"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.BUSINESS_HOURS.DAY.HOURS') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-n-weak">
|
||||
<BusinessDay
|
||||
v-for="timeSlot in timeSlots"
|
||||
:key="timeSlot.day"
|
||||
:day-name="dayNames[timeSlot.day]"
|
||||
:time-slot="timeSlot"
|
||||
@update="data => onSlotUpdate(timeSlot.day, data)"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex justify-end items-center py-4 mt-2">
|
||||
<NextButton
|
||||
type="submit"
|
||||
:label="$t('INBOX_MGMT.BUSINESS_HOURS.UPDATE')"
|
||||
:is-loading="uiFlags.isUpdating"
|
||||
:disabled="hasError"
|
||||
/>
|
||||
</form>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.timezone-input-wrap {
|
||||
&::v-deep .multiselect {
|
||||
@apply mt-2;
|
||||
}
|
||||
}
|
||||
|
||||
::v-deep.message-editor {
|
||||
@apply border-0;
|
||||
}
|
||||
|
||||
.unavailable-input-wrap {
|
||||
textarea {
|
||||
@apply min-h-[4rem] mt-2;
|
||||
}
|
||||
textarea {
|
||||
@apply min-h-[4rem] mt-1.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -99,7 +99,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<InboxReconnectionRequired class="mx-8 mt-5" @reauthorize="startLogin" />
|
||||
<InboxReconnectionRequired class="mx-6" @reauthorize="startLogin" />
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
+167
-142
@@ -7,10 +7,13 @@ import { useVuelidate } from '@vuelidate/core';
|
||||
import { minValue } from '@vuelidate/validators';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import SettingsSection from '../../../../../components/SettingsSection.vue';
|
||||
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
|
||||
import SettingsAccordion from 'dashboard/components-next/Settings/SettingsAccordion.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Switch from 'dashboard/components-next/switch/Switch.vue';
|
||||
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
|
||||
import assignmentPoliciesAPI from 'dashboard/api/assignmentPolicies';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
@@ -27,7 +30,7 @@ const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { isEnterprise } = useConfig();
|
||||
|
||||
const selectedAgents = ref([]);
|
||||
const selectedAgentIds = ref([]);
|
||||
const isAgentListUpdating = ref(false);
|
||||
const enableAutoAssignment = ref(false);
|
||||
const maxAssignmentLimit = ref(null);
|
||||
@@ -42,6 +45,33 @@ const isLinkingPolicy = ref(false);
|
||||
|
||||
const agentList = computed(() => store.getters['agents/getAgents']);
|
||||
|
||||
const selectedAgentNames = computed(() =>
|
||||
selectedAgentIds.value.map(
|
||||
id => agentList.value.find(a => a.id === id)?.name ?? ''
|
||||
)
|
||||
);
|
||||
|
||||
const agentMenuItems = computed(() =>
|
||||
agentList.value
|
||||
.filter(({ id }) => !selectedAgentIds.value.includes(id))
|
||||
.map(({ id, name, thumbnail, avatar_url }) => ({
|
||||
label: name,
|
||||
value: id,
|
||||
action: 'select',
|
||||
thumbnail: { name, src: thumbnail || avatar_url || '' },
|
||||
}))
|
||||
);
|
||||
|
||||
const handleAgentAdd = ({ value }) => {
|
||||
if (!selectedAgentIds.value.includes(value)) {
|
||||
selectedAgentIds.value.push(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAgentRemove = index => {
|
||||
selectedAgentIds.value.splice(index, 1);
|
||||
};
|
||||
|
||||
const isFeatureEnabled = feature => {
|
||||
const accountId = Number(route.params.accountId);
|
||||
return store.getters['accounts/isFeatureEnabledonAccount'](
|
||||
@@ -95,6 +125,18 @@ const rules = {
|
||||
|
||||
const v$ = useVuelidate(rules, { maxAssignmentLimit });
|
||||
|
||||
const assignmentHeader = computed(() =>
|
||||
hasAssignmentV2.value
|
||||
? t('INBOX_MGMT.ASSIGNMENT.ENABLE_AUTO_ASSIGNMENT')
|
||||
: t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT')
|
||||
);
|
||||
|
||||
const assignmentDescription = computed(() =>
|
||||
hasAssignmentV2.value
|
||||
? t('INBOX_MGMT.ASSIGNMENT.DESCRIPTION')
|
||||
: t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT_SUB_TEXT')
|
||||
);
|
||||
|
||||
const maxAssignmentLimitErrors = computed(() => {
|
||||
if (v$.value.maxAssignmentLimit.$error) {
|
||||
return t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_RANGE_ERROR');
|
||||
@@ -110,7 +152,7 @@ const fetchAttachedAgents = async () => {
|
||||
const {
|
||||
data: { payload: inboxMembers },
|
||||
} = response;
|
||||
selectedAgents.value = inboxMembers;
|
||||
selectedAgentIds.value = inboxMembers.map(m => m.id);
|
||||
} catch (error) {
|
||||
// Handle error
|
||||
}
|
||||
@@ -204,12 +246,13 @@ const closePolicyDropdown = () => {
|
||||
showPolicyDropdown.value = false;
|
||||
};
|
||||
|
||||
const handleToggleAutoAssignment = async () => {
|
||||
const handleToggleAutoAssignment = async val => {
|
||||
enableAutoAssignment.value = val;
|
||||
try {
|
||||
const payload = {
|
||||
id: props.inbox.id,
|
||||
formData: false,
|
||||
enable_auto_assignment: enableAutoAssignment.value,
|
||||
enable_auto_assignment: val,
|
||||
};
|
||||
await store.dispatch('inboxes/updateInbox', payload);
|
||||
useAlert(t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
@@ -219,12 +262,11 @@ const handleToggleAutoAssignment = async () => {
|
||||
};
|
||||
|
||||
const updateAgents = async () => {
|
||||
const agentListIds = selectedAgents.value.map(el => el.id);
|
||||
isAgentListUpdating.value = true;
|
||||
try {
|
||||
await store.dispatch('inboxMembers/create', {
|
||||
inboxId: props.inbox.id,
|
||||
agentList: agentListIds,
|
||||
agentList: selectedAgentIds.value,
|
||||
});
|
||||
useAlert(t('AGENT_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
@@ -320,81 +362,76 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_AGENTS')"
|
||||
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_AGENTS_SUB_TEXT')"
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_AGENTS')"
|
||||
:help-text="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_AGENTS_SUB_TEXT')"
|
||||
class="[&>div]:!items-start"
|
||||
>
|
||||
<multiselect
|
||||
v-model="selectedAgents"
|
||||
:options="agentList"
|
||||
track-by="id"
|
||||
label="name"
|
||||
multiple
|
||||
:close-on-select="false"
|
||||
:clear-on-select="false"
|
||||
hide-selected
|
||||
placeholder="Pick some"
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
|
||||
/>
|
||||
<div
|
||||
class="rounded-xl outline outline-1 -outline-offset-1 outline-n-weak hover:outline-n-strong px-2 py-2"
|
||||
>
|
||||
<TagInput
|
||||
:model-value="selectedAgentNames"
|
||||
:placeholder="$t('INBOX_MGMT.ADD.AGENTS.PICK_AGENTS')"
|
||||
:menu-items="agentMenuItems"
|
||||
show-dropdown
|
||||
skip-label-dedup
|
||||
:auto-open-dropdown="false"
|
||||
@add="handleAgentAdd"
|
||||
@remove="handleAgentRemove"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NextButton
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
|
||||
:is-loading="isAgentListUpdating"
|
||||
@click="updateAgents"
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.AGENT_ASSIGNMENT')"
|
||||
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.AGENT_ASSIGNMENT_SUB_TEXT')"
|
||||
>
|
||||
<!-- New UI for assignment_v2 -->
|
||||
<template v-if="hasAssignmentV2">
|
||||
<div class="flex items-start gap-3">
|
||||
<Switch
|
||||
v-model="enableAutoAssignment"
|
||||
class="flex-shrink-0 mt-0.5"
|
||||
@change="handleToggleAutoAssignment"
|
||||
/>
|
||||
<div class="flex-grow">
|
||||
<label class="text-sm text-n-slate-12 font-medium mb-1">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.ENABLE_AUTO_ASSIGNMENT') }}
|
||||
</label>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.DESCRIPTION') }}
|
||||
</p>
|
||||
<template #extra>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-8">
|
||||
<div class="col-span-1 lg:col-span-2" />
|
||||
<div class="col-span-1 lg:col-span-6 mt-4 justify-self-end">
|
||||
<NextButton
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
|
||||
:is-loading="isAgentListUpdating"
|
||||
@click="updateAgents"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition-all duration-150 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 -translate-y-2"
|
||||
</template>
|
||||
</SettingsFieldSection>
|
||||
<SettingsAccordion
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.AGENT_ASSIGNMENT')"
|
||||
class="mt-6"
|
||||
>
|
||||
<SettingsToggleSection
|
||||
v-model="enableAutoAssignment"
|
||||
compact
|
||||
:header="assignmentHeader"
|
||||
:description="assignmentDescription"
|
||||
@update:model-value="handleToggleAutoAssignment"
|
||||
>
|
||||
<template
|
||||
v-if="enableAutoAssignment && (isEnterprise || hasAssignmentV2)"
|
||||
#editor
|
||||
>
|
||||
<div v-if="enableAutoAssignment" class="mt-6">
|
||||
<!-- assignment_v2 UI -->
|
||||
<template v-if="hasAssignmentV2">
|
||||
<!-- Policy Card - When policy is attached -->
|
||||
<div
|
||||
v-if="showAdvancedAssignmentUI && assignmentPolicy"
|
||||
class="p-4 rounded-xl outline-1 outline-n-weak outline bg-n-solid-1 dark:bg-n-slate-1"
|
||||
class="ltr:pr-0 rtl:pl-0 ltr:pl-4 rtl:pr-4 py-4"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex-shrink-0 size-12 rounded-xl bg-n-slate-3 flex items-center justify-center"
|
||||
class="flex-shrink-0 size-10 rounded-xl bg-n-slate-3 flex items-center justify-center"
|
||||
>
|
||||
<span class="i-lucide-zap text-xl text-n-slate-11" />
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<div class="flex items-start justify-between gap-4 mb-4">
|
||||
<div
|
||||
class="flex items-start justify-between gap-4 mb-4 ltr:pr-4 rtl:pl-4"
|
||||
>
|
||||
<div class="flex flex-col items-start">
|
||||
<span class="text-base font-medium text-n-slate-12 mb-1">
|
||||
<span class="text-heading-3 text-n-slate-12 mb-1">
|
||||
{{ assignmentPolicy.name }}
|
||||
</span>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
<p class="text-body-main text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.POLICY_LABEL') }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -412,7 +449,7 @@ onMounted(() => {
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-12">
|
||||
<span class="text-body-main text-n-slate-12">
|
||||
{{ assignmentOrderLabel }}
|
||||
</span>
|
||||
</li>
|
||||
@@ -420,7 +457,7 @@ onMounted(() => {
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-12">
|
||||
<span class="text-body-main text-n-slate-12">
|
||||
{{ assignmentMethodLabel }}
|
||||
</span>
|
||||
</li>
|
||||
@@ -447,21 +484,20 @@ onMounted(() => {
|
||||
!assignmentPolicy &&
|
||||
!isLoadingPolicy
|
||||
"
|
||||
class="rounded-xl outline-1 outline-n-weak outline"
|
||||
>
|
||||
<!-- Default Policy Header -->
|
||||
<div class="p-4">
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex-shrink-0 w-12 h-12 rounded-xl bg-n-slate-3 dark:bg-n-slate-4 flex items-center justify-center"
|
||||
class="flex-shrink-0 size-10 rounded-xl bg-n-slate-3 dark:bg-n-slate-4 flex items-center justify-center"
|
||||
>
|
||||
<i class="i-lucide-zap text-xl text-n-slate-11" />
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<h4 class="text-base font-medium text-n-slate-12 mb-1">
|
||||
<h4 class="text-heading-3 text-n-slate-12 mb-0.5">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_POLICY_LINKED') }}
|
||||
</h4>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
<p class="text-body-main text-n-slate-11">
|
||||
{{
|
||||
$t('INBOX_MGMT.ASSIGNMENT.DEFAULT_POLICY_DESCRIPTION')
|
||||
}}
|
||||
@@ -476,45 +512,47 @@ onMounted(() => {
|
||||
v-on-click-outside="closePolicyDropdown"
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-n-brand hover:bg-n-brand/90 rounded-lg transition-colors"
|
||||
<NextButton
|
||||
icon="i-lucide-link"
|
||||
sm
|
||||
@click="togglePolicyDropdown"
|
||||
>
|
||||
<i class="i-lucide-link text-sm" />
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.LINK_EXISTING_POLICY') }}
|
||||
<i
|
||||
class="i-lucide-chevron-down text-sm transition-transform"
|
||||
<Icon
|
||||
icon="i-lucide-chevron-down"
|
||||
class="transition-transform flex-shrink-0"
|
||||
:class="{ 'rotate-180': showPolicyDropdown }"
|
||||
/>
|
||||
</button>
|
||||
</NextButton>
|
||||
|
||||
<DropdownMenu
|
||||
v-if="showPolicyDropdown"
|
||||
class="top-full left-0 mt-2 min-w-72"
|
||||
class="top-full ltr:left-0 rtl:right-0 mt-2 max-w-64 max-h-72 overflow-y-auto"
|
||||
:menu-items="policyMenuItems"
|
||||
:is-searching="isLoadingPolicies"
|
||||
@action="handlePolicyMenuAction"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-n-slate-12 bg-n-slate-3 dark:bg-n-slate-4 hover:bg-n-slate-4 dark:hover:bg-n-slate-5 rounded-lg transition-colors"
|
||||
<NextButton
|
||||
icon="i-lucide-plus"
|
||||
:label="$t('INBOX_MGMT.ASSIGNMENT.CREATE_NEW_POLICY')"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
@click="navigateToCreatePolicy"
|
||||
>
|
||||
<i class="i-lucide-plus text-sm" />
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.CREATE_NEW_POLICY') }}
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Default Rules Info -->
|
||||
<div class="px-4 py-4 border-t border-n-weak bg-n-slate-2">
|
||||
<div
|
||||
class="px-4 py-4 border-t border-n-weak bg-n-slate-2 rounded-b-xl"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<i class="i-lucide-info text-base text-n-slate-10 mt-0.5" />
|
||||
<Icon icon="i-lucide-info" class="mt-0.5 text-n-slate-11" />
|
||||
<div>
|
||||
<p class="text-sm text-n-slate-11 mb-2">
|
||||
<p class="text-body-main text-n-slate-11 mb-2">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.CURRENT_BEHAVIOR') }}
|
||||
</p>
|
||||
<ul class="space-y-1">
|
||||
@@ -522,7 +560,7 @@ onMounted(() => {
|
||||
<span
|
||||
class="w-1 h-1 rounded-full bg-n-slate-10 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
<span class="text-body-main text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_1') }}
|
||||
</span>
|
||||
</li>
|
||||
@@ -530,7 +568,7 @@ onMounted(() => {
|
||||
<span
|
||||
class="w-1 h-1 rounded-full bg-n-slate-10 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
<span class="text-body-main text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_2') }}
|
||||
</span>
|
||||
</li>
|
||||
@@ -543,19 +581,19 @@ onMounted(() => {
|
||||
<!-- Default Rules Card - Feature not enabled (no advanced_assignment) -->
|
||||
<div
|
||||
v-else-if="!showAdvancedAssignmentUI"
|
||||
class="p-4 rounded-xl outline outline-1 outline-n-weak -outline-offset-1"
|
||||
class="ltr:pr-0 rtl:pl-0 ltr:pl-4 rtl:pr-4 py-4"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex-shrink-0 w-12 h-12 rounded-xl bg-n-slate-3 dark:bg-n-slate-4 flex items-center justify-center"
|
||||
class="flex-shrink-0 size-10 rounded-xl bg-n-slate-3 dark:bg-n-slate-4 flex items-center justify-center"
|
||||
>
|
||||
<i class="i-lucide-zap text-xl text-n-slate-11" />
|
||||
<Icon icon="i-lucide-zap" class="text-xl text-n-slate-11" />
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<h4 class="text-base font-medium text-n-slate-12 mb-1">
|
||||
<h4 class="text-heading-3 text-n-slate-12 mb-0.5">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULES_TITLE') }}
|
||||
</h4>
|
||||
<p class="text-sm text-n-slate-11 mb-4">
|
||||
<p class="text-body-main text-n-slate-11 mb-4">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULES_DESCRIPTION') }}
|
||||
</p>
|
||||
|
||||
@@ -564,7 +602,7 @@ onMounted(() => {
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
<span class="text-body-main text-n-slate-12">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_1') }}
|
||||
</span>
|
||||
</li>
|
||||
@@ -572,7 +610,7 @@ onMounted(() => {
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
<span class="text-body-main text-n-slate-12">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_2') }}
|
||||
</span>
|
||||
</li>
|
||||
@@ -582,7 +620,7 @@ onMounted(() => {
|
||||
|
||||
<!-- Upgrade prompt when advanced_assignment is not enabled -->
|
||||
<div v-if="!hasAdvancedAssignment">
|
||||
<p class="text-sm text-n-slate-11 mb-1">
|
||||
<p class="text-body-main text-n-slate-11 mb-1">
|
||||
{{ $t('INBOX_MGMT.ASSIGNMENT.UPGRADE_PROMPT') }}
|
||||
</p>
|
||||
<NextButton
|
||||
@@ -596,52 +634,39 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Old UI for non-assignment_v2 -->
|
||||
<template v-else>
|
||||
<label class="w-3/4 settings-item">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="enableAutoAssignment"
|
||||
v-model="enableAutoAssignment"
|
||||
type="checkbox"
|
||||
@change="handleToggleAutoAssignment"
|
||||
/>
|
||||
<label for="enableAutoAssignment">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT') }}
|
||||
</label>
|
||||
</div>
|
||||
<!-- Old UI for non-assignment_v2 -->
|
||||
<template v-else-if="isEnterprise">
|
||||
<div class="p-4">
|
||||
<woot-input
|
||||
v-model="maxAssignmentLimit"
|
||||
type="number"
|
||||
:class="{ error: v$.maxAssignmentLimit.$error }"
|
||||
:error="maxAssignmentLimitErrors"
|
||||
:label="$t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT')"
|
||||
class="[&>input]:!mb-0"
|
||||
@blur="v$.maxAssignmentLimit.$touch"
|
||||
/>
|
||||
|
||||
<p class="pb-1 text-sm not-italic text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT_SUB_TEXT') }}
|
||||
</p>
|
||||
</label>
|
||||
<p class="mt-1.5 text-label-small text-n-slate-11">
|
||||
{{
|
||||
$t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_SUB_TEXT')
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div v-if="enableAutoAssignment && isEnterprise" class="py-3">
|
||||
<woot-input
|
||||
v-model="maxAssignmentLimit"
|
||||
type="number"
|
||||
:class="{ error: v$.maxAssignmentLimit.$error }"
|
||||
:error="maxAssignmentLimitErrors"
|
||||
:label="$t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT')"
|
||||
@blur="v$.maxAssignmentLimit.$touch"
|
||||
/>
|
||||
|
||||
<p class="pb-1 text-sm not-italic text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_SUB_TEXT') }}
|
||||
</p>
|
||||
|
||||
<NextButton
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
|
||||
:disabled="v$.maxAssignmentLimit.$invalid"
|
||||
@click="updateInbox"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsSection>
|
||||
<div class="flex justify-end mt-4">
|
||||
<NextButton
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
|
||||
:disabled="v$.maxAssignmentLimit.$invalid"
|
||||
@click="updateInbox"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</SettingsToggleSection>
|
||||
</SettingsAccordion>
|
||||
|
||||
<woot-modal
|
||||
v-if="showDeleteConfirmModal"
|
||||
|
||||
+181
-137
@@ -1,7 +1,9 @@
|
||||
<script>
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import SettingsSection from '../../../../../components/SettingsSection.vue';
|
||||
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
|
||||
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
|
||||
import SettingsAccordion from 'dashboard/components-next/Settings/SettingsAccordion.vue';
|
||||
import ImapSettings from '../ImapSettings.vue';
|
||||
import SmtpSettings from '../SmtpSettings.vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
@@ -13,7 +15,9 @@ import { sanitizeAllowedDomains } from 'dashboard/helper/URLHelper';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SettingsSection,
|
||||
SettingsFieldSection,
|
||||
SettingsToggleSection,
|
||||
SettingsAccordion,
|
||||
ImapSettings,
|
||||
SmtpSettings,
|
||||
NextButton,
|
||||
@@ -33,11 +37,13 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
hmacMandatory: false,
|
||||
allowMobileWebview: false,
|
||||
whatsAppInboxAPIKey: '',
|
||||
isRequestingReauthorization: false,
|
||||
isSyncingTemplates: false,
|
||||
allowedDomains: '',
|
||||
isUpdatingAllowedDomains: false,
|
||||
isSettingDefaults: false,
|
||||
};
|
||||
},
|
||||
validations: {
|
||||
@@ -58,14 +64,28 @@ export default {
|
||||
inbox() {
|
||||
this.setDefaults();
|
||||
},
|
||||
allowMobileWebview() {
|
||||
if (!this.isSettingDefaults) this.handleMobileWebviewFlag();
|
||||
},
|
||||
hmacMandatory() {
|
||||
if (!this.isSettingDefaults && this.isAWebWidgetInbox)
|
||||
this.handleHmacFlag();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setDefaults();
|
||||
},
|
||||
methods: {
|
||||
setDefaults() {
|
||||
this.isSettingDefaults = true;
|
||||
this.hmacMandatory = this.inbox.hmac_mandatory || false;
|
||||
this.allowMobileWebview = (
|
||||
this.inbox.selected_feature_flags || []
|
||||
).includes('allow_mobile_webview');
|
||||
this.allowedDomains = this.inbox.allowed_domains || '';
|
||||
this.$nextTick(() => {
|
||||
this.isSettingDefaults = false;
|
||||
});
|
||||
},
|
||||
handleHmacFlag() {
|
||||
this.updateInbox();
|
||||
@@ -85,6 +105,26 @@ export default {
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
}
|
||||
},
|
||||
async handleMobileWebviewFlag() {
|
||||
try {
|
||||
const currentFlags = this.inbox.selected_feature_flags || [];
|
||||
const selectedFlags = this.allowMobileWebview
|
||||
? [...currentFlags, 'allow_mobile_webview']
|
||||
: currentFlags.filter(f => f !== 'allow_mobile_webview');
|
||||
|
||||
const payload = {
|
||||
id: this.inbox.id,
|
||||
formData: false,
|
||||
channel: {
|
||||
selected_feature_flags: selectedFlags,
|
||||
},
|
||||
};
|
||||
await this.$store.dispatch('inboxes/updateInbox', payload);
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
|
||||
}
|
||||
},
|
||||
async updateAllowedDomains() {
|
||||
this.isUpdatingAllowedDomains = true;
|
||||
const sanitizedAllowedDomains = sanitizeAllowedDomains(
|
||||
@@ -149,136 +189,151 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isATwilioChannel" class="mx-8">
|
||||
<SettingsSection
|
||||
<div v-if="isATwilioChannel">
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.ADD.TWILIO.API_CALLBACK.TITLE')"
|
||||
:help-text="$t('INBOX_MGMT.ADD.TWILIO.API_CALLBACK.SUBTITLE')"
|
||||
>
|
||||
<woot-code :script="inbox.callback_webhook_url" lang="html" />
|
||||
</SettingsFieldSection>
|
||||
<SettingsFieldSection
|
||||
v-if="isATwilioWhatsAppChannel"
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_TITLE')"
|
||||
:sub-title="
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_TITLE')"
|
||||
:help-text="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_SUBHEADER')
|
||||
"
|
||||
>
|
||||
<div class="flex justify-start items-center mt-2">
|
||||
<NextButton :disabled="isSyncingTemplates" @click="syncTemplates">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_BUTTON') }}
|
||||
</NextButton>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
<NextButton :disabled="isSyncingTemplates" @click="syncTemplates">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_BUTTON') }}
|
||||
</NextButton>
|
||||
</SettingsFieldSection>
|
||||
</div>
|
||||
<div v-else-if="isAVoiceChannel" class="mx-8">
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
|
||||
:sub-title="
|
||||
<div v-else-if="isAVoiceChannel">
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
|
||||
:help-text="
|
||||
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_SUBTITLE')
|
||||
"
|
||||
>
|
||||
<woot-code :script="inbox.voice_call_webhook_url" lang="html" />
|
||||
</SettingsSection>
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_TITLE')"
|
||||
:sub-title="
|
||||
</SettingsFieldSection>
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_TITLE')"
|
||||
:help-text="
|
||||
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_SUBTITLE')
|
||||
"
|
||||
>
|
||||
<woot-code :script="inbox.voice_status_webhook_url" lang="html" />
|
||||
</SettingsSection>
|
||||
</SettingsFieldSection>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isALineChannel" class="mx-8">
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.ADD.LINE_CHANNEL.API_CALLBACK.TITLE')"
|
||||
:sub-title="$t('INBOX_MGMT.ADD.LINE_CHANNEL.API_CALLBACK.SUBTITLE')"
|
||||
<div v-else-if="isALineChannel">
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.ADD.LINE_CHANNEL.API_CALLBACK.TITLE')"
|
||||
:help-text="$t('INBOX_MGMT.ADD.LINE_CHANNEL.API_CALLBACK.SUBTITLE')"
|
||||
>
|
||||
<woot-code :script="inbox.callback_webhook_url" lang="html" />
|
||||
</SettingsSection>
|
||||
</SettingsFieldSection>
|
||||
</div>
|
||||
<div v-else-if="isAWebWidgetInbox">
|
||||
<div class="mx-8">
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.MESSENGER_HEADING')"
|
||||
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.MESSENGER_SUB_HEAD')"
|
||||
<div class="space-y-4">
|
||||
<SettingsToggleSection
|
||||
:header="$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.TITLE')"
|
||||
:description="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.DESCRIPTION')
|
||||
"
|
||||
hide-toggle
|
||||
>
|
||||
<woot-code
|
||||
:script="inbox.web_widget_script"
|
||||
lang="html"
|
||||
:codepen-title="`${inbox.name} - Chatwoot Widget Test`"
|
||||
enable-code-pen
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.TITLE')"
|
||||
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.SUBTITLE')"
|
||||
>
|
||||
<div class="flex flex-col w-full max-w-3xl gap-4">
|
||||
<template #editor>
|
||||
<TextArea
|
||||
v-model="allowedDomains"
|
||||
:placeholder="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.PLACEHOLDER')
|
||||
"
|
||||
auto-height
|
||||
min-height="8rem"
|
||||
class="w-full"
|
||||
resize
|
||||
class="w-full [&>div]:!bg-transparent [&>div]:!border-none [&>div]:!border-0 [&>div]:px-0 [&>div]:pb-0 [&>div]:pt-0"
|
||||
/>
|
||||
<div>
|
||||
<div class="mt-3 flex justify-end">
|
||||
<NextButton
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
|
||||
:is-loading="isUpdatingAllowedDomains"
|
||||
@click="updateAllowedDomains"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_VERIFICATION')"
|
||||
>
|
||||
<woot-code :script="inbox.hmac_token" />
|
||||
<template #subTitle>
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.HMAC_DESCRIPTION') }}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://www.chatwoot.com/docs/product/channels/live-chat/sdk/identity-validation/"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.HMAC_LINK_TO_DOCS') }}
|
||||
</a>
|
||||
</template>
|
||||
</SettingsSection>
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_VERIFICATION')"
|
||||
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_DESCRIPTION')"
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<input
|
||||
id="hmacMandatory"
|
||||
v-model="hmacMandatory"
|
||||
type="checkbox"
|
||||
@change="handleHmacFlag"
|
||||
/>
|
||||
<label for="hmacMandatory">
|
||||
{{ $t('INBOX_MGMT.EDIT.ENABLE_HMAC.LABEL') }}
|
||||
</label>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</SettingsToggleSection>
|
||||
<SettingsToggleSection
|
||||
v-model="allowMobileWebview"
|
||||
:header="$t('INBOX_MGMT.SETTINGS_POPUP.ALLOW_MOBILE_WEBVIEW.LABEL')"
|
||||
:description="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.ALLOW_MOBILE_WEBVIEW.SUBTITLE')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SettingsAccordion
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.IDENTITY_VALIDATION.TITLE')"
|
||||
class="mt-6"
|
||||
>
|
||||
<SettingsToggleSection
|
||||
:header="$t('INBOX_MGMT.SETTINGS_POPUP.IDENTITY_VALIDATION.TITLE')"
|
||||
:description="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.IDENTITY_VALIDATION.DESCRIPTION')
|
||||
"
|
||||
hide-toggle
|
||||
>
|
||||
<template #editor>
|
||||
<p class="mb-1 text-sm font-medium text-n-slate-12">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.IDENTITY_VALIDATION.SECRET_KEY') }}
|
||||
</p>
|
||||
<woot-code :script="inbox.hmac_token" />
|
||||
<p class="mt-1.5 text-label-small text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.HMAC_DESCRIPTION') }}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://www.chatwoot.com/docs/product/channels/live-chat/sdk/identity-validation/"
|
||||
class="text-n-blue-11 hover:underline text-label-small"
|
||||
>
|
||||
{{
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.IDENTITY_VALIDATION.VIEW_DOCS')
|
||||
}}
|
||||
</a>
|
||||
</p>
|
||||
</template>
|
||||
</SettingsToggleSection>
|
||||
|
||||
<SettingsToggleSection
|
||||
v-model="hmacMandatory"
|
||||
:header="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.IDENTITY_VALIDATION.REQUIRE_LABEL')
|
||||
"
|
||||
:description="
|
||||
$t(
|
||||
'INBOX_MGMT.SETTINGS_POPUP.IDENTITY_VALIDATION.REQUIRE_DESCRIPTION'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</SettingsAccordion>
|
||||
</div>
|
||||
<div v-else-if="isAPIInbox" class="mx-8">
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_IDENTIFIER')"
|
||||
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_IDENTIFIER_SUB_TEXT')"
|
||||
<div v-else-if="isAPIInbox">
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_IDENTIFIER')"
|
||||
:help-text="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_IDENTIFIER_SUB_TEXT')"
|
||||
>
|
||||
<woot-code :script="inbox.inbox_identifier" />
|
||||
</SettingsSection>
|
||||
</SettingsFieldSection>
|
||||
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_VERIFICATION')"
|
||||
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_DESCRIPTION')"
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_VERIFICATION')"
|
||||
:help-text="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_DESCRIPTION')"
|
||||
>
|
||||
<woot-code :script="inbox.hmac_token" />
|
||||
</SettingsSection>
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_VERIFICATION')"
|
||||
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_DESCRIPTION')"
|
||||
</SettingsFieldSection>
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_VERIFICATION')"
|
||||
:help-text="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_DESCRIPTION')"
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<input
|
||||
@@ -287,17 +342,17 @@ export default {
|
||||
type="checkbox"
|
||||
@change="handleHmacFlag"
|
||||
/>
|
||||
<label for="hmacMandatory">
|
||||
<label for="hmacMandatory" class="text-body-main text-n-slate-12">
|
||||
{{ $t('INBOX_MGMT.EDIT.ENABLE_HMAC.LABEL') }}
|
||||
</label>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</SettingsFieldSection>
|
||||
</div>
|
||||
<div v-else-if="isAnEmailChannel">
|
||||
<div class="mx-8">
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.FORWARD_EMAIL_TITLE')"
|
||||
:sub-title="
|
||||
<div>
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.FORWARD_EMAIL_TITLE')"
|
||||
:help-text="
|
||||
isForwardingEnabled
|
||||
? $t('INBOX_MGMT.SETTINGS_POPUP.FORWARD_EMAIL_SUB_TEXT')
|
||||
: ''
|
||||
@@ -309,71 +364,62 @@ export default {
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg"
|
||||
class="py-2 px-3 bg-n-amber-3 outline-n-amber-4 text-n-amber-11 outline outline-1 -outline-offset-1 rounded-xl"
|
||||
>
|
||||
<p class="text-sm text-yellow-800 dark:text-yellow-200 mb-0">
|
||||
<p class="text-body-para mb-0">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.FORWARD_EMAIL_NOT_CONFIGURED') }}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</SettingsFieldSection>
|
||||
</div>
|
||||
<ImapSettings :inbox="inbox" />
|
||||
<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">
|
||||
<!-- Embedded Signup Section -->
|
||||
<template v-if="isEmbeddedSignupWhatsApp">
|
||||
<SettingsSection
|
||||
<SettingsFieldSection
|
||||
v-if="whatsappAppId"
|
||||
:title="
|
||||
:label="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_TITLE')
|
||||
"
|
||||
:sub-title="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER')
|
||||
"
|
||||
:help-text="`${$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER')} ${$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION')}`"
|
||||
>
|
||||
<div class="flex gap-4 items-center">
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{
|
||||
$t(
|
||||
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<div class="flex flex-col gap-1 items-start">
|
||||
<NextButton @click="handleReconfigure">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_BUTTON') }}
|
||||
</NextButton>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</SettingsFieldSection>
|
||||
</template>
|
||||
|
||||
<!-- Manual Setup Section -->
|
||||
<template v-else>
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_TITLE')"
|
||||
:sub-title="
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_TITLE')"
|
||||
:help-text="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_SUBHEADER')
|
||||
"
|
||||
>
|
||||
<woot-code :script="inbox.provider_config.webhook_verify_token" />
|
||||
</SettingsSection>
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_TITLE')"
|
||||
:sub-title="
|
||||
</SettingsFieldSection>
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_TITLE')"
|
||||
:help-text="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_SUBHEADER')
|
||||
"
|
||||
>
|
||||
<woot-code :script="inbox.provider_config.api_key" />
|
||||
</SettingsSection>
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_TITLE')"
|
||||
:sub-title="
|
||||
</SettingsFieldSection>
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_TITLE')"
|
||||
:help-text="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_SUBHEADER')
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="flex flex-1 justify-between items-center mt-2 whatsapp-settings--content"
|
||||
class="flex flex-1 justify-between items-center whatsapp-settings--content"
|
||||
>
|
||||
<woot-input
|
||||
v-model="whatsAppInboxAPIKey"
|
||||
@@ -394,20 +440,18 @@ export default {
|
||||
}}
|
||||
</NextButton>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</SettingsFieldSection>
|
||||
</template>
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_TITLE')"
|
||||
:sub-title="
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_TITLE')"
|
||||
:help-text="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_SUBHEADER')
|
||||
"
|
||||
>
|
||||
<div class="flex justify-start items-center mt-2">
|
||||
<NextButton :disabled="isSyncingTemplates" @click="syncTemplates">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_BUTTON') }}
|
||||
</NextButton>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
<NextButton :disabled="isSyncingTemplates" @click="syncTemplates">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_BUTTON') }}
|
||||
</NextButton>
|
||||
</SettingsFieldSection>
|
||||
</div>
|
||||
<WhatsappReauthorize
|
||||
v-if="isEmbeddedSignupWhatsApp"
|
||||
|
||||
+314
-167
@@ -4,17 +4,17 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useInbox } from 'dashboard/composables/useInbox';
|
||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import WithLabel from 'v3/components/Form/WithLabel.vue';
|
||||
import SectionLayout from 'dashboard/routes/dashboard/settings/account/components/SectionLayout.vue';
|
||||
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
|
||||
import CSATDisplayTypeSelector from './components/CSATDisplayTypeSelector.vue';
|
||||
import CSATTemplate from 'dashboard/components-next/message/bubbles/Template/CSAT.vue';
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Switch from 'next/switch/Switch.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
|
||||
@@ -27,6 +27,7 @@ const props = defineProps({
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const labels = useMapGetter('labels/getLabels');
|
||||
const { captainEnabled } = useCaptain();
|
||||
|
||||
const { isAWhatsAppChannel, isATwilioWhatsAppChannel } = useInbox(
|
||||
props.inbox?.id
|
||||
@@ -38,6 +39,8 @@ const isAnyWhatsAppChannel = computed(
|
||||
);
|
||||
|
||||
const isUpdating = ref(false);
|
||||
const utilityAnalysisLoading = ref(false);
|
||||
const utilityAnalysisResult = ref(null);
|
||||
const selectedLabelValues = ref([]);
|
||||
const currentLabel = ref('');
|
||||
|
||||
@@ -47,7 +50,7 @@ const state = reactive({
|
||||
message: '',
|
||||
templateButtonText: 'Please rate us',
|
||||
surveyRuleOperator: 'contains',
|
||||
templateLanguage: '',
|
||||
templateLanguage: 'en',
|
||||
});
|
||||
|
||||
const templateStatus = ref(null);
|
||||
@@ -90,6 +93,9 @@ const messagePreviewData = computed(() => ({
|
||||
const shouldShowTemplateStatus = computed(
|
||||
() => templateStatus.value && !templateLoading.value
|
||||
);
|
||||
const showUtilityAnalyzer = computed(
|
||||
() => isAnyWhatsAppChannel.value && captainEnabled.value
|
||||
);
|
||||
|
||||
const templateApprovalStatus = computed(() => {
|
||||
const statusMap = {
|
||||
@@ -219,6 +225,85 @@ const updateDisplayType = type => {
|
||||
state.displayType = type;
|
||||
};
|
||||
|
||||
const resetUtilityAnalysis = () => {
|
||||
utilityAnalysisResult.value = null;
|
||||
};
|
||||
|
||||
const analyzeTemplateUtility = async () => {
|
||||
if (!showUtilityAnalyzer.value || !state.message?.trim()) return;
|
||||
|
||||
utilityAnalysisLoading.value = true;
|
||||
resetUtilityAnalysis();
|
||||
|
||||
try {
|
||||
const response = await store.dispatch(
|
||||
'inboxes/analyzeCSATTemplateUtility',
|
||||
{
|
||||
inboxId: props.inbox.id,
|
||||
template: {
|
||||
message: state.message,
|
||||
button_text: state.templateButtonText,
|
||||
language: state.templateLanguage,
|
||||
},
|
||||
}
|
||||
);
|
||||
utilityAnalysisResult.value = response;
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error.response?.data?.error ||
|
||||
t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
} finally {
|
||||
utilityAnalysisLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const applyUtilitySuggestion = () => {
|
||||
const suggestion = utilityAnalysisResult.value?.optimized_message;
|
||||
if (!suggestion) return;
|
||||
|
||||
state.message = suggestion;
|
||||
resetUtilityAnalysis();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [state.message, state.templateButtonText, state.templateLanguage],
|
||||
(newValues, oldValues) => {
|
||||
if (!oldValues || !utilityAnalysisResult.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const changed = newValues.some(
|
||||
(value, index) => value !== oldValues[index]
|
||||
);
|
||||
if (changed) {
|
||||
resetUtilityAnalysis();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const getUtilityClassificationLabel = classification => {
|
||||
if (classification === 'LIKELY_UTILITY') {
|
||||
return t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.CLASSIFICATION.LIKELY_UTILITY');
|
||||
}
|
||||
if (classification === 'LIKELY_MARKETING') {
|
||||
return t(
|
||||
'INBOX_MGMT.CSAT.UTILITY_ANALYZER.CLASSIFICATION.LIKELY_MARKETING'
|
||||
);
|
||||
}
|
||||
return t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.CLASSIFICATION.UNCLEAR');
|
||||
};
|
||||
|
||||
const getUtilityClassificationClass = classification => {
|
||||
if (classification === 'LIKELY_UTILITY') {
|
||||
return 'bg-n-teal-3 text-n-teal-11';
|
||||
}
|
||||
if (classification === 'LIKELY_MARKETING') {
|
||||
return 'bg-n-ruby-3 text-n-ruby-11';
|
||||
}
|
||||
return 'bg-n-amber-3 text-n-amber-11';
|
||||
};
|
||||
|
||||
const updateSurveyRuleOperator = operator => {
|
||||
state.surveyRuleOperator = operator;
|
||||
};
|
||||
@@ -414,181 +499,243 @@ const handleConfirmTemplateUpdate = async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-8">
|
||||
<SectionLayout
|
||||
:title="$t('INBOX_MGMT.CSAT.TITLE')"
|
||||
<div class="mx-6">
|
||||
<SettingsToggleSection
|
||||
v-model="state.csatSurveyEnabled"
|
||||
:header="$t('INBOX_MGMT.CSAT.TITLE')"
|
||||
:description="$t('INBOX_MGMT.CSAT.SUBTITLE')"
|
||||
>
|
||||
<template #headerActions>
|
||||
<div class="flex justify-end">
|
||||
<Switch v-model="state.csatSurveyEnabled" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="grid gap-5">
|
||||
<!-- Show display type only for non-WhatsApp channels -->
|
||||
<WithLabel
|
||||
v-if="!isAnyWhatsAppChannel"
|
||||
:label="$t('INBOX_MGMT.CSAT.DISPLAY_TYPE.LABEL')"
|
||||
name="display_type"
|
||||
>
|
||||
<CSATDisplayTypeSelector
|
||||
:selected-type="state.displayType"
|
||||
@update="updateDisplayType"
|
||||
/>
|
||||
</WithLabel>
|
||||
|
||||
<template v-if="isAnyWhatsAppChannel">
|
||||
<div
|
||||
class="flex flex-col gap-4 justify-between w-full lg:flex-row lg:gap-6"
|
||||
>
|
||||
<div class="flex flex-col gap-3 basis-3/5">
|
||||
<WithLabel
|
||||
:label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')"
|
||||
name="message"
|
||||
>
|
||||
<Editor
|
||||
v-model="state.message"
|
||||
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
|
||||
:max-length="200"
|
||||
channel-type="Context::Plain"
|
||||
class="w-full"
|
||||
/>
|
||||
</WithLabel>
|
||||
<Input
|
||||
v-model="state.templateButtonText"
|
||||
:label="$t('INBOX_MGMT.CSAT.BUTTON_TEXT.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.CSAT.BUTTON_TEXT.PLACEHOLDER')"
|
||||
class="w-full"
|
||||
/>
|
||||
|
||||
<WithLabel
|
||||
:label="$t('INBOX_MGMT.CSAT.LANGUAGE.LABEL')"
|
||||
name="language"
|
||||
>
|
||||
<ComboBox
|
||||
v-model="state.templateLanguage"
|
||||
:options="languageOptions"
|
||||
:placeholder="$t('INBOX_MGMT.CSAT.LANGUAGE.PLACEHOLDER')"
|
||||
/>
|
||||
</WithLabel>
|
||||
|
||||
<div
|
||||
v-if="shouldShowTemplateStatus"
|
||||
class="flex gap-2 items-center mt-4"
|
||||
>
|
||||
<Icon
|
||||
:icon="templateApprovalStatus.icon"
|
||||
:class="templateApprovalStatus.color"
|
||||
class="size-4"
|
||||
/>
|
||||
<span
|
||||
:class="templateApprovalStatus.color"
|
||||
class="text-sm font-medium"
|
||||
>
|
||||
{{ templateApprovalStatus.text }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col flex-shrink-0 justify-start items-center p-6 mt-1 rounded-xl basis-2/5 bg-n-slate-2 outline outline-1 outline-n-weak"
|
||||
>
|
||||
<p
|
||||
class="inline-flex items-center text-sm font-medium text-n-slate-11"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.CSAT.MESSAGE_PREVIEW.LABEL') }}
|
||||
<Icon
|
||||
v-tooltip.top-end="
|
||||
$t('INBOX_MGMT.CSAT.MESSAGE_PREVIEW.TOOLTIP')
|
||||
"
|
||||
icon="i-lucide-info"
|
||||
class="flex-shrink-0 mx-1 size-4"
|
||||
/>
|
||||
</p>
|
||||
<CSATTemplate
|
||||
:message="messagePreviewData"
|
||||
:button-text="state.templateButtonText"
|
||||
class="pt-12"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Non-WhatsApp channels layout -->
|
||||
<template v-else>
|
||||
<template v-if="state.csatSurveyEnabled" #editor>
|
||||
<div class="grid gap-5">
|
||||
<!-- Show display type only for non-WhatsApp channels -->
|
||||
<WithLabel
|
||||
:label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')"
|
||||
name="message"
|
||||
v-if="!isAnyWhatsAppChannel"
|
||||
:label="$t('INBOX_MGMT.CSAT.DISPLAY_TYPE.LABEL')"
|
||||
name="display_type"
|
||||
>
|
||||
<Editor
|
||||
v-model="state.message"
|
||||
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
|
||||
:max-length="200"
|
||||
class="w-full"
|
||||
<CSATDisplayTypeSelector
|
||||
:selected-type="state.displayType"
|
||||
@update="updateDisplayType"
|
||||
/>
|
||||
</WithLabel>
|
||||
</template>
|
||||
|
||||
<WithLabel
|
||||
:label="$t('INBOX_MGMT.CSAT.SURVEY_RULE.LABEL')"
|
||||
name="survey_rule"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<span
|
||||
class="inline-flex flex-wrap gap-1.5 items-center text-sm text-n-slate-12"
|
||||
<template v-if="isAnyWhatsAppChannel">
|
||||
<div
|
||||
class="flex flex-col gap-4 justify-between w-full lg:flex-row lg:gap-6"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.CSAT.SURVEY_RULE.DESCRIPTION_PREFIX') }}
|
||||
<FilterSelect
|
||||
v-model="state.surveyRuleOperator"
|
||||
variant="faded"
|
||||
:options="filterTypes"
|
||||
class="inline-flex shrink-0"
|
||||
@update:model-value="updateSurveyRuleOperator"
|
||||
/>
|
||||
{{ $t('INBOX_MGMT.CSAT.SURVEY_RULE.DESCRIPTION_SUFFIX') }}
|
||||
<div class="flex flex-col gap-3 basis-3/5">
|
||||
<WithLabel
|
||||
:label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')"
|
||||
name="message"
|
||||
>
|
||||
<Editor
|
||||
v-model="state.message"
|
||||
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
|
||||
:max-length="200"
|
||||
channel-type="Context::Plain"
|
||||
class="w-full"
|
||||
/>
|
||||
</WithLabel>
|
||||
<div v-if="showUtilityAnalyzer" class="flex flex-col gap-2">
|
||||
<NextButton
|
||||
sm
|
||||
slate
|
||||
:label="$t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.ACTION')"
|
||||
:is-loading="utilityAnalysisLoading"
|
||||
:disabled="!state.message?.trim()"
|
||||
@click="analyzeTemplateUtility"
|
||||
/>
|
||||
<p class="text-xs text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.HELPER_NOTE') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<NextButton
|
||||
v-for="label in selectedLabelValues"
|
||||
:key="label"
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
trailing-icon
|
||||
:label="label"
|
||||
icon="i-lucide-x"
|
||||
class="inline-flex shrink-0"
|
||||
@click="removeLabel(label)"
|
||||
<div
|
||||
v-if="utilityAnalysisResult"
|
||||
class="flex flex-col gap-3 p-3 rounded-xl outline outline-1 outline-n-weak bg-n-alpha-1"
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.RESULT_LABEL') }}
|
||||
</span>
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
:class="
|
||||
getUtilityClassificationClass(
|
||||
utilityAnalysisResult.classification
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
getUtilityClassificationLabel(
|
||||
utilityAnalysisResult.classification
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.GUIDANCE_NOTE') }}
|
||||
</p>
|
||||
<div
|
||||
v-if="
|
||||
utilityAnalysisResult.optimized_message &&
|
||||
utilityAnalysisResult.classification !== 'LIKELY_UTILITY'
|
||||
"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<p class="text-xs font-medium text-n-slate-12">
|
||||
{{
|
||||
$t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.SUGGESTION_LABEL')
|
||||
}}
|
||||
</p>
|
||||
<p class="text-sm text-n-slate-12">
|
||||
{{ utilityAnalysisResult.optimized_message }}
|
||||
</p>
|
||||
<NextButton
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
:label="$t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.APPLY')"
|
||||
@click="applyUtilitySuggestion"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
v-model="state.templateButtonText"
|
||||
:label="$t('INBOX_MGMT.CSAT.BUTTON_TEXT.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.CSAT.BUTTON_TEXT.PLACEHOLDER')"
|
||||
class="w-full"
|
||||
/>
|
||||
|
||||
<WithLabel
|
||||
:label="$t('INBOX_MGMT.CSAT.LANGUAGE.LABEL')"
|
||||
name="language"
|
||||
>
|
||||
<ComboBox
|
||||
v-model="state.templateLanguage"
|
||||
:options="languageOptions"
|
||||
:placeholder="$t('INBOX_MGMT.CSAT.LANGUAGE.PLACEHOLDER')"
|
||||
/>
|
||||
</WithLabel>
|
||||
|
||||
<div
|
||||
v-if="shouldShowTemplateStatus"
|
||||
class="flex gap-2 items-center mt-4"
|
||||
>
|
||||
<Icon
|
||||
:icon="templateApprovalStatus.icon"
|
||||
:class="templateApprovalStatus.color"
|
||||
class="size-4"
|
||||
/>
|
||||
<span
|
||||
:class="templateApprovalStatus.color"
|
||||
class="text-sm font-medium"
|
||||
>
|
||||
{{ templateApprovalStatus.text }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col flex-shrink-0 justify-start items-center p-6 mt-1 rounded-xl basis-2/5 bg-n-slate-2 outline outline-1 outline-n-weak"
|
||||
>
|
||||
<p
|
||||
class="inline-flex items-center text-sm font-medium text-n-slate-11"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.CSAT.MESSAGE_PREVIEW.LABEL') }}
|
||||
<Icon
|
||||
v-tooltip.top-end="
|
||||
$t('INBOX_MGMT.CSAT.MESSAGE_PREVIEW.TOOLTIP')
|
||||
"
|
||||
icon="i-lucide-info"
|
||||
class="flex-shrink-0 mx-1 size-4"
|
||||
/>
|
||||
</p>
|
||||
<CSATTemplate
|
||||
:message="messagePreviewData"
|
||||
:button-text="state.templateButtonText"
|
||||
class="pt-12"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Non-WhatsApp channels layout -->
|
||||
<template v-else>
|
||||
<WithLabel
|
||||
:label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')"
|
||||
name="message"
|
||||
>
|
||||
<Editor
|
||||
v-model="state.message"
|
||||
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
|
||||
:max-length="200"
|
||||
class="w-full"
|
||||
/>
|
||||
<FilterSelect
|
||||
v-model="currentLabel"
|
||||
:options="labelOptions"
|
||||
:label="$t('INBOX_MGMT.CSAT.SURVEY_RULE.SELECT_PLACEHOLDER')"
|
||||
hide-label
|
||||
variant="faded"
|
||||
class="inline-flex shrink-0"
|
||||
@update:model-value="handleLabelSelect"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</WithLabel>
|
||||
<p class="text-sm italic text-n-slate-11">
|
||||
{{
|
||||
isAnyWhatsAppChannel
|
||||
? $t('INBOX_MGMT.CSAT.WHATSAPP_NOTE')
|
||||
: $t('INBOX_MGMT.CSAT.NOTE')
|
||||
}}
|
||||
</p>
|
||||
<div>
|
||||
<NextButton
|
||||
type="submit"
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
|
||||
:is-loading="isUpdating"
|
||||
@click="saveSettings"
|
||||
/>
|
||||
</WithLabel>
|
||||
</template>
|
||||
|
||||
<WithLabel
|
||||
:label="$t('INBOX_MGMT.CSAT.SURVEY_RULE.LABEL')"
|
||||
name="survey_rule"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<span
|
||||
class="inline-flex flex-wrap gap-1.5 items-center text-sm text-n-slate-12"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.CSAT.SURVEY_RULE.DESCRIPTION_PREFIX') }}
|
||||
<FilterSelect
|
||||
v-model="state.surveyRuleOperator"
|
||||
variant="faded"
|
||||
:options="filterTypes"
|
||||
class="inline-flex shrink-0"
|
||||
@update:model-value="updateSurveyRuleOperator"
|
||||
/>
|
||||
{{ $t('INBOX_MGMT.CSAT.SURVEY_RULE.DESCRIPTION_SUFFIX') }}
|
||||
|
||||
<NextButton
|
||||
v-for="label in selectedLabelValues"
|
||||
:key="label"
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
trailing-icon
|
||||
:label="label"
|
||||
icon="i-lucide-x"
|
||||
class="inline-flex shrink-0"
|
||||
@click="removeLabel(label)"
|
||||
/>
|
||||
<FilterSelect
|
||||
v-model="currentLabel"
|
||||
:options="labelOptions"
|
||||
:label="$t('INBOX_MGMT.CSAT.SURVEY_RULE.SELECT_PLACEHOLDER')"
|
||||
hide-label
|
||||
variant="faded"
|
||||
class="inline-flex shrink-0"
|
||||
@update:model-value="handleLabelSelect"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</WithLabel>
|
||||
<p class="text-sm italic text-n-slate-11">
|
||||
{{
|
||||
isAnyWhatsAppChannel
|
||||
? $t('INBOX_MGMT.CSAT.WHATSAPP_NOTE')
|
||||
: $t('INBOX_MGMT.CSAT.NOTE')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SectionLayout>
|
||||
</template>
|
||||
</SettingsToggleSection>
|
||||
|
||||
<div class="w-full flex justify-end items-center py-4 mt-2">
|
||||
<NextButton
|
||||
type="submit"
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
|
||||
:is-loading="isUpdating"
|
||||
@click="saveSettings"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Template Update Confirmation Dialog -->
|
||||
<ConfirmTemplateUpdateDialog
|
||||
|
||||
+46
-36
@@ -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,49 @@ 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
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
@click="$emit('delete', app)"
|
||||
/>
|
||||
</div>
|
||||
</BaseTableCell>
|
||||
</template>
|
||||
</BaseTableRow>
|
||||
</template>
|
||||
|
||||
+62
-45
@@ -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,62 @@ 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 v-if="records?.length" #count>
|
||||
<span class="text-body-main text-n-slate-11">
|
||||
{{
|
||||
$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.COUNT', {
|
||||
n: records.length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<NextButton
|
||||
:label="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.HEADER_BTN_TXT')"
|
||||
size="sm"
|
||||
@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 +177,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"
|
||||
|
||||
@@ -62,7 +62,7 @@ const confirmDeletion = () => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col items-start justify-between lg:flex-row lg:items-center p-6 outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow gap-6"
|
||||
class="flex flex-col items-start justify-between lg:flex-row lg:items-center p-6 outline outline-n-container outline-1 bg-n-card rounded-xl gap-6"
|
||||
>
|
||||
<div
|
||||
class="flex items-start lg:items-center justify-start flex-1 m-0 gap-6 flex-col lg:flex-row"
|
||||
@@ -78,10 +78,10 @@ const confirmDeletion = () => {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="mb-1 text-xl font-medium text-n-slate-12">
|
||||
<h3 class="mb-1 text-heading-1 text-n-slate-12">
|
||||
{{ integrationName }}
|
||||
</h3>
|
||||
<p class="text-n-slate-11 text-sm leading-6">
|
||||
<p class="text-n-slate-11 text-body-main">
|
||||
{{ replaceInstallationName(integrationDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+36
-19
@@ -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: {
|
||||
@@ -28,6 +32,7 @@ export default {
|
||||
isIntegrationSingle,
|
||||
isHookTypeInbox,
|
||||
} = useIntegrationHook(integrationId);
|
||||
|
||||
return {
|
||||
integration,
|
||||
isIntegrationMultiple,
|
||||
@@ -71,6 +76,9 @@ export default {
|
||||
return this.$t('INTEGRATION_APPS.DELETE.CANCEL_BUTTON_TEXT');
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('integrations/get');
|
||||
},
|
||||
methods: {
|
||||
openAddHookModal() {
|
||||
this.showAddHookModal = true;
|
||||
@@ -108,26 +116,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 +158,5 @@ export default {
|
||||
:confirm-text="confirmText"
|
||||
:reject-text="cancelText"
|
||||
/>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
|
||||
+17
-13
@@ -6,6 +6,7 @@ import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Label from 'dashboard/components-next/label/Label.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
@@ -39,7 +40,7 @@ const integrationStatus = computed(() =>
|
||||
);
|
||||
|
||||
const integrationStatusColor = computed(() =>
|
||||
props.enabled ? 'bg-n-teal-9' : 'bg-n-slate-8'
|
||||
props.enabled ? 'teal' : 'slate'
|
||||
);
|
||||
|
||||
const actionURL = computed(() =>
|
||||
@@ -49,10 +50,10 @@ const actionURL = computed(() =>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col flex-1 p-6 m-[1px] outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow"
|
||||
class="flex flex-col flex-1 p-4 m-px outline outline-n-container outline-1 bg-n-card rounded-xl"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex h-12 w-12 mb-4">
|
||||
<div class="flex h-12 w-12 mb-2">
|
||||
<img
|
||||
:src="`/dashboard/images/integrations/${id}.png`"
|
||||
class="max-w-full rounded-md border border-n-weak shadow-sm block dark:hidden bg-n-alpha-3 dark:bg-n-alpha-2"
|
||||
@@ -62,24 +63,27 @@ const actionURL = computed(() =>
|
||||
class="max-w-full rounded-md border border-n-weak shadow-sm hidden dark:block bg-n-alpha-3 dark:bg-n-alpha-2"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
v-tooltip="integrationStatus"
|
||||
class="text-white p-0.5 rounded-full w-5 h-5 flex items-center justify-center"
|
||||
:class="integrationStatusColor"
|
||||
>
|
||||
<i class="i-ph-check-bold text-sm" />
|
||||
</span>
|
||||
<Label
|
||||
:label="integrationStatus"
|
||||
:color="integrationStatusColor"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col m-0 flex-1">
|
||||
<div
|
||||
class="font-medium mb-2 text-n-slate-12 flex justify-between items-center"
|
||||
>
|
||||
<span class="text-base font-semibold">{{ name }}</span>
|
||||
<span class="text-heading-3 text-n-slate-12">{{ name }}</span>
|
||||
<router-link :to="actionURL">
|
||||
<Button :label="$t('INTEGRATION_APPS.CONFIGURE')" link />
|
||||
<Button
|
||||
:label="$t('INTEGRATION_APPS.CONFIGURE')"
|
||||
icon="i-woot-settings"
|
||||
link
|
||||
xs
|
||||
/>
|
||||
</router-link>
|
||||
</div>
|
||||
<p class="text-n-slate-11">
|
||||
<p class="text-n-slate-11 text-body-main">
|
||||
{{ replaceInstallationName(description) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+116
-96
@@ -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">
|
||||
<div class="flex flex-col flex-1 gap-4 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,65 @@ export default {
|
||||
"
|
||||
:feature-name="integrationId"
|
||||
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
|
||||
:search-placeholder="$t('INTEGRATION_APPS.SEARCH_PLACEHOLDER')"
|
||||
>
|
||||
<template v-if="hooks?.length" #count>
|
||||
<span class="text-body-main text-n-slate-11">
|
||||
{{ $t('INTEGRATION_APPS.COUNT', { n: hooks.length }) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<NextButton
|
||||
v-if="showAddButton"
|
||||
icon="i-lucide-circle-plus"
|
||||
:label="$t('INTEGRATION_APPS.ADD_BUTTON')"
|
||||
size="sm"
|
||||
@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
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
@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>
|
||||
|
||||
+3
-3
@@ -22,7 +22,7 @@ const { replaceInstallationName } = useBranding();
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow flex-grow overflow-auto p-4"
|
||||
class="outline outline-n-container outline-1 bg-n-card rounded-xl flex-grow overflow-auto p-4"
|
||||
>
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="flex h-16 w-16 items-center justify-center">
|
||||
@@ -36,10 +36,10 @@ const { replaceInstallationName } = useBranding();
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center m-0 mx-4 flex-1">
|
||||
<h3 class="mb-1 text-xl font-medium text-n-slate-12">
|
||||
<h3 class="mb-1 text-heading-1 text-n-slate-12">
|
||||
{{ integration.name }}
|
||||
</h3>
|
||||
<p class="text-n-slate-11 text-sm leading-6">
|
||||
<p class="text-n-slate-11 text-body-main">
|
||||
{{ replaceInstallationName(integration.description) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+3
-3
@@ -61,19 +61,19 @@ const updateIntegration = async () => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="px-6 py-4 mb-4 outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow"
|
||||
class="px-6 py-4 mb-4 outline outline-n-container outline-1 bg-n-card rounded-xl"
|
||||
>
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="i-lucide-bell text-xl text-n-amber-11 mt-1" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="mb-1 text-base font-semibold text-n-slate-12">
|
||||
<p class="mb-1 text-heading-2 text-n-slate-12">
|
||||
{{
|
||||
$t('INTEGRATION_SETTINGS.SLACK.SELECT_CHANNEL.ATTENTION_REQUIRED')
|
||||
}}
|
||||
</p>
|
||||
<div class="mt-2 text-sm text-n-slate-11 mb-3">
|
||||
<div class="mt-2 text-body-main text-n-slate-11 mb-3">
|
||||
<p v-dompurify-html="formattedErrorMessage" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+6
-3
@@ -25,13 +25,16 @@ const formattedHelpText = computed(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex-1 w-full px-6 py-5 outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow"
|
||||
class="flex-1 w-full px-6 py-5 outline outline-n-container outline-1 bg-n-card rounded-xl"
|
||||
>
|
||||
<div class="prose-lg max-w-5xl">
|
||||
<h5 class="text-n-slate-12 tracking-tight">
|
||||
<h5 class="text-n-slate-12 text-heading-1 tracking-tight">
|
||||
{{ t('INTEGRATION_SETTINGS.SLACK.HELP_TEXT.TITLE') }}
|
||||
</h5>
|
||||
<div v-dompurify-html="formattedHelpText" class="text-n-slate-11" />
|
||||
<div
|
||||
v-dompurify-html="formattedHelpText"
|
||||
class="text-n-slate-11 text-body-main"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+33
-15
@@ -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(
|
||||
@@ -49,6 +58,7 @@ export default {
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('integrations/get', 'webhook');
|
||||
this.$store.dispatch('webhooks/get');
|
||||
},
|
||||
methods: {
|
||||
@@ -103,44 +113,52 @@ 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 v-if="records?.length" #count>
|
||||
<span class="text-body-main text-n-slate-11">
|
||||
{{
|
||||
$t('INTEGRATION_SETTINGS.WEBHOOK.COUNT', { n: records.length })
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<NextButton
|
||||
blue
|
||||
icon="i-lucide-circle-plus"
|
||||
:label="$t('INTEGRATION_SETTINGS.WEBHOOK.HEADER_BTN_TXT')"
|
||||
size="sm"
|
||||
@click="openAddPopup"
|
||||
/>
|
||||
</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 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" />
|
||||
|
||||
+86
-48
@@ -1,60 +1,98 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import WebhookForm from './WebhookForm.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: { WebhookForm },
|
||||
props: {
|
||||
onClose: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { replaceInstallationName } = useBranding();
|
||||
return {
|
||||
replaceInstallationName,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
uiFlags: 'webhooks/getUIFlags',
|
||||
}),
|
||||
},
|
||||
methods: {
|
||||
async onSubmit(webhook) {
|
||||
try {
|
||||
await this.$store.dispatch('webhooks/create', { webhook });
|
||||
useAlert(
|
||||
this.$t('INTEGRATION_SETTINGS.WEBHOOK.ADD.API.SUCCESS_MESSAGE')
|
||||
);
|
||||
this.onClose();
|
||||
} catch (error) {
|
||||
const message =
|
||||
error.response.data.message ||
|
||||
this.$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.API.ERROR_MESSAGE');
|
||||
useAlert(message);
|
||||
}
|
||||
},
|
||||
const props = defineProps({
|
||||
onClose: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
const createdWebhook = ref(null);
|
||||
|
||||
const uiFlags = computed(() => store.getters['webhooks/getUIFlags']);
|
||||
|
||||
const onSubmit = async webhook => {
|
||||
try {
|
||||
const result = await store.dispatch('webhooks/create', { webhook });
|
||||
createdWebhook.value = result;
|
||||
} catch (error) {
|
||||
const message =
|
||||
error.response.data.message ||
|
||||
t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.API.ERROR_MESSAGE');
|
||||
useAlert(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopySecret = async () => {
|
||||
await copyTextToClipboard(createdWebhook.value.secret);
|
||||
useAlert(t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY_SUCCESS'));
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-auto overflow-auto flex flex-col">
|
||||
<woot-modal-header
|
||||
:header-title="$t('INTEGRATION_SETTINGS.WEBHOOK.ADD.TITLE')"
|
||||
:header-content="
|
||||
replaceInstallationName($t('INTEGRATION_SETTINGS.WEBHOOK.FORM.DESC'))
|
||||
"
|
||||
/>
|
||||
<WebhookForm
|
||||
:is-submitting="uiFlags.creatingItem"
|
||||
:submit-label="$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.ADD_SUBMIT')"
|
||||
@submit="onSubmit"
|
||||
@cancel="onClose"
|
||||
/>
|
||||
<template v-if="createdWebhook">
|
||||
<woot-modal-header
|
||||
:header-title="
|
||||
t('INTEGRATION_SETTINGS.WEBHOOK.ADD.API.SUCCESS_MESSAGE')
|
||||
"
|
||||
/>
|
||||
<div class="px-8 pb-6">
|
||||
<p class="text-sm text-n-slate-11 mb-4">
|
||||
{{ t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.CREATED_DESC') }}
|
||||
</p>
|
||||
<label>
|
||||
{{ t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.LABEL') }}
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
:value="createdWebhook.secret"
|
||||
type="text"
|
||||
readonly
|
||||
class="!mb-0 font-mono"
|
||||
/>
|
||||
<NextButton
|
||||
v-tooltip.top="t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY')"
|
||||
icon="i-lucide-copy"
|
||||
slate
|
||||
faded
|
||||
@click="handleCopySecret"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<div class="flex justify-end mt-4">
|
||||
<NextButton
|
||||
blue
|
||||
:label="t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.DONE')"
|
||||
@click="props.onClose()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<woot-modal-header
|
||||
:header-title="t('INTEGRATION_SETTINGS.WEBHOOK.ADD.TITLE')"
|
||||
:header-content="
|
||||
replaceInstallationName(t('INTEGRATION_SETTINGS.WEBHOOK.FORM.DESC'))
|
||||
"
|
||||
/>
|
||||
<WebhookForm
|
||||
:is-submitting="uiFlags.creatingItem"
|
||||
:submit-label="t('INTEGRATION_SETTINGS.WEBHOOK.FORM.ADD_SUBMIT')"
|
||||
@submit="onSubmit"
|
||||
@cancel="props.onClose()"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+39
@@ -3,6 +3,8 @@ import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, url, minLength } from '@vuelidate/validators';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { getI18nKey } from 'dashboard/routes/dashboard/settings/helper/settingsHelper';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const { EXAMPLE_WEBHOOK_URL } = wootConstants;
|
||||
@@ -57,10 +59,14 @@ export default {
|
||||
url: this.value.url || '',
|
||||
name: this.value.name || '',
|
||||
subscriptions: this.value.subscriptions || [],
|
||||
secretVisible: false,
|
||||
supportedWebhookEvents: SUPPORTED_WEBHOOK_EVENTS,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
hasSecret() {
|
||||
return !!this.value.secret;
|
||||
},
|
||||
webhookURLInputPlaceholder() {
|
||||
return this.$t(
|
||||
'INTEGRATION_SETTINGS.WEBHOOK.FORM.END_POINT.PLACEHOLDER',
|
||||
@@ -81,6 +87,10 @@ export default {
|
||||
subscriptions: this.subscriptions,
|
||||
});
|
||||
},
|
||||
async copySecret() {
|
||||
await copyTextToClipboard(this.value.secret);
|
||||
useAlert(this.$t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY_SUCCESS'));
|
||||
},
|
||||
getI18nKey,
|
||||
},
|
||||
};
|
||||
@@ -111,6 +121,35 @@ export default {
|
||||
:placeholder="webhookNameInputPlaceholder"
|
||||
/>
|
||||
</label>
|
||||
<label v-if="hasSecret" class="mb-4">
|
||||
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.LABEL') }}
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
:value="
|
||||
secretVisible ? value.secret : '••••••••••••••••••••••••••••••••'
|
||||
"
|
||||
type="text"
|
||||
readonly
|
||||
class="!mb-0 font-mono"
|
||||
/>
|
||||
<NextButton
|
||||
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.TOGGLE')"
|
||||
type="button"
|
||||
:icon="secretVisible ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
||||
slate
|
||||
faded
|
||||
@click="secretVisible = !secretVisible"
|
||||
/>
|
||||
<NextButton
|
||||
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY')"
|
||||
type="button"
|
||||
icon="i-lucide-copy"
|
||||
slate
|
||||
faded
|
||||
@click="copySecret"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label :class="{ error: v$.url.$error }" class="mb-2">
|
||||
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.LABEL') }}
|
||||
</label>
|
||||
|
||||
+44
-40
@@ -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,49 @@ 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
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
@click="emit('delete', webhook, index)"
|
||||
/>
|
||||
</div>
|
||||
</BaseTableCell>
|
||||
</template>
|
||||
</BaseTableRow>
|
||||
</template>
|
||||
|
||||
+1
-23
@@ -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,18 @@ 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, [
|
||||
{ name: 'title', weight: 4 },
|
||||
'description',
|
||||
]);
|
||||
});
|
||||
const uiFlags = computed(() => getters['labels/getUIFlags'].value);
|
||||
|
||||
const deleteMessage = computed(() => ` ${selectedLabel.value.title}?`);
|
||||
@@ -72,6 +88,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,73 +106,87 @@ 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 v-if="records?.length" #count>
|
||||
<span class="text-body-main text-n-slate-11">
|
||||
{{ $t('LABEL_MGMT.COUNT', { n: records.length }) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<Button
|
||||
icon="i-lucide-circle-plus"
|
||||
:label="$t('LABEL_MGMT.HEADER_BTN_TXT')"
|
||||
size="sm"
|
||||
@click="openAddPopup"
|
||||
/>
|
||||
</template>
|
||||
</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
|
||||
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
|
||||
: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,42 @@ 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 v-if="records?.length" #count>
|
||||
<span class="text-body-main text-n-slate-11">
|
||||
{{ $t('MACROS.COUNT', { n: records.length }) }}
|
||||
</span>
|
||||
</template>
|
||||
<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')" size="sm" />
|
||||
</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 gap-6 mb-8 max-w-7xl mx-auto w-full !px-6">
|
||||
<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"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user