Compare commits

..
Author SHA1 Message Date
Shivam Mishra ff531dde50 feat: remove local implementation 2024-05-02 18:10:21 +05:30
Shivam Mishra 5c98414334 chore: add debug statements 2024-05-02 18:06:50 +05:30
Shivam Mishra bedf15eadb Revert "feat: remove timer"
This reverts commit 285017647b.
2024-05-02 15:34:32 +05:30
Shivam Mishra 52adc6b4ab feat: remove prevent default 2024-05-02 14:48:48 +05:30
Shivam Mishra 285017647b feat: remove timer 2024-05-02 14:24:50 +05:30
Shivam Mishra 2de42b7de2 feat: add more logs 2024-05-02 14:14:12 +05:30
Shivam Mishra 9c34a96242 feat: use local implementation 2024-05-02 14:12:46 +05:30
Shivam Mishra 61805d7bf3 fix: error 2024-05-02 13:27:39 +05:30
Shivam Mishra 7c6dd3d750 feat: add keydown handler in a try-catch-block 2024-05-02 13:21:16 +05:30
PranavandGitHub af39308851 chore: Add a separate Sentry project for frontend error (#9336) 2024-05-01 12:06:19 -07:00
d34d726b37 chore(snyk): Security upgrade administrate-field-active_storage from 1.0.1 to 1.0.2 (#9332)
fix: Gemfile & Gemfile.lock to reduce vulnerabilities

The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-RUBY-ACTIONCABLE-20338
- https://snyk.io/vuln/SNYK-RUBY-RACK-1061917

Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2024-05-01 12:05:40 -07:00
c9a575c310 feat: Revamp access token section in profile settings (#9328)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2024-04-30 22:02:01 +05:30
e8475a9b2d fix: Switch to instagram CDN url for attachments (#9287)
Fixes: https://github.com/chatwoot/chatwoot/issues/8512

Co-authored-by: Denis Karlyuk <DevilDK@mail.ru>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2024-04-30 08:04:05 -07:00
Vishnu NarayananandGitHub 9057c865c3 feat: Add rake task to clear ActionCable jobs (#9307) 2024-04-30 08:03:40 -07:00
PranavandGitHub 2c3e7d6554 chore: Temporarily disable the disconnect logic for inboxes (#9321)
* chore: Temporarily disable the disconnect logic for inboxes

* Remove unused spec
2024-04-30 07:49:37 -07:00
Vishnu NarayananandGitHub 0856d76332 chore: Fix typos in inboxmgmt.json (#9326) 2024-04-30 07:49:23 -07:00
c92ea11eee feat: Revamp notification and audio preferences (#9312)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2024-04-30 16:50:00 +05:30
Sivin VargheseandGitHub 4fd8c7a61b fix: TypeError cannot set properties of undefined (setting 'keydownHandlerIndex') (#9327) 2024-04-30 15:20:05 +05:30
6917ea2dbd fix: portal settings layout (#9324)
* fix: padding

* fix: max width

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2024-04-30 10:57:22 +05:30
Sivin VargheseandGitHub 12af2fe026 feat: Remove the usage of clickAway mixin with directive (#9323) 2024-04-30 10:35:14 +05:30
705f8ef948 feat: add composables for migration and update portal settings (#9299)
* feat: setup vuelitdate for vue 2.7

* feat: add all composables

* fix: portal settings layout

* feat: remove styles

* feat: use setup API for ListAllCategories

* chore: format ListAllCategories

* refactor: add useAlert

* feat: add track composable

* feat: update map getters

* fix: import

* feat: update edit portal locales page [wip]

* feat: migrate locales page

* feat: remove alert message ref

* chore: format EditPortalLocales

* refactor: use composiiton api for PortalCustomization

* refactor: remove color

* feat: update PortalSettingsCustomizationForm to use setup syntax

* refactor: no need to import defineEmits

* refactor: format component

* fix: update logic

* feat: migrate PortalSettingsBasicForm

* refactor: format PortalSettingsBasicForm

* refactor: migrate EditPortalCustomization to Vue 2.7

* feat: migrate EditPortalBasic to vue 2.7

* chore: revert changes to EditPortal

* fix: portal layout

* fix: width

* feat: use setup syntax

* fix: double border

* feat: return track method

* refactor: track usage

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2024-04-30 10:13:50 +05:30
Sivin VargheseandGitHub 2012d00109 fix: Start calender is not moving properly is last 30 days range (#9322)
* fix: Start calender is not moving properly is last 30 days range

* chore: Minor fix
2024-04-30 10:09:08 +05:30
43b79aba9e feat: Revamp hotkeys and change password in profile settings (#9311)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2024-04-29 17:58:29 +05:30
47b1f610f1 feat: Adds helper text for custom fields in contact sidebars (#9317)
* feat: Adds helper text for custom fields in contact sidebars

* Update app/javascript/dashboard/components/ui/HelperTextPopup.vue

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>

* chore: Review fix

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2024-04-29 17:32:44 +05:30
8e9b21820e feat: setup composables for vue 2.7 (#9305)
* feat: setup vuelitdate for vue 2.7

* feat: add all composables

* feat: return track method

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2024-04-29 17:00:49 +05:30
c4eadd12ed feat: Revamp basic profile, avatar and message signature (#9310)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2024-04-29 16:41:55 +05:30
28728635c9 feat: Custom date picker (#9247)
* feat: Custom date picker

* chore: Calender footer

* chore: Minor fix

* chore: Reset date picker

* chore: Minor fix

* feat: Toggle button

* chore: Clean up

* chore: Use font inter

* chore: Cleanup and fix bugs

* fix: custom date range reset the calendar

* chore: fix logic bug

* feat: Add manual date range

* fix: styles in rtl

* chore: Helper specs

* chore: Clean up

* chore: Review fixes

* chore: remove magic strings

* chore: Add comments

* chore: Review fixes

* chore: Clean up

* chore: remove magic strings

* fix: Use outline instead of border

* chore: Minor style fix

* chore: disable pointer events for the disabled dates

* chore: Fix code climate

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2024-04-29 14:43:57 +05:30
a5ab8201c6 fix: [Snyk] Fix for 1 vulnerabilities (#9316)
fix: Gemfile & Gemfile.lock to reduce vulnerabilities

The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-RUBY-SIDEKIQ-6689289

Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2024-04-29 14:24:06 +05:30
116 changed files with 4262 additions and 1625 deletions
+3 -3
View File
@@ -77,7 +77,7 @@ gem 'jwt'
gem 'pundit'
# super admin
gem 'administrate', '>= 0.20.1'
gem 'administrate-field-active_storage', '>= 1.0.1'
gem 'administrate-field-active_storage', '>= 1.0.2'
gem 'administrate-field-belongs_to_search', '>= 0.9.0'
##--- gems for pubsub service ---##
@@ -113,10 +113,10 @@ gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
gem 'scout_apm', require: false
gem 'sentry-rails', '>= 5.14.0', require: false
gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.14.0', require: false
gem 'sentry-sidekiq', '>= 5.15.0', require: false
##-- background job processing --##
gem 'sidekiq', '>= 7.2.1'
gem 'sidekiq', '>= 7.2.4'
# We want cron jobs
gem 'sidekiq-cron', '>= 1.12.0'
+17 -15
View File
@@ -113,7 +113,7 @@ GEM
kaminari (~> 1.2.2)
sassc-rails (~> 2.1)
selectize-rails (~> 0.6)
administrate-field-active_storage (1.0.1)
administrate-field-active_storage (1.0.2)
administrate (>= 0.2.2)
rails (>= 7.0)
administrate-field-belongs_to_search (0.9.0)
@@ -150,6 +150,7 @@ GEM
statsd-ruby (~> 1.1)
base64 (0.1.1)
bcrypt (3.1.20)
bigdecimal (3.1.7)
bindex (0.8.1)
bootsnap (1.16.0)
msgpack (~> 1.2)
@@ -447,13 +448,13 @@ GEM
net-imap
net-pop
net-smtp
marcel (1.0.2)
marcel (1.0.4)
maxminddb (0.1.22)
memoist (0.16.2)
meta_request (0.8.2)
rack-contrib (>= 1.1, < 3)
railties (>= 3.0.0, < 8)
method_source (1.0.0)
method_source (1.1.0)
mime-types (3.4.1)
mime-types-data (~> 3.2015)
mime-types-data (3.2023.0218.1)
@@ -473,7 +474,7 @@ GEM
uri
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.9)
net-imap (0.4.10)
date
net-protocol
net-pop (0.1.2)
@@ -488,7 +489,7 @@ GEM
sidekiq
newrelic_rpm (9.6.0)
base64
nio4r (2.7.0)
nio4r (2.7.1)
nokogiri (1.16.4)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
@@ -603,7 +604,7 @@ GEM
ffi (~> 1.0)
redis (5.0.6)
redis-client (>= 0.9.0)
redis-client (0.19.1)
redis-client (0.22.1)
connection_pool
redis-namespace (1.10.0)
redis (>= 4)
@@ -702,18 +703,19 @@ GEM
activesupport (>= 4)
selectize-rails (0.12.6)
semantic_range (3.0.0)
sentry-rails (5.14.0)
sentry-rails (5.17.3)
railties (>= 5.0)
sentry-ruby (~> 5.14.0)
sentry-ruby (5.14.0)
sentry-ruby (~> 5.17.3)
sentry-ruby (5.17.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-sidekiq (5.14.0)
sentry-ruby (~> 5.14.0)
sentry-sidekiq (5.17.3)
sentry-ruby (~> 5.17.3)
sidekiq (>= 3.0)
sexp_processor (4.17.0)
shoulda-matchers (5.3.0)
activesupport (>= 5.2.0)
sidekiq (7.2.1)
sidekiq (7.2.4)
concurrent-ruby (< 2)
connection_pool (>= 2.3.0)
rack (>= 2.2.4)
@@ -835,7 +837,7 @@ DEPENDENCIES
activerecord-import
acts-as-taggable-on
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.1)
administrate-field-active_storage (>= 1.0.2)
administrate-field-belongs_to_search (>= 0.9.0)
annotate
attr_extras
@@ -931,9 +933,9 @@ DEPENDENCIES
seed_dump
sentry-rails (>= 5.14.0)
sentry-ruby
sentry-sidekiq (>= 5.14.0)
sentry-sidekiq (>= 5.15.0)
shoulda-matchers
sidekiq (>= 7.2.1)
sidekiq (>= 7.2.4)
sidekiq-cron (>= 1.12.0)
simplecov (= 0.17.1)
slack-ruby-client (~> 2.2.0)
@@ -12,7 +12,7 @@
</div>
<div class="flex items-center justify-between w-full">
<span
class="w-full font-medium text-sm mb-0"
class="w-full inline-flex gap-1.5 items-start font-medium whitespace-nowrap text-sm mb-0"
:class="
$v.editedValue.$error
? 'text-red-400 dark:text-red-500'
@@ -20,6 +20,11 @@
"
>
{{ label }}
<helper-text-popup
v-if="description"
:message="description"
class="mt-0.5"
/>
</span>
<woot-button
v-if="showCopyAndDeleteButton"
@@ -41,7 +46,7 @@
ref="inputfield"
v-model="editedValue"
:type="inputType"
class="!h-8 ltr:rounded-r-none rtl:rounded-l-none !mb-0 !text-sm"
class="!h-8 ltr:!rounded-r-none rtl:!rounded-l-none !mb-0 !text-sm"
autofocus="true"
:class="{ error: $v.editedValue.$error }"
@blur="$v.editedValue.$touch"
@@ -130,22 +135,25 @@
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { format, parseISO } from 'date-fns';
import { required, url } from 'vuelidate/lib/validators';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
import HelperTextPopup from 'dashboard/components/ui/HelperTextPopup.vue';
import { isValidURL } from '../helper/URLHelper';
import customAttributeMixin from '../mixins/customAttributeMixin';
const DATE_FORMAT = 'yyyy-MM-dd';
export default {
components: {
MultiselectDropdown,
HelperTextPopup,
},
mixins: [customAttributeMixin, clickaway],
mixins: [customAttributeMixin],
props: {
label: { type: String, required: true },
description: { type: String, default: '' },
values: { type: Array, default: () => [] },
value: { type: [String, Number, Boolean], default: '' },
showActions: { type: Boolean, default: false },
@@ -0,0 +1,31 @@
<template>
<div class="flex flex-col items-start w-full gap-6">
<div class="flex flex-col w-full gap-4">
<h4 v-if="title" class="text-lg font-medium text-ash-900">
{{ title }}
</h4>
<div class="flex flex-row items-center justify-between">
<div class="flex-grow h-px bg-ash-200" />
</div>
<p v-if="description" class="mb-0 text-sm font-normal text-ash-900">
{{ description }}
</p>
</div>
<div class="flex flex-col w-full gap-6">
<slot />
</div>
</div>
</template>
<script setup>
defineProps({
title: {
type: String,
default: '',
},
description: {
type: String,
default: '',
},
});
</script>
@@ -88,7 +88,6 @@
<script>
import { getUnixTime } from 'date-fns';
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
@@ -109,7 +108,7 @@ export default {
WootDropdownMenu,
CustomSnoozeModal,
},
mixins: [clickaway, alertMixin, keyboardEventListenerMixins],
mixins: [alertMixin, keyboardEventListenerMixins],
props: { conversationId: { type: [String, Number], required: true } },
data() {
return {
@@ -24,6 +24,7 @@ import SubmitButton from './buttons/FormSubmitButton';
import Tabs from './ui/Tabs/Tabs';
import TabsItem from './ui/Tabs/TabsItem';
import Thumbnail from './widgets/Thumbnail.vue';
import DatePicker from './ui/DatePicker/DatePicker.vue';
const WootUIKit = {
AvatarUploader,
@@ -51,6 +52,7 @@ const WootUIKit = {
Tabs,
TabsItem,
Thumbnail,
DatePicker,
install(Vue) {
const keys = Object.keys(this);
keys.pop(); // remove 'install' from keys
@@ -47,7 +47,6 @@
<script>
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
@@ -67,7 +66,7 @@ export default {
AvailabilityStatusBadge,
},
mixins: [clickaway, alertMixin],
mixins: [alertMixin],
data() {
return {
@@ -106,7 +106,6 @@
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { mapGetters } from 'vuex';
import Auth from '../../../api/auth';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
@@ -119,7 +118,6 @@ export default {
WootDropdownItem,
AvailabilityStatus,
},
mixins: [clickaway],
props: {
show: {
type: Boolean,
@@ -0,0 +1,304 @@
<script setup>
import { ref, watch } from 'vue';
import {
getActiveDateRange,
moveCalendarDate,
DATE_RANGE_TYPES,
CALENDAR_TYPES,
CALENDAR_PERIODS,
} from './helpers/DatePickerHelper';
import {
isValid,
startOfMonth,
subDays,
startOfDay,
endOfDay,
isBefore,
subMonths,
addMonths,
isSameMonth,
differenceInCalendarMonths,
setMonth,
setYear,
isAfter,
} from 'date-fns';
import DatePickerButton from './components/DatePickerButton.vue';
import CalendarDateInput from './components/CalendarDateInput.vue';
import CalendarDateRange from './components/CalendarDateRange.vue';
import CalendarYear from './components/CalendarYear.vue';
import CalendarMonth from './components/CalendarMonth.vue';
import CalendarWeek from './components/CalendarWeek.vue';
import CalendarFooter from './components/CalendarFooter.vue';
const { LAST_7_DAYS, LAST_30_DAYS, CUSTOM_RANGE } = DATE_RANGE_TYPES;
const { START_CALENDAR, END_CALENDAR } = CALENDAR_TYPES;
const { WEEK, MONTH, YEAR } = CALENDAR_PERIODS;
const showDatePicker = ref(false);
const calendarViews = ref({ start: WEEK, end: WEEK });
const currentDate = ref(new Date());
const selectedStartDate = ref(startOfDay(subDays(currentDate.value, 6))); // LAST_7_DAYS
const selectedEndDate = ref(endOfDay(currentDate.value));
// Setting the start and end calendar
const startCurrentDate = ref(startOfDay(selectedStartDate.value));
const endCurrentDate = ref(
isSameMonth(selectedStartDate.value, selectedEndDate.value)
? startOfMonth(addMonths(selectedEndDate.value, 1)) // Moves to the start of the next month if dates are in the same month (Mounted case LAST_7_DAYS)
: startOfMonth(selectedEndDate.value) // Always shows the month of the end date starting from the first (Mounted case LAST_7_DAYS)
);
const selectingEndDate = ref(false);
const selectedRange = ref(LAST_7_DAYS);
const hoveredEndDate = ref(null);
const manualStartDate = ref(selectedStartDate.value);
const manualEndDate = ref(selectedEndDate.value);
const emit = defineEmits(['change']);
// Watcher will set the start and end dates based on the selected range
watch(selectedRange, newRange => {
if (newRange !== CUSTOM_RANGE) {
// If selecting a range other than last 7 days or last 30 days, set the start and end dates to the selected start and end dates
// If selecting last 7 days or last 30 days is, set the start date to the selected start date
// and the end date to one month ahead of the start date if the start date and end date are in the same month
// Otherwise set the end date to the selected end date
const isLastSevenOrThirtyDays =
newRange === LAST_7_DAYS || newRange === LAST_30_DAYS;
startCurrentDate.value = selectedStartDate.value;
endCurrentDate.value =
isLastSevenOrThirtyDays &&
isSameMonth(selectedStartDate.value, selectedEndDate.value)
? startOfMonth(addMonths(selectedStartDate.value, 1))
: selectedEndDate.value;
selectingEndDate.value = false;
} else if (!selectingEndDate.value) {
// If selecting a custom range and not selecting an end date, set the start date to the selected start date
startCurrentDate.value = startOfDay(currentDate.value);
}
});
// Watcher will set the input values based on the selected start and end dates
watch(
[selectedStartDate, selectedEndDate],
([newStart, newEnd]) => {
if (isValid(newStart)) {
manualStartDate.value = newStart;
} else {
manualStartDate.value = selectedStartDate.value;
}
if (isValid(newEnd)) {
manualEndDate.value = newEnd;
} else {
manualEndDate.value = selectedEndDate.value;
}
},
{ immediate: true }
);
// Watcher to ensure dates are always in logical order
// This watch is will ensure that the start date is always before the end date
watch(
[startCurrentDate, endCurrentDate],
([newStart, newEnd], [oldStart, oldEnd]) => {
const monthDifference = differenceInCalendarMonths(newEnd, newStart);
if (newStart !== oldStart) {
if (isAfter(newStart, newEnd) || monthDifference === 0) {
// Adjust the end date forward if the start date is adjusted and is after the end date or in the same month
endCurrentDate.value = addMonths(newStart, 1);
}
}
if (newEnd !== oldEnd) {
if (isBefore(newEnd, newStart) || monthDifference === 0) {
// Adjust the start date backward if the end date is adjusted and is before the start date or in the same month
startCurrentDate.value = subMonths(newEnd, 1);
}
}
},
{ immediate: true, deep: true }
);
const setDateRange = range => {
selectedRange.value = range.value;
const { start, end } = getActiveDateRange(range.value, currentDate.value);
selectedStartDate.value = start;
selectedEndDate.value = end;
};
const moveCalendar = (calendar, direction, period = MONTH) => {
const { start, end } = moveCalendarDate(
calendar,
startCurrentDate.value,
endCurrentDate.value,
direction,
period
);
startCurrentDate.value = start;
endCurrentDate.value = end;
};
const selectDate = day => {
selectedRange.value = CUSTOM_RANGE;
if (!selectingEndDate.value || day < selectedStartDate.value) {
selectedStartDate.value = day;
selectedEndDate.value = null;
selectingEndDate.value = true;
} else {
selectedEndDate.value = day;
selectingEndDate.value = false;
}
};
const setViewMode = (calendar, mode) => {
selectedRange.value = CUSTOM_RANGE;
calendarViews.value[calendar] = mode;
};
const openCalendar = (index, calendarType, period = MONTH) => {
const current =
calendarType === START_CALENDAR
? startCurrentDate.value
: endCurrentDate.value;
const newDate =
period === MONTH
? setMonth(startOfMonth(current), index)
: setYear(current, index);
if (calendarType === START_CALENDAR) {
startCurrentDate.value = newDate;
} else {
endCurrentDate.value = newDate;
}
setViewMode(calendarType, period === MONTH ? WEEK : MONTH);
};
const updateManualInput = (newDate, calendarType) => {
if (calendarType === START_CALENDAR) {
selectedStartDate.value = newDate;
startCurrentDate.value = newDate;
} else {
selectedEndDate.value = newDate;
endCurrentDate.value = newDate;
}
selectingEndDate.value = false;
};
const handleManualInputError = message => {
bus.$emit('newToastMessage', message);
};
const resetDatePicker = () => {
startCurrentDate.value = startOfDay(currentDate.value); // Resets to today at start of the day
endCurrentDate.value = addMonths(startOfDay(currentDate.value), 1); // Resets to one month ahead
selectedStartDate.value = startOfDay(subDays(currentDate.value, 6));
selectedEndDate.value = endOfDay(currentDate.value);
selectingEndDate.value = false;
selectedRange.value = LAST_7_DAYS;
// Reset view modes if they are being used to toggle between different calendar views
calendarViews.value = { start: WEEK, end: WEEK };
};
const emitDateRange = () => {
if (!isValid(selectedStartDate.value) || !isValid(selectedEndDate.value)) {
bus.$emit('newToastMessage', 'Please select a valid time range');
} else {
showDatePicker.value = false;
emit('dateRangeChanged', [selectedStartDate.value, selectedEndDate.value]);
}
};
</script>
<template>
<div class="relative font-inter">
<DatePickerButton
:selected-start-date="selectedStartDate"
:selected-end-date="selectedEndDate"
:selected-range="selectedRange"
@open="showDatePicker = !showDatePicker"
/>
<div
v-if="showDatePicker"
class="flex absolute top-9 ltr:left-0 rtl:right-0 z-30 shadow-md select-none w-[880px] h-[490px] rounded-2xl border border-slate-50 dark:border-slate-800 bg-white dark:bg-slate-800"
>
<CalendarDateRange
:selected-range="selectedRange"
@set-range="setDateRange"
/>
<div
class="flex flex-col w-[680px] ltr:border-l rtl:border-r border-slate-50 dark:border-slate-700/50"
>
<div class="flex justify-around h-fit">
<!-- Calendars for Start and End Dates -->
<div
v-for="calendar in [START_CALENDAR, END_CALENDAR]"
:key="`${calendar}-calendar`"
class="flex flex-col items-center"
>
<CalendarDateInput
:calendar-type="calendar"
:date-value="
calendar === START_CALENDAR ? manualStartDate : manualEndDate
"
:compare-date="
calendar === START_CALENDAR ? manualEndDate : manualStartDate
"
:is-disabled="selectedRange !== CUSTOM_RANGE"
@update="
calendar === START_CALENDAR
? (manualStartDate = $event)
: (manualEndDate = $event)
"
@validate="updateManualInput($event, calendar)"
@error="handleManualInputError($event)"
/>
<div class="py-5 border-b border-slate-50 dark:border-slate-700/50">
<div
class="flex flex-col items-center gap-2 px-5 min-w-[340px] max-h-[352px]"
:class="
calendar === START_CALENDAR &&
'ltr:border-r rtl:border-l border-slate-50 dark:border-slate-700/50'
"
>
<CalendarYear
v-if="calendarViews[calendar] === YEAR"
:calendar-type="calendar"
:start-current-date="startCurrentDate"
:end-current-date="endCurrentDate"
@select-year="openCalendar($event, calendar, YEAR)"
/>
<CalendarMonth
v-else-if="calendarViews[calendar] === MONTH"
:calendar-type="calendar"
:start-current-date="startCurrentDate"
:end-current-date="endCurrentDate"
@select-month="openCalendar($event, calendar)"
@set-view="setViewMode"
@prev="moveCalendar(calendar, 'prev', YEAR)"
@next="moveCalendar(calendar, 'next', YEAR)"
/>
<CalendarWeek
v-else-if="calendarViews[calendar] === WEEK"
:calendar-type="calendar"
:current-date="currentDate"
:start-current-date="startCurrentDate"
:end-current-date="endCurrentDate"
:selected-start-date="selectedStartDate"
:selected-end-date="selectedEndDate"
:selecting-end-date="selectingEndDate"
:hovered-end-date="hoveredEndDate"
@update-hovered-end-date="hoveredEndDate = $event"
@select-date="selectDate"
@set-view="setViewMode"
@prev="moveCalendar(calendar, 'prev')"
@next="moveCalendar(calendar, 'next')"
/>
</div>
</div>
</div>
</div>
<CalendarFooter @change="emitDateRange" @clear="resetDatePicker" />
</div>
</div>
</div>
</template>
@@ -0,0 +1,79 @@
<script setup>
import { CALENDAR_PERIODS } from '../helpers/DatePickerHelper';
defineProps({
calendarType: {
type: String,
default: 'start',
},
firstButtonLabel: {
type: String,
default: '',
},
buttonLabel: {
type: String,
default: '',
},
viewMode: {
type: String,
default: '',
},
});
const emit = defineEmits(['prev', 'next', 'set-view']);
const { YEAR } = CALENDAR_PERIODS;
const onClickPrev = type => {
emit('prev', type);
};
const onClickNext = type => {
emit('next', type);
};
const onClickSetView = (type, mode) => {
emit('set-view', type, mode);
};
</script>
<template>
<div class="flex items-start justify-between w-full h-9">
<button
class="p-1 rounded-lg hover:bg-slate-75 dark:hover:bg-slate-700/50 rtl:rotate-180"
@click="onClickPrev(calendarType)"
>
<fluent-icon
icon="chevron-left"
size="14"
class="text-slate-900 dark:text-slate-50"
/>
</button>
<div class="flex items-center gap-1">
<button
v-if="firstButtonLabel"
class="p-0 text-sm font-medium text-center text-slate-800 dark:text-slate-50 hover:text-woot-600 dark:hover:text-woot-600"
@click="onClickSetView(calendarType, viewMode)"
>
{{ firstButtonLabel }}
</button>
<button
v-if="buttonLabel"
class="p-0 text-sm font-medium text-center text-slate-800 dark:text-slate-50"
:class="{ 'hover:text-woot-600 dark:hover:text-woot-600': viewMode }"
@click="onClickSetView(calendarType, YEAR)"
>
{{ buttonLabel }}
</button>
</div>
<button
class="p-1 rounded-lg hover:bg-slate-75 dark:hover:bg-slate-700/50 rtl:rotate-180"
@click="onClickNext(calendarType)"
>
<fluent-icon
icon="chevron-right"
size="14"
class="text-slate-900 dark:text-slate-50"
/>
</button>
</div>
</template>
@@ -0,0 +1,74 @@
<script setup>
import { computed } from 'vue';
import { parse, isValid, isAfter, isBefore } from 'date-fns';
import {
getIntlDateFormatForLocale,
CALENDAR_TYPES,
} from '../helpers/DatePickerHelper';
const props = defineProps({
calendarType: {
type: String,
default: '',
},
dateValue: Date,
compareDate: Date,
isDisabled: Boolean,
});
const emit = defineEmits(['update', 'validate', 'error']);
const { START_CALENDAR, END_CALENDAR } = CALENDAR_TYPES;
const dateFormat = computed(() => getIntlDateFormatForLocale()?.toUpperCase());
const localDateValue = computed({
get: () => props.dateValue?.toLocaleDateString(navigator.language) || '',
set: newValue => {
const format = getIntlDateFormatForLocale();
const parsedDate = parse(newValue, format, new Date());
if (isValid(parsedDate)) {
emit('update', parsedDate);
}
},
});
const validateDate = () => {
if (!isValid(props.dateValue)) {
emit('error', `Please enter the date in valid format: ${dateFormat.value}`);
return;
}
const { calendarType, compareDate, dateValue } = props;
const isStartCalendar = calendarType === START_CALENDAR;
const isEndCalendar = calendarType === END_CALENDAR;
if (compareDate && isStartCalendar && isAfter(dateValue, compareDate)) {
emit('error', 'Start date must be before the end date.');
} else if (compareDate && isEndCalendar && isBefore(dateValue, compareDate)) {
emit('error', 'End date must be after the start date.');
} else {
emit('validate', dateValue);
}
};
</script>
<template>
<div class="h-[82px] flex flex-col items-start px-5 gap-1.5 pt-4 w-full">
<span class="text-sm font-medium text-slate-800 dark:text-slate-50">
{{
calendarType === START_CALENDAR
? $t('DATE_PICKER.DATE_RANGE_INPUT.START')
: $t('DATE_PICKER.DATE_RANGE_INPUT.END')
}}
</span>
<input
v-model="localDateValue"
type="text"
class="reset-base border bg-slate-25 dark:bg-slate-900 ring-offset-ash-900 border-slate-50 dark:border-slate-700/50 w-full disabled:text-slate-200 dark:disabled:text-slate-700 disabled:cursor-not-allowed text-slate-800 dark:text-slate-50 px-1.5 py-1 text-sm rounded-xl h-10"
:placeholder="dateFormat"
:disabled="isDisabled"
@keypress.enter="validateDate"
/>
</div>
</template>
@@ -0,0 +1,41 @@
<script setup>
import { dateRanges } from '../helpers/DatePickerHelper';
defineProps({
selectedRange: {
type: String,
default: '',
},
});
const emit = defineEmits(['set-range']);
const setDateRange = range => {
emit('set-range', range);
};
</script>
<template>
<div class="w-[200px] flex flex-col items-start">
<h4
class="w-full px-5 py-4 text-sm font-medium capitalize text-start text-slate-600 dark:text-slate-200"
>
{{ $t('DATE_PICKER.DATE_RANGE_OPTIONS.TITLE') }}
</h4>
<div class="flex flex-col items-start w-full">
<button
v-for="range in dateRanges"
:key="range.label"
class="w-full px-5 py-3 text-sm font-medium truncate border-none rounded-none text-start hover:bg-slate-50 dark:hover:bg-slate-700"
:class="
range.value === selectedRange
? 'text-slate-800 dark:text-slate-50 bg-slate-50 dark:bg-slate-700'
: 'text-slate-600 dark:text-slate-200'
"
@click="setDateRange(range)"
>
{{ $t(range.label) }}
</button>
</div>
</div>
</template>
@@ -0,0 +1,28 @@
<script setup>
const emit = defineEmits(['clear', 'apply']);
const onClickClear = () => {
emit('clear');
};
const onClickApply = () => {
emit('change');
};
</script>
<template>
<div class="h-[56px] flex justify-between px-5 py-3 items-center">
<button
class="p-1.5 rounded-lg w-fit text-sm font-medium text-slate-600 dark:text-slate-200 hover:text-slate-800 dark:hover:text-slate-100"
@click="onClickClear"
>
{{ $t('DATE_PICKER.CLEAR_BUTTON') }}
</button>
<button
class="p-1.5 rounded-lg w-fit text-sm font-medium text-woot-500 dark:text-woot-300 hover:text-woot-700 dark:hover:text-woot-500"
@click="onClickApply"
>
{{ $t('DATE_PICKER.APPLY_BUTTON') }}
</button>
</div>
</template>
@@ -0,0 +1,86 @@
<script setup>
import { computed } from 'vue';
import { format, getMonth, setMonth, startOfMonth } from 'date-fns';
import {
yearName,
CALENDAR_TYPES,
CALENDAR_PERIODS,
} from '../helpers/DatePickerHelper';
import CalendarAction from './CalendarAction.vue';
const props = defineProps({
calendarType: {
type: String,
default: 'start',
},
startCurrentDate: Date,
endCurrentDate: Date,
});
const { START_CALENDAR } = CALENDAR_TYPES;
const { MONTH, YEAR } = CALENDAR_PERIODS;
const months = Array.from({ length: 12 }, (_, index) =>
format(setMonth(startOfMonth(new Date()), index), 'MMM')
);
const activeMonthIndex = computed(() => {
const date =
props.calendarType === START_CALENDAR
? props.startCurrentDate
: props.endCurrentDate;
return getMonth(date);
});
const emit = defineEmits(['select-month', 'prev', 'next', 'set-view']);
const setViewMode = (type, mode) => {
emit('set-view', type, mode);
};
const onClickPrev = () => {
emit('prev');
};
const onClickNext = () => {
emit('next');
};
const selectMonth = index => {
emit('select-month', index);
};
</script>
<template>
<div class="flex flex-col w-full gap-2 max-h-[312px]">
<CalendarAction
:view-mode="YEAR"
:calendar-type="calendarType"
:button-label="
yearName(
calendarType === START_CALENDAR ? startCurrentDate : endCurrentDate,
MONTH
)
"
@set-view="setViewMode"
@prev="onClickPrev"
@next="onClickNext"
/>
<div class="grid w-full grid-cols-3 gap-x-3 gap-y-2 auto-rows-[61px]">
<button
v-for="(month, index) in months"
:key="index"
class="p-2 text-sm font-medium text-center text-slate-800 dark:text-slate-50 w-[92px] h-10 rounded-lg py-2.5 px-2 hover:bg-slate-75 dark:hover:bg-slate-700"
:class="{
'bg-woot-600 dark:bg-woot-600 text-white dark:text-white hover:bg-woot-500 dark:bg-woot-700':
index === activeMonthIndex,
}"
@click="selectMonth(index)"
>
{{ month }}
</button>
</div>
</div>
</template>
@@ -0,0 +1,172 @@
<script setup>
import {
monthName,
yearName,
getWeeksForMonth,
isToday,
dayIsInRange,
isCurrentMonth,
isLastDayOfMonth,
isHoveringDayInRange,
isHoveringNextDayInRange,
CALENDAR_TYPES,
CALENDAR_PERIODS,
} from '../helpers/DatePickerHelper';
import CalendarWeekLabel from './CalendarWeekLabel.vue';
import CalendarAction from './CalendarAction.vue';
const props = defineProps({
calendarType: {
type: String,
default: 'start',
},
currentDate: Date,
startCurrentDate: Date,
endCurrentDate: Date,
selectedStartDate: Date,
selectingEndDate: Boolean,
selectedEndDate: Date,
hoveredEndDate: Date,
});
const emit = defineEmits([
'update-hovered-end-date',
'select-date',
'prev',
'next',
'set-view',
]);
const { START_CALENDAR } = CALENDAR_TYPES;
const { MONTH } = CALENDAR_PERIODS;
const emitHoveredEndDate = day => {
emit('update-hovered-end-date', day);
};
const emitSelectDate = day => {
emit('select-date', day);
};
const onClickPrev = () => {
emit('prev');
};
const onClickNext = () => {
emit('next');
};
const setViewMode = (type, mode) => {
emit('set-view', type, mode);
};
const weeks = calendarType => {
return getWeeksForMonth(
calendarType === START_CALENDAR
? props.startCurrentDate
: props.endCurrentDate
);
};
const isSelectedStartOrEndDate = day => {
return (
dayIsInRange(day, props.selectedStartDate, props.selectedStartDate) ||
dayIsInRange(day, props.selectedEndDate, props.selectedEndDate)
);
};
const isInRange = day => {
return dayIsInRange(day, props.selectedStartDate, props.selectedEndDate);
};
const isInCurrentMonth = day => {
return isCurrentMonth(
day,
props.calendarType === START_CALENDAR
? props.startCurrentDate
: props.endCurrentDate
);
};
const isHoveringInRange = day => {
return isHoveringDayInRange(
day,
props.selectedStartDate,
props.selectingEndDate,
props.hoveredEndDate
);
};
const isNextDayInRange = day => {
return isHoveringNextDayInRange(
day,
props.selectedStartDate,
props.selectedEndDate,
props.hoveredEndDate
);
};
const dayClasses = day => ({
'text-slate-500 dark:text-slate-400 pointer-events-none':
!isInCurrentMonth(day),
'text-slate-800 dark:text-slate-50 hover:text-slate-800 dark:hover:text-white hover:bg-woot-100 dark:hover:bg-woot-700':
isInCurrentMonth(day),
'bg-woot-600 dark:bg-woot-600 text-white dark:text-white':
isSelectedStartOrEndDate(day) && isInCurrentMonth(day),
'bg-woot-50 dark:bg-woot-800':
(isInRange(day) || isHoveringInRange(day)) &&
!isSelectedStartOrEndDate(day) &&
isInCurrentMonth(day),
'outline outline-1 outline-woot-200 -outline-offset-1 dark:outline-woot-700 text-woot-600 dark:text-woot-400':
isToday(props.currentDate, day) && !isSelectedStartOrEndDate(day),
});
</script>
<template>
<div class="flex flex-col w-full gap-2 max-h-[312px]">
<CalendarAction
:view-mode="MONTH"
:calendar-type="calendarType"
:first-button-label="
monthName(
calendarType === START_CALENDAR ? startCurrentDate : endCurrentDate
)
"
:button-label="
yearName(
calendarType === START_CALENDAR ? startCurrentDate : endCurrentDate
)
"
@prev="onClickPrev"
@next="onClickNext"
@set-view="setViewMode"
/>
<CalendarWeekLabel />
<div
v-for="week in weeks(calendarType)"
:key="week[0].getTime()"
class="grid max-w-md grid-cols-7 gap-2 mx-auto overflow-hidden rounded-lg"
>
<div
v-for="day in week"
:key="day.getTime()"
class="flex relative items-center justify-center w-9 h-8 py-1.5 px-2 font-medium text-sm rounded-lg cursor-pointer"
:class="dayClasses(day)"
@mouseenter="emitHoveredEndDate(day)"
@mouseleave="emitHoveredEndDate(null)"
@click="emitSelectDate(day)"
>
{{ day.getDate() }}
<span
v-if="
(isInRange(day) || isHoveringInRange(day)) &&
isNextDayInRange(day) &&
!isLastDayOfMonth(day) &&
isInCurrentMonth(day)
"
class="absolute bottom-0 w-6 h-8 ltr:-right-4 rtl:-left-4 bg-woot-50 dark:bg-woot-800 -z-10"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,15 @@
<script setup>
import { calendarWeeks } from '../helpers/DatePickerHelper';
</script>
<template>
<div class="max-w-md mx-auto grid grid-cols-7 gap-2">
<div
v-for="day in calendarWeeks"
:key="day.id"
class="flex items-center justify-center font-medium text-sm w-9 h-7 py-1.5 px-2"
>
{{ day.label }}
</div>
</div>
</template>
@@ -0,0 +1,86 @@
<script setup>
import { ref, computed } from 'vue';
import { getYear, addYears, subYears } from 'date-fns';
import { CALENDAR_TYPES } from '../helpers/DatePickerHelper';
import CalendarAction from './CalendarAction.vue';
const props = defineProps({
calendarType: {
type: String,
default: 'start',
},
startCurrentDate: Date,
endCurrentDate: Date,
});
const { START_CALENDAR } = CALENDAR_TYPES;
const calculateStartYear = date => {
const year = getYear(date);
return year - (year % 10); // Align with the beginning of a decade
};
const startYear = ref(
calculateStartYear(
props.calendarType === START_CALENDAR
? props.startCurrentDate
: props.endCurrentDate
)
);
const years = computed(() =>
Array.from({ length: 10 }, (_, i) => startYear.value + i)
);
const firstYear = computed(() => years.value[0]);
const lastYear = computed(() => years.value[years.value.length - 1]);
const activeYear = computed(() => {
const date =
props.calendarType === START_CALENDAR
? props.startCurrentDate
: props.endCurrentDate;
return getYear(date);
});
const onClickPrev = () => {
startYear.value = subYears(new Date(startYear.value, 0, 1), 10).getFullYear();
};
const onClickNext = () => {
startYear.value = addYears(new Date(startYear.value, 0, 1), 10).getFullYear();
};
const emit = defineEmits(['select-year']);
const selectYear = year => {
emit('select-year', year);
};
</script>
<template>
<div class="flex flex-col w-full gap-2 max-h-[312px]">
<CalendarAction
:calendar-type="calendarType"
:button-label="`${firstYear} - ${lastYear}`"
@prev="onClickPrev"
@next="onClickNext"
/>
<div class="grid grid-cols-2 gap-x-3 gap-y-2 w-full auto-rows-[47px]">
<button
v-for="year in years"
:key="year"
class="p-2 text-sm font-medium text-center text-slate-800 dark:text-slate-50 w-[144px] h-10 rounded-lg py-2.5 px-2 hover:bg-slate-75 dark:hover:bg-slate-700"
:class="{
'bg-woot-600 dark:bg-woot-600 text-white dark:text-white hover:bg-woot-500 dark:hover:bg-woot-700':
year === activeYear,
}"
@click="selectYear(year)"
>
{{ year }}
</button>
</div>
</div>
</template>
@@ -0,0 +1,71 @@
<script setup>
import { computed } from 'vue';
import { dateRanges } from '../helpers/DatePickerHelper';
import { format, isSameYear, isValid } from 'date-fns';
const props = defineProps({
selectedStartDate: Date,
selectedEndDate: Date,
selectedRange: {
type: String,
default: '',
},
});
const formatDateRange = computed(() => {
const startDate = props.selectedStartDate;
const endDate = props.selectedEndDate;
if (!isValid(startDate) || !isValid(endDate)) {
return 'Select a date range';
}
const formatString = isSameYear(startDate, endDate)
? 'MMM d' // Same year: "Apr 1"
: 'MMM d yyyy'; // Different years: "Apr 1 2025"
if (isSameYear(startDate, new Date()) && isSameYear(endDate, new Date())) {
// Both dates are in the current year
return `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}`;
}
// At least one date is not in the current year
return `${format(startDate, formatString)} - ${format(
endDate,
formatString
)}`;
});
const activeDateRange = computed(
() => dateRanges.find(range => range.value === props.selectedRange).label
);
const emit = defineEmits(['open']);
const openDatePicker = () => {
emit('open');
};
</script>
<template>
<button
class="inline-flex relative items-center rounded-lg gap-2 py-1.5 px-3 h-8 bg-slate-50 dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800 active:bg-slate-75 dark:active:bg-slate-800"
@click="openDatePicker"
>
<fluent-icon
class="text-slate-800 dark:text-slate-50"
icon="calendar"
size="16"
/>
<span class="text-sm font-medium text-slate-800 dark:text-slate-50">
{{ $t(activeDateRange) }}
</span>
<span class="text-sm font-medium text-slate-600 dark:text-slate-200">
{{ formatDateRange }}
</span>
<fluent-icon
class="text-slate-800 dark:text-slate-50"
icon="chevron-down"
size="14"
/>
</button>
</template>
@@ -0,0 +1,219 @@
import {
startOfDay,
subDays,
endOfDay,
subMonths,
addMonths,
subYears,
addYears,
startOfMonth,
isSameMonth,
format,
startOfWeek,
addDays,
eachDayOfInterval,
endOfMonth,
isSameDay,
isWithinInterval,
} from 'date-fns';
// Constants for calendar and date ranges
export const calendarWeeks = [
{ id: 1, label: 'M' },
{ id: 2, label: 'T' },
{ id: 3, label: 'W' },
{ id: 4, label: 'T' },
{ id: 5, label: 'F' },
{ id: 6, label: 'S' },
{ id: 7, label: 'S' },
];
export const dateRanges = [
{ label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_7_DAYS', value: 'last7days' },
{ label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_30_DAYS', value: 'last30days' },
{
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_3_MONTHS',
value: 'last3months',
},
{
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_6_MONTHS',
value: 'last6months',
},
{ label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_YEAR', value: 'lastYear' },
{ label: 'DATE_PICKER.DATE_RANGE_OPTIONS.CUSTOM_RANGE', value: 'custom' },
];
export const DATE_RANGE_TYPES = {
LAST_7_DAYS: 'last7days',
LAST_30_DAYS: 'last30days',
LAST_3_MONTHS: 'last3months',
LAST_6_MONTHS: 'last6months',
LAST_YEAR: 'lastYear',
CUSTOM_RANGE: 'custom',
};
export const CALENDAR_TYPES = {
START_CALENDAR: 'start',
END_CALENDAR: 'end',
};
export const CALENDAR_PERIODS = {
WEEK: 'week',
MONTH: 'month',
YEAR: 'year',
};
// Utility functions for date operations
export const monthName = currentDate => format(currentDate, 'MMMM');
export const yearName = currentDate => format(currentDate, 'yyyy');
export const getIntlDateFormatForLocale = () => {
const year = 2222;
const month = 12;
const day = 15;
const date = new Date(year, month - 1, day);
const formattedDate = new Intl.DateTimeFormat(navigator.language).format(
date
);
return formattedDate
.replace(`${year}`, 'yyyy')
.replace(`${month}`, 'MM')
.replace(`${day}`, 'dd');
};
// Utility functions for calendar operations
export const chunk = (array, size) =>
Array.from({ length: Math.ceil(array.length / size) }, (_, index) =>
array.slice(index * size, index * size + size)
);
export const getWeeksForMonth = (date, weekStartsOn = 1) => {
const startOfTheMonth = startOfMonth(date);
const startOfTheFirstWeek = startOfWeek(startOfTheMonth, { weekStartsOn });
const endOfTheLastWeek = addDays(startOfTheFirstWeek, 41); // Covering six weeks to fill the calendar
return chunk(
eachDayOfInterval({ start: startOfTheFirstWeek, end: endOfTheLastWeek }),
7
);
};
export const moveCalendarDate = (
calendar,
startCurrentDate,
endCurrentDate,
direction,
period
) => {
const adjustFunctions = {
month: { prev: subMonths, next: addMonths },
year: { prev: subYears, next: addYears },
};
const adjust = adjustFunctions[period][direction];
if (calendar === 'start') {
const newStart = adjust(startCurrentDate, 1);
return { start: newStart, end: endCurrentDate };
}
const newEnd = adjust(endCurrentDate, 1);
return { start: startCurrentDate, end: newEnd };
};
// Date comparison functions
export const isToday = (currentDate, date) =>
date.getDate() === currentDate.getDate() &&
date.getMonth() === currentDate.getMonth() &&
date.getFullYear() === currentDate.getFullYear();
export const isCurrentMonth = (day, referenceDate) =>
isSameMonth(day, referenceDate);
export const isLastDayOfMonth = day => {
const lastDay = endOfMonth(day);
return day.getDate() === lastDay.getDate();
};
export const dayIsInRange = (date, startDate, endDate) => {
if (!startDate || !endDate) {
return false;
}
// Normalize dates to ignore time differences
let startOfDayStart = startOfDay(startDate);
let startOfDayEnd = startOfDay(endDate);
// Swap if start is greater than end
if (startOfDayStart > startOfDayEnd) {
[startOfDayStart, startOfDayEnd] = [startOfDayEnd, startOfDayStart];
}
// Check if the date is within the interval or is the same as the start date
return (
isSameDay(date, startOfDayStart) ||
isWithinInterval(date, {
start: startOfDayStart,
end: startOfDayEnd,
})
);
};
// Handling hovering states in date range pickers
export const isHoveringDayInRange = (
day,
startDate,
endDate,
hoveredEndDate
) => {
if (endDate && hoveredEndDate && startDate <= hoveredEndDate) {
// Ensure the start date is not after the hovered end date
return isWithinInterval(day, { start: startDate, end: hoveredEndDate });
}
return false;
};
export const isHoveringNextDayInRange = (
day,
startDate,
endDate,
hoveredEndDate
) => {
if (startDate && !endDate && hoveredEndDate) {
// If a start date is selected, and we're hovering (but haven't clicked an end date yet)
const nextDay = addDays(day, 1);
return isWithinInterval(nextDay, { start: startDate, end: hoveredEndDate });
}
if (startDate && endDate) {
// Normal range checking between selected start and end dates
const nextDay = addDays(day, 1);
return isWithinInterval(nextDay, { start: startDate, end: endDate });
}
return false;
};
// Helper func to determine active date ranges based on user selection
export const getActiveDateRange = (range, currentDate) => {
const ranges = {
last7days: () => ({
start: startOfDay(subDays(currentDate, 6)),
end: endOfDay(currentDate),
}),
last30days: () => ({
start: startOfDay(subDays(currentDate, 29)),
end: endOfDay(currentDate),
}),
last3months: () => ({
start: startOfDay(subMonths(currentDate, 3)),
end: endOfDay(currentDate),
}),
last6months: () => ({
start: startOfDay(subMonths(currentDate, 6)),
end: endOfDay(currentDate),
}),
lastYear: () => ({
start: startOfDay(subMonths(currentDate, 12)),
end: endOfDay(currentDate),
}),
custom: () => ({ start: currentDate, end: currentDate }),
};
return (
ranges[range] || (() => ({ start: currentDate, end: currentDate }))
)();
};
@@ -0,0 +1,309 @@
import {
monthName,
yearName,
getIntlDateFormatForLocale,
getWeeksForMonth,
isToday,
isCurrentMonth,
isLastDayOfMonth,
dayIsInRange,
getActiveDateRange,
isHoveringDayInRange,
isHoveringNextDayInRange,
moveCalendarDate,
chunk,
} from '../DatePickerHelper';
describe('Date formatting functions', () => {
const testDate = new Date(2020, 4, 15); // May 15, 2020
beforeEach(() => {
jest.spyOn(navigator, 'language', 'get').mockReturnValue('en-US');
});
afterEach(() => {
jest.restoreAllMocks();
});
it('returns the correct month name from a date', () => {
expect(monthName(testDate)).toBe('May');
});
it('returns the correct year from a date', () => {
expect(yearName(testDate)).toBe('2020');
});
it('returns the correct date format for the current locale en-US', () => {
const expected = 'MM/dd/yyyy';
expect(getIntlDateFormatForLocale()).toBe(expected);
});
it('returns the correct date format for the current locale en-IN', () => {
jest.spyOn(navigator, 'language', 'get').mockReturnValue('en-IN');
const expected = 'dd/MM/yyyy';
expect(getIntlDateFormatForLocale()).toBe(expected);
});
});
describe('chunk', () => {
const array = [1, 2, 3, 4, 5, 6, 7, 8, 9];
it('correctly chunks an array into smaller arrays of given size', () => {
const expected = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
];
expect(chunk(array, 3)).toEqual(expected);
});
it('handles arrays that do not divide evenly by the chunk size', () => {
const expected = [[1, 2], [3, 4], [5, 6], [7, 8], [9]];
expect(chunk(array, 2)).toEqual(expected);
});
});
describe('getWeeksForMonth', () => {
it('returns the correct weeks array for a month starting on Monday', () => {
const date = new Date(2020, 3, 1); // April 2020
const weeks = getWeeksForMonth(date, 1);
expect(weeks.length).toBe(6);
expect(weeks[0][0]).toEqual(new Date(2020, 2, 30)); // Check if first day of the first week is correct
});
});
describe('moveCalendarDate', () => {
it('handles the year transition when moving the start date backward by one month from January', () => {
const startDate = new Date(2021, 0, 1);
const endDate = new Date(2021, 0, 31);
const result = moveCalendarDate(
'start',
startDate,
endDate,
'prev',
'month'
);
const expectedStartDate = new Date(2020, 11, 1);
expect(result.start.toISOString()).toEqual(expectedStartDate.toISOString());
expect(result.end.toISOString()).toEqual(endDate.toISOString());
});
it('handles the year transition when moving the start date forward by one month from January', () => {
const startDate = new Date(2020, 0, 1);
const endDate = new Date(2020, 1, 31);
const result = moveCalendarDate(
'start',
startDate,
endDate,
'next',
'month'
);
const expectedStartDate = new Date(2020, 1, 1);
expect(result.start.toISOString()).toEqual(expectedStartDate.toISOString());
expect(result.end.toISOString()).toEqual(endDate.toISOString());
});
it('handles the year transition when moving the end date forward by one month from December', () => {
const startDate = new Date(2021, 11, 1);
const endDate = new Date(2021, 11, 31);
const result = moveCalendarDate('end', startDate, endDate, 'next', 'month');
const expectedEndDate = new Date(2022, 0, 31);
expect(result.start).toEqual(startDate);
expect(result.end.toISOString()).toEqual(expectedEndDate.toISOString());
});
it('handles the year transition when moving the end date backward by one month from December', () => {
const startDate = new Date(2021, 11, 1);
const endDate = new Date(2021, 11, 31);
const result = moveCalendarDate('end', startDate, endDate, 'prev', 'month');
const expectedEndDate = new Date(2021, 10, 30);
expect(result.start).toEqual(startDate);
expect(result.end.toISOString()).toEqual(expectedEndDate.toISOString());
});
});
describe('isToday', () => {
it('returns true if the dates are the same', () => {
const today = new Date();
const alsoToday = new Date(today);
expect(isToday(today, alsoToday)).toBeTruthy();
});
it('returns false if the dates are not the same', () => {
const today = new Date();
const notToday = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate() - 1
);
expect(isToday(today, notToday)).toBeFalsy();
});
});
describe('isCurrentMonth', () => {
const referenceDate = new Date(2020, 6, 15); // July 15, 2020
it('returns true if the day is in the same month as the reference date', () => {
const testDay = new Date(2020, 6, 1);
expect(isCurrentMonth(testDay, referenceDate)).toBeTruthy();
});
it('returns false if the day is not in the same month as the reference date', () => {
const testDay = new Date(2020, 5, 30);
expect(isCurrentMonth(testDay, referenceDate)).toBeFalsy();
});
});
describe('isLastDayOfMonth', () => {
it('returns true if the day is the last day of the month', () => {
const testDay = new Date(2020, 6, 31); // July 31, 2020
expect(isLastDayOfMonth(testDay)).toBeTruthy();
});
it('returns false if the day is not the last day of the month', () => {
const testDay = new Date(2020, 6, 30); // July 30, 2020
expect(isLastDayOfMonth(testDay)).toBeFalsy();
});
});
describe('dayIsInRange', () => {
it('returns true if the date is within the range', () => {
const start = new Date(2020, 1, 10);
const end = new Date(2020, 1, 20);
const testDate = new Date(2020, 1, 15);
expect(dayIsInRange(testDate, start, end)).toBeTruthy();
});
it('returns true if the date is the same as the start date', () => {
const start = new Date(2020, 1, 10);
const end = new Date(2020, 1, 20);
const testDate = new Date(2020, 1, 10);
expect(dayIsInRange(testDate, start, end)).toBeTruthy();
});
it('returns false if the date is outside the range', () => {
const start = new Date(2020, 1, 10);
const end = new Date(2020, 1, 20);
const testDate = new Date(2020, 1, 9);
expect(dayIsInRange(testDate, start, end)).toBeFalsy();
});
});
describe('isHoveringDayInRange', () => {
const startDate = new Date(2020, 6, 10);
const endDate = new Date(2020, 6, 20);
const hoveredEndDate = new Date(2020, 6, 15);
it('returns true if the day is within the start and hovered end dates', () => {
const testDay = new Date(2020, 6, 12);
expect(
isHoveringDayInRange(testDay, startDate, endDate, hoveredEndDate)
).toBeTruthy();
});
it('returns false if the day is outside the hovered date range', () => {
const testDay = new Date(2020, 6, 16);
expect(
isHoveringDayInRange(testDay, startDate, endDate, hoveredEndDate)
).toBeFalsy();
});
});
describe('isHoveringNextDayInRange', () => {
const startDate = new Date(2020, 6, 10);
const hoveredEndDate = new Date(2020, 6, 15);
it('returns true if the next day after the given day is within the start and hovered end dates', () => {
const testDay = new Date(2020, 6, 14);
expect(
isHoveringNextDayInRange(testDay, startDate, null, hoveredEndDate)
).toBeTruthy();
});
it('returns false if the next day is outside the start and hovered end dates', () => {
const testDay = new Date(2020, 6, 15);
expect(
isHoveringNextDayInRange(testDay, startDate, null, hoveredEndDate)
).toBeFalsy();
});
});
describe('getActiveDateRange', () => {
const currentDate = new Date(2020, 5, 15, 12, 0); // May 15, 2020, at noon
beforeAll(() => {
// Mocking the current date to ensure consistency in tests
jest.useFakeTimers().setSystemTime(currentDate.getTime());
});
afterAll(() => {
jest.useRealTimers();
});
it('returns the correct range for "last7days"', () => {
const range = getActiveDateRange('last7days', new Date());
const expectedStart = new Date(2020, 5, 9);
expectedStart.setHours(0, 0, 0, 0);
const expectedEnd = new Date();
expectedEnd.setHours(23, 59, 59, 999);
expect(range.start).toEqual(expectedStart);
expect(range.end).toEqual(expectedEnd);
});
it('returns the correct range for "last30days"', () => {
const range = getActiveDateRange('last30days', new Date());
const expectedStart = new Date(2020, 4, 17);
expectedStart.setHours(0, 0, 0, 0);
const expectedEnd = new Date();
expectedEnd.setHours(23, 59, 59, 999);
expect(range.start).toEqual(expectedStart);
expect(range.end).toEqual(expectedEnd);
});
it('returns the correct range for "last3months"', () => {
const range = getActiveDateRange('last3months', new Date());
const expectedStart = new Date(2020, 2, 15);
expectedStart.setHours(0, 0, 0, 0);
const expectedEnd = new Date();
expectedEnd.setHours(23, 59, 59, 999);
expect(range.start).toEqual(expectedStart);
expect(range.end).toEqual(expectedEnd);
});
it('returns the correct range for "last6months"', () => {
const range = getActiveDateRange('last6months', new Date());
const expectedStart = new Date(2019, 11, 15);
expectedStart.setHours(0, 0, 0, 0);
const expectedEnd = new Date();
expectedEnd.setHours(23, 59, 59, 999);
expect(range.start).toEqual(expectedStart);
expect(range.end).toEqual(expectedEnd);
});
it('returns the correct range for "lastYear"', () => {
const range = getActiveDateRange('lastYear', new Date());
const expectedStart = new Date(2019, 5, 15);
expectedStart.setHours(0, 0, 0, 0);
const expectedEnd = new Date();
expectedEnd.setHours(23, 59, 59, 999);
expect(range.start).toEqual(expectedStart);
expect(range.end).toEqual(expectedEnd);
});
it('returns the correct range for "custom date range"', () => {
const range = getActiveDateRange('custom', new Date());
expect(range.start).toEqual(new Date(currentDate));
expect(range.end).toEqual(new Date(currentDate));
});
it('handles an unknown range label gracefully', () => {
const range = getActiveDateRange('unknown', new Date());
expect(range.start).toEqual(new Date(currentDate));
expect(range.end).toEqual(new Date(currentDate));
});
});
@@ -0,0 +1,24 @@
<script setup>
defineProps({
message: {
type: String,
default: '',
},
});
</script>
<template>
<div class="relative group w-[inherit] whitespace-normal z-20">
<fluent-icon
icon="info"
size="14"
class="mt-0.5 text-slate-600 absolute dark:text-slate-400"
/>
<div
class="bg-white dark:bg-slate-900 w-fit ltr:left-4 rtl:right-4 top-0 border p-2.5 group-hover:flex items-center hidden absolute border-slate-75 dark:border-slate-800 rounded-lg shadow-md"
>
<p class="text-slate-800 dark:text-slate-75 mb-0 text-xs">
{{ message }}
</p>
</div>
</div>
</template>
@@ -18,13 +18,11 @@
<script>
import { Chrome } from 'vue-color';
import { mixin as clickaway } from 'vue-clickaway';
export default {
components: {
Chrome,
},
mixins: [clickaway],
props: {
value: {
type: String,
@@ -33,7 +33,6 @@
import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import LabelDropdown from 'shared/components/ui/label/LabelDropdown.vue';
import { mixin as clickaway } from 'vue-clickaway';
import adminMixin from 'dashboard/mixins/isAdmin';
export default {
@@ -42,7 +41,7 @@ export default {
LabelDropdown,
},
mixins: [clickaway, adminMixin, keyboardEventListenerMixins],
mixins: [adminMixin, keyboardEventListenerMixins],
props: {
allLabels: {
@@ -45,7 +45,6 @@
<script>
import wootConstants from 'dashboard/constants/globals';
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import FilterItem from './FilterItem.vue';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
@@ -53,7 +52,7 @@ export default {
components: {
FilterItem,
},
mixins: [clickaway, uiSettingsMixin],
mixins: [uiSettingsMixin],
data() {
return {
showActionsDropdown: false,
@@ -37,7 +37,6 @@
</template>
<script>
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import EmailTranscriptModal from './EmailTranscriptModal.vue';
import ResolveAction from '../../buttons/ResolveAction.vue';
@@ -52,7 +51,7 @@ export default {
EmailTranscriptModal,
ResolveAction,
},
mixins: [alertMixin, clickaway],
mixins: [alertMixin],
data() {
return {
showEmailActionsModal: false,
@@ -153,7 +153,6 @@
<script>
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
@@ -218,7 +217,6 @@ export default {
ArticleSearchPopover,
},
mixins: [
clickaway,
inboxMixin,
uiSettingsMixin,
alertMixin,
@@ -180,7 +180,6 @@
</woot-modal>
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { mapGetters } from 'vuex';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import timeMixin from 'dashboard/mixins/time';
@@ -200,7 +199,7 @@ export default {
components: {
Thumbnail,
},
mixins: [keyboardEventListenerMixins, clickaway, timeMixin],
mixins: [keyboardEventListenerMixins, timeMixin],
props: {
show: {
type: Boolean,
@@ -51,7 +51,6 @@
<script>
import { evaluateSLAStatus } from '../helpers/SLAHelper';
import SLAPopoverCard from './SLAPopoverCard.vue';
import { mixin as clickaway } from 'vue-clickaway';
const REFRESH_INTERVAL = 60000;
@@ -59,7 +58,6 @@ export default {
components: {
SLAPopoverCard,
},
mixins: [clickaway],
props: {
chat: {
type: Object,
@@ -101,14 +101,12 @@
import { mapGetters } from 'vuex';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Spinner from 'shared/components/Spinner.vue';
import { mixin as clickaway } from 'vue-clickaway';
export default {
components: {
Thumbnail,
Spinner,
},
mixins: [clickaway],
props: {
selectedInboxes: {
type: Array,
@@ -74,11 +74,9 @@
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { mapGetters } from 'vuex';
export default {
mixins: [clickaway],
data() {
return {
query: '',
@@ -59,10 +59,8 @@
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { mapGetters } from 'vuex';
export default {
mixins: [clickaway],
data() {
return {
query: '',
@@ -36,7 +36,6 @@
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
@@ -45,7 +44,6 @@ export default {
WootDropdownItem,
WootDropdownMenu,
},
mixins: [clickaway],
props: {
selectedInboxes: {
type: Array,
@@ -1,5 +1,5 @@
<template>
<div class="phone-input--wrap relative">
<div class="relative phone-input--wrap">
<div class="phone-input" :class="{ 'has-error': error }">
<div
class="cursor-pointer py-2 pr-1.5 pl-2 rounded-tl-md rounded-bl-md flex items-center justify-center gap-1.5 bg-slate-25 dark:bg-slate-700 h-10 w-14"
@@ -13,7 +13,7 @@
</div>
<span
v-if="activeDialCode"
class="flex bg-white dark:bg-slate-900 font-medium text-slate-800 dark:text-slate-100 font-normal text-base leading-normal py-2 pl-2 pr-0"
class="flex py-2 pl-2 pr-0 text-base font-normal font-medium leading-normal bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100"
>
{{ activeDialCode }}
</span>
@@ -49,20 +49,20 @@
}"
@click="onSelectCountry(country)"
>
<span class="text-base mr-1">{{ country.emoji }}</span>
<span class="mr-1 text-base">{{ country.emoji }}</span>
<span
class="max-w-[7.5rem] overflow-hidden text-ellipsis whitespace-nowrap"
>
{{ country.name }}
</span>
<span class="ml-1 text-slate-300 dark:text-slate-300 text-xs">{{
<span class="ml-1 text-xs text-slate-300 dark:text-slate-300">{{
country.dial_code
}}</span>
</div>
<div v-if="filteredCountriesBySearch.length === 0">
<span
class="flex items-center justify-center text-sm text-slate-500 dark:text-slate-300 mt-4"
class="flex items-center justify-center mt-4 text-sm text-slate-500 dark:text-slate-300"
>
No results found
</span>
@@ -229,6 +229,7 @@ export default {
ArrowUp: {
action: e => {
e.preventDefault();
console.log('ArrowUp in PhoneInput');
this.moveUp();
},
allowOnFocusedInput: true,
@@ -236,6 +237,7 @@ export default {
ArrowDown: {
action: e => {
e.preventDefault();
console.log('ArrowDown in PhoneInput');
this.moveDown();
},
allowOnFocusedInput: true,
@@ -243,6 +245,7 @@ export default {
Enter: {
action: e => {
e.preventDefault();
console.log('Enter in PhoneInput');
this.onSelectCountry(
this.filteredCountriesBySearch[this.selectedIndex]
);
@@ -24,6 +24,7 @@ export default {
ArrowUp: {
action: e => {
this.moveSelectionUp();
console.log('ArrowUp in mentionSelectionKeyboardMixin');
e.preventDefault();
},
allowOnFocusedInput: true,
@@ -38,6 +39,7 @@ export default {
ArrowDown: {
action: e => {
this.moveSelectionDown();
console.log('ArrowDown in mentionSelectionKeyboardMixin');
e.preventDefault();
},
allowOnFocusedInput: true,
@@ -52,6 +54,7 @@ export default {
Enter: {
action: e => {
this.onSelect();
console.log('Enter in mentionSelectionKeyboardMixin');
e.preventDefault();
},
allowOnFocusedInput: true,
@@ -87,7 +87,6 @@
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { SHORTCUT_KEYS } from './constants';
import Hotkey from 'dashboard/components/base/Hotkey.vue';
@@ -95,7 +94,6 @@ export default {
components: {
Hotkey,
},
mixins: [clickaway],
props: {
show: {
type: Boolean,
@@ -0,0 +1,12 @@
import { getCurrentInstance } from 'vue';
export const useTrack = () => {
const vm = getCurrentInstance();
if (!vm) throw new Error('must be called in setup');
return vm.proxy.$track;
};
export function useAlert(message, action) {
bus.$emit('newToastMessage', message, action);
}
@@ -0,0 +1,30 @@
import { getCurrentInstance, reactive, watchEffect } from 'vue';
/**
* Returns the current route location. Equivalent to using `$route` inside
* templates.
*/
export function useRoute() {
const instance = getCurrentInstance();
const route = reactive(Object.assign({}, instance.proxy.$root.$route));
watchEffect(() => {
Object.assign(route, instance.proxy.$root.$route);
});
return route;
}
/**
* Returns the router instance. Equivalent to using `$router` inside
* templates.
*/
export function useRouter() {
const instance = getCurrentInstance();
const router = instance.proxy.$root.$router;
watchEffect(() => {
if (router) {
Object.assign(router, instance.proxy.$root.$router);
}
});
return router;
}
@@ -0,0 +1,23 @@
import { computed } from 'vue';
import { getCurrentInstance } from 'vue';
export const useStore = () => {
const vm = getCurrentInstance();
if (!vm) throw new Error('must be called in setup');
return vm.proxy.$store;
};
export const useStoreGetters = () => {
const store = useStore();
return Object.fromEntries(
Object.keys(store.getters).map(getter => [
getter,
computed(() => store.getters[getter]),
])
);
};
export const mapGetter = key => {
const store = useStore();
return computed(() => store.getters[key]);
};
@@ -0,0 +1,19 @@
{
"DATE_PICKER": {
"APPLY_BUTTON": "Apply",
"CLEAR_BUTTON": "Clear",
"DATE_RANGE_INPUT": {
"START": "Start Date",
"END": "End Date"
},
"DATE_RANGE_OPTIONS": {
"TITLE": "DATE RANGE",
"LAST_7_DAYS": "Last 7 days",
"LAST_30_DAYS": "Last 30 days",
"LAST_3_MONTHS": "Last 3 months",
"LAST_6_MONTHS": "Last 6 months",
"LAST_YEAR": "Last year",
"CUSTOM_RANGE": "Custom date range"
}
}
}
@@ -22,7 +22,7 @@
"body": "Add agents to the created inbox."
},
{
"title": "Voila!",
"title": "Voilà!",
"route": "settings_inbox_finish",
"body": "You are all set to go!"
}
@@ -43,7 +43,7 @@
"CHOOSE_PLACEHOLDER": "Select a page from the list",
"INBOX_NAME": "Inbox Name",
"ADD_NAME": "Add a name for your inbox",
"PICK_NAME": "Pick A Name Your Inbox",
"PICK_NAME": "Pick a Name for your Inbox",
"PICK_A_VALUE": "Pick a value"
},
"TWITTER": {
@@ -62,7 +62,7 @@
},
"CHANNEL_WEBHOOK_URL": {
"LABEL": "Webhook URL",
"PLACEHOLDER": "Enter your Webhook URL",
"PLACEHOLDER": "Please enter your Webhook URL",
"ERROR": "Please enter a valid URL"
},
"CHANNEL_DOMAIN": {
@@ -143,7 +143,7 @@
"ERROR": "This field is required"
},
"PHONE_NUMBER": {
"LABEL": "Phone number",
"LABEL": "Phone Number",
"PLACEHOLDER": "Please enter the phone number from which message will be sent.",
"ERROR": "Please provide a valid phone number that starts with a `+` sign and does not contain any spaces."
},
@@ -175,12 +175,12 @@
},
"API_KEY": {
"LABEL": "API Key",
"PLACEHOLDER": "Please enter your Bandwith API Key",
"PLACEHOLDER": "Please enter your Bandwidth API Key",
"ERROR": "This field is required"
},
"API_SECRET": {
"LABEL": "API Secret",
"PLACEHOLDER": "Please enter your Bandwith API Secret",
"PLACEHOLDER": "Please enter your Bandwidth API Secret",
"ERROR": "This field is required"
},
"APPLICATION_ID": {
@@ -239,7 +239,7 @@
},
"WEBHOOK_VERIFY_TOKEN": {
"LABEL": "Webhook Verify Token",
"PLACEHOLDER": "Enter a verify token which you want to configure for facebook webhooks.",
"PLACEHOLDER": "Enter a verify token which you want to configure for Facebook webhooks.",
"ERROR": "Please enter a valid value."
},
"API_KEY": {
@@ -269,7 +269,7 @@
},
"WEBHOOK_URL": {
"LABEL": "Webhook URL",
"SUBTITLE": "Configure the URL where you want to recieve callbacks on events.",
"SUBTITLE": "Configure the URL where you want to receive callbacks on events.",
"PLACEHOLDER": "Webhook URL"
},
"SUBMIT_BUTTON": "Create API Channel",
@@ -279,7 +279,7 @@
},
"EMAIL_CHANNEL": {
"TITLE": "Email Channel",
"DESC": "Integrate you email inbox.",
"DESC": "Integrate your email inbox.",
"CHANNEL_NAME": {
"LABEL": "Channel Name",
"PLACEHOLDER": "Please enter a channel name",
@@ -345,7 +345,7 @@
"AGENTS": {
"TITLE": "Agents",
"DESC": "Here you can add agents to manage your newly created inbox. Only these selected agents will have access to your inbox. Agents which are not part of this inbox will not be able to see or respond to messages in this inbox when they login. <br> <b>PS:</b> As an administrator, if you need access to all inboxes, you should add yourself as agent to all inboxes that you create.",
"VALIDATION_ERROR": "Add atleast one agent to your new Inbox",
"VALIDATION_ERROR": "Add at least one agent to your new Inbox",
"PICK_AGENTS": "Pick agents for the inbox"
},
"DETAILS": {
@@ -406,7 +406,7 @@
},
"SENDER_NAME_SECTION": {
"TITLE": "Sender name",
"SUB_TEXT": "Select the name shown to the your customer when they receive emails from your agents.",
"SUB_TEXT": "Select the name shown to your customer when they receive emails from your agents.",
"FOR_EG": "For eg:",
"FRIENDLY": {
"TITLE": "Friendly",
@@ -508,12 +508,12 @@
"ALLOW_MESSAGES_AFTER_RESOLVED": "Allow messages after conversation resolved",
"ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "Allow the end-users to send messages even after the conversation is resolved.",
"WHATSAPP_SECTION_SUBHEADER": "This API Key is used for the integration with the WhatsApp APIs.",
"WHATSAPP_SECTION_UPDATE_SUBHEADER": "Enter the updated key to be used for the integration with the WhatsApp APIs.",
"WHATSAPP_SECTION_UPDATE_SUBHEADER": "Enter the new API key to be used for the integration with the WhatsApp APIs.",
"WHATSAPP_SECTION_TITLE": "API Key",
"WHATSAPP_SECTION_UPDATE_TITLE": "Update API Key",
"WHATSAPP_SECTION_UPDATE_PLACEHOLDER": "Enter the new API Key here",
"WHATSAPP_SECTION_UPDATE_BUTTON": "Update",
"WHATSAPP_WEBHOOK_TITLE": "Webhook Verify Token",
"WHATSAPP_WEBHOOK_TITLE": "Webhook Verification Token",
"WHATSAPP_WEBHOOK_SUBHEADER": "This token is used to verify the authenticity of the webhook endpoint.",
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
},
@@ -585,7 +585,7 @@
"NOTE_TEXT": "To enable SMTP, please configure IMAP.",
"UPDATE": "Update IMAP settings",
"TOGGLE_AVAILABILITY": "Enable IMAP configuration for this inbox",
"TOGGLE_HELP": "Enabling IMAP will help the user to recieve email",
"TOGGLE_HELP": "Enabling IMAP will help the user to receive email",
"EDIT": {
"SUCCESS_MESSAGE": "IMAP settings updated successfully",
"ERROR_MESSAGE": "Unable to update IMAP settings"
@@ -32,6 +32,7 @@ import whatsappTemplates from './whatsappTemplates.json';
import sla from './sla.json';
import inbox from './inbox.json';
import general from './general.json';
import datePicker from './datePicker.json';
export default {
...advancedFilters,
@@ -68,4 +69,5 @@ export default {
...whatsappTemplates,
...inbox,
...general,
...datePicker,
};
@@ -10,6 +10,7 @@
"PASSWORD_UPDATE_SUCCESS": "Your password has been changed successfully",
"AFTER_EMAIL_CHANGED": "Your profile has been updated successfully, please login again as your login credentials are changed",
"FORM": {
"PICTURE": "Profile Picture",
"AVATAR": "Profile Image",
"ERROR": "Please fix form errors",
"REMOVE_IMAGE": "Remove",
@@ -56,13 +57,19 @@
},
"ACCESS_TOKEN": {
"TITLE": "Access Token",
"NOTE": "This token can be used if you are building an API based integration"
"NOTE": "This token can be used if you are building an API based integration",
"COPY": "Copy"
},
"AUDIO_NOTIFICATIONS_SECTION": {
"TITLE": "Audio Notifications",
"NOTE": "Enable audio notifications in dashboard for new messages and conversations.",
"ALERT_TYPES": {
"NONE": "None",
"MINE": "Assigned",
"ALL": "All"
},
"ALERT_TYPE": {
"TITLE": "Alert events:",
"TITLE": "Alert events for conversations:",
"NONE": "None",
"ASSIGNED": "Assigned Conversations",
"ALL_CONVERSATIONS": "All Conversations"
@@ -88,6 +95,22 @@
"SLA_MISSED_NEXT_RESPONSE": "Send email notifications when a conversation misses next response SLA",
"SLA_MISSED_RESOLUTION": "Send email notifications when a conversation misses resolution SLA"
},
"NOTIFICATIONS": {
"TITLE": "Notification preferences",
"TYPE_TITLE": "Notification type",
"EMAIL": "Email",
"PUSH": "Push notification",
"TYPES": {
"CONVERSATION_CREATED": "A new conversation is created",
"CONVERSATION_ASSIGNED": "A conversation is assigned to you",
"CONVERSATION_MENTION": "You are mentioned in a conversation",
"ASSIGNED_CONVERSATION_NEW_MESSAGE": "A new message is created in an assigned conversation",
"PARTICIPATING_CONVERSATION_NEW_MESSAGE": "A new message is created in a participating conversation",
"SLA_MISSED_FIRST_RESPONSE": "A conversation misses first response SLA",
"SLA_MISSED_NEXT_RESPONSE": "A conversation misses next response SLA",
"SLA_MISSED_RESOLUTION": "A conversation misses resolution SLA"
}
},
"API": {
"UPDATE_SUCCESS": "Your notification preferences are updated successfully",
"UPDATE_ERROR": "There is an error while updating the preferences, please try again"
@@ -106,7 +106,6 @@
<script>
import alertMixin from 'shared/mixins/alertMixin';
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import AddCannedModal from 'dashboard/routes/dashboard/settings/canned/AddCanned.vue';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
@@ -124,7 +123,7 @@ export default {
TranslateModal,
MenuItem,
},
mixins: [alertMixin, clickaway, messageFormatterMixin],
mixins: [alertMixin, messageFormatterMixin],
props: {
message: {
type: Object,
@@ -73,7 +73,6 @@ import SearchResultConversationsList from './SearchResultConversationsList.vue';
import SearchResultMessagesList from './SearchResultMessagesList.vue';
import SearchResultContactsList from './SearchResultContactsList.vue';
import { mixin as clickaway } from 'vue-clickaway';
import { mapGetters } from 'vuex';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
export default {
@@ -84,7 +83,6 @@ export default {
SearchResultConversationsList,
SearchResultMessagesList,
},
mixins: [clickaway],
data() {
return {
selectedTab: 'all',
@@ -28,7 +28,6 @@
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { VeTable } from 'vue-easytable';
import { getCountryFlag } from 'dashboard/helper/flag';
@@ -45,7 +44,7 @@ export default {
Spinner,
VeTable,
},
mixins: [clickaway, timeMixin, rtlMixin],
mixins: [timeMixin, rtlMixin],
props: {
contacts: {
type: Array,
@@ -23,15 +23,6 @@
:key="element.name"
class="bg-white dark:bg-gray-800"
>
<div v-if="element.name === 'linear'" class="conversation--actions">
<accordion-item
title="Linear"
:is-open="isContactSidebarItemOpen('is_linear_open')"
@click="value => toggleSidebarUIState('is_linear_open', value)"
>
<linear :conversation-id="conversationId" />
</accordion-item>
</div>
<div
v-if="element.name === 'conversation_actions'"
class="conversation--actions"
@@ -154,7 +145,6 @@ import CustomAttributes from './customAttributes/CustomAttributes.vue';
import draggable from 'vuedraggable';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import MacrosList from './Macros/List.vue';
import Linear from './linear/index.vue';
export default {
components: {
@@ -167,7 +157,6 @@ export default {
ConversationParticipant,
draggable,
MacrosList,
Linear,
},
mixins: [alertMixin, uiSettingsMixin],
props: {
@@ -78,7 +78,6 @@
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import Spinner from 'shared/components/Spinner.vue';
import alertMixin from 'shared/mixins/alertMixin';
import { mapGetters } from 'vuex';
@@ -92,7 +91,7 @@ export default {
ThumbnailGroup,
MultiselectDropdownItems,
},
mixins: [alertMixin, agentMixin, clickaway],
mixins: [alertMixin, agentMixin],
props: {
conversationId: {
type: [Number, String],
@@ -34,7 +34,6 @@
<script>
import alertMixin from 'shared/mixins/alertMixin';
import { mixin as clickaway } from 'vue-clickaway';
import MacroPreview from './MacroPreview.vue';
import { CONVERSATION_EVENTS } from '../../../../helper/AnalyticsHelper/events';
@@ -42,7 +41,7 @@ export default {
components: {
MacroPreview,
},
mixins: [alertMixin, clickaway],
mixins: [alertMixin],
props: {
macro: {
type: Object,
@@ -166,7 +166,6 @@
</div>
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import timeMixin from 'dashboard/mixins/time';
import ContactInfoRow from './ContactInfoRow.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
@@ -195,7 +194,7 @@ export default {
NewConversation,
ContactMergeModal,
},
mixins: [alertMixin, adminMixin, clickaway, timeMixin],
mixins: [alertMixin, adminMixin, timeMixin],
props: {
contact: {
type: Object,
@@ -7,6 +7,7 @@
:attribute-type="attribute.attribute_display_type"
:values="attribute.attribute_values"
:label="attribute.attribute_display_name"
:description="attribute.attribute_description"
:value="attribute.value"
:show-actions="true"
:attribute-regex="attribute.regex_pattern"
@@ -48,7 +48,6 @@ import { mapGetters } from 'vuex';
import Spinner from 'shared/components/Spinner.vue';
import LabelDropdown from 'shared/components/ui/label/LabelDropdown.vue';
import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue';
import { mixin as clickaway } from 'vue-clickaway';
import adminMixin from 'dashboard/mixins/isAdmin';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import conversationLabelMixin from 'dashboard/mixins/conversation/labelMixin';
@@ -60,12 +59,7 @@ export default {
AddLabel,
},
mixins: [
clickaway,
conversationLabelMixin,
adminMixin,
keyboardEventListenerMixins,
],
mixins: [conversationLabelMixin, adminMixin, keyboardEventListenerMixins],
props: {
conversationId: {
type: Number,
@@ -1,242 +0,0 @@
<template>
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header
header-title="Create/link linear issue"
header-content="Quickly create/link a linear issue from this conversation"
/>
<div class="flex flex-col h-auto overflow-auto">
<div class="flex flex-col px-8 pb-4" @submit.prevent="onSubmit">
<woot-input
v-model="title"
label="Title"
class="w-full"
placeholder="Enter title"
type="text"
@blur="$v.title.$touch"
/>
<label>
Description
<textarea
v-model="description"
rows="6"
type="text"
placeholder="Enter description"
@blur="$v.description.$touch"
/>
</label>
<label>
Team
<select v-model="teamId" @change="onChangeTeam($event)">
<option v-for="item in teams" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<label>
Assignee
<select v-model="assigneeId">
<option v-for="item in assignees" :key="item.id" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<label>
Priority
<select v-model="priority">
<option v-for="item in priorities" :key="item.id" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<label>
Label
<select v-model="labelId">
<option v-for="item in labels" :key="item.id" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<label>
Status
<select v-model="stateId">
<option v-for="item in statuses" :key="item.id" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<div class="flex items-center justify-end w-full gap-2 mt-8">
<woot-button
class="px-4 rounded-xl button clear outline-woot-200/50 outline"
@click.prevent="onClose"
>
{{ $t('SLA.FORM.CANCEL') }}
</woot-button>
<woot-button
:is-disabled="isSubmitDisabled"
class="px-4 rounded-xl"
:is-loading="isCreating"
@click.prevent="createIssue"
>
Create
</woot-button>
</div>
</div>
</div>
</div>
</template>
<script>
import alertMixin from 'shared/mixins/alertMixin';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper.js';
import { LinearClient } from '@linear/sdk';
// const chatwootAPIKey = '';
const localAPIKey = '';
const linearClient = new LinearClient({
apiKey: localAPIKey,
});
// const BASE_URL = 'https:app.chatwoot.com';
const BASE_URL = 'http://localhost:3000';
import validations from './validations';
export default {
mixins: [alertMixin],
props: {
accountId: {
type: [Number, String],
required: true,
},
conversationId: {
type: [Number, String],
required: true,
},
},
data() {
return {
title: '',
description: '',
teamId: '',
assigneeId: '',
stateId: '',
labelId: '',
priority: 0,
teams: [],
assignees: [],
labels: [],
statuses: [],
searchTerm: '',
priorities: [
{
id: 0,
name: 'No priority',
},
{
id: 1,
name: 'Urgent',
},
{
id: 2,
name: 'High',
},
{
id: 3,
name: 'Normal',
},
{
id: 4,
name: 'Low',
},
],
isCreating: false,
searchResults: [
{
id: 1,
title: 'Test',
},
],
};
},
validations,
computed: {
isSubmitDisabled() {
return this.$v.title.$invalid || this.isCreating;
},
},
mounted() {
this.getTeams();
this.getAssignees();
this.getLabels();
this.getWorkflowStates();
},
methods: {
onClose() {
this.$emit('close');
},
onChangeTeam(event) {
this.teamId = event.target.value;
this.getLabels();
this.getWorkflowStates();
this.getAssignees();
},
async getTeams() {
const teams = await linearClient.teams();
this.teams = teams.nodes;
},
async getAssignees() {
const assignees = await linearClient.users();
this.assignees = assignees.nodes;
},
async getLabels() {
const labels = await linearClient.issueLabels({
teamId: this.teamId,
});
this.labels = labels.nodes;
},
async getWorkflowStates() {
const states = await linearClient.workflowStates();
this.statuses = states.nodes;
},
async createIssue() {
this.isCreating = true;
const response = await linearClient.createIssue({
teamId: this.teamId,
title: this.title,
description: this.description,
assigneeId: this.assigneeId,
priority: this.priority,
labelIds: this.labelId ? [this.labelId] : [],
});
const stringifyResponse = JSON.stringify(response);
const {
_issue: { id: issueId },
} = JSON.parse(stringifyResponse);
this.attachmentLinkURL(issueId);
this.showAlert('Linear issue created successfully');
},
async attachmentLinkURL(issueId) {
const url =
BASE_URL +
frontendURL(
conversationUrl({
accountId: this.accountId,
id: this.conversationId,
})
);
await linearClient.attachmentLinkURL(issueId, url);
this.isCreating = false;
this.onClose();
},
openSearch() {
this.showSearchBox = true;
},
},
};
</script>
@@ -1,128 +0,0 @@
<template>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between group">
<!-- <woot-button
size="tiny"
variant="link"
color-scheme="secondary"
icon="delete"
@click="deleteIssue(issue)"
/> -->
<a
:href="issue.link"
target="_blank"
rel="noopener noreferrer"
class="inline-block rounded-sm mb-0 break-all py-0.5 text-primary-600"
>
{{ `${issue.identifier} ${issue.title}` }}
</a>
<woot-button
size="small"
variant="clear"
color-scheme="secondary"
class="!px-2 hover:!bg-transparent hidden dark:hover:!bg-transparent underline group-hover:block"
@click="deleteIssue(issue)"
>
Unlink
</woot-button>
</div>
<span class="text-sm font-normal text-slate-900 dark:text-slate-25">
{{ issue.description }}
</span>
</div>
<div class="flex justify-between w-full">
<span
class="text-sm sticky top-0 h-fit font-normal tracking-[-0.6%] min-w-[140px] truncate text-slate-600 dark:text-slate-200"
>
Status
</span>
<div class="flex flex-col w-full gap-2">
<span
class="text-sm font-normal text-left text-slate-900 dark:text-slate-25 tabular-nums"
>
Todo
</span>
</div>
</div>
<div class="flex justify-between w-full">
<span
class="text-sm sticky top-0 h-fit font-normal tracking-[-0.6%] min-w-[140px] truncate text-slate-600 dark:text-slate-200"
>
Priority
</span>
<div class="flex flex-col w-full gap-2">
<span
class="text-sm font-normal text-left text-slate-900 dark:text-slate-25 tabular-nums"
>
{{ issue.priorityLabel }}
</span>
</div>
</div>
<div class="flex justify-between w-full">
<span
class="text-sm sticky top-0 h-fit font-normal tracking-[-0.6%] min-w-[140px] truncate text-slate-600 dark:text-slate-200"
>
Assignee
</span>
<div class="flex flex-col w-full gap-2">
<span
class="text-sm font-normal text-left text-slate-900 dark:text-slate-25 tabular-nums"
>
Muhsin
</span>
</div>
</div>
<div class="flex justify-between w-full">
<span
class="text-sm sticky top-0 h-fit font-normal tracking-[-0.6%] min-w-[140px] truncate text-slate-600 dark:text-slate-200"
>
Labels
</span>
<div class="flex flex-col w-full gap-2">
<span
class="text-sm font-normal text-left text-slate-900 dark:text-slate-25 tabular-nums"
>
---
</span>
</div>
</div>
<div class="flex justify-between w-full">
<span
class="text-sm sticky top-0 h-fit font-normal tracking-[-0.6%] min-w-[140px] truncate text-slate-600 dark:text-slate-200"
>
Created At
</span>
<div class="flex flex-col w-full gap-2">
<span
class="text-sm font-normal text-left text-slate-900 dark:text-slate-25 tabular-nums"
>
{{ formatDate(issue.createdAt) }}
</span>
</div>
</div>
</div>
</template>
<script>
import { format } from 'date-fns';
export default {
props: {
issue: {
type: Object,
required: true,
},
},
methods: {
deleteIssue(issue) {
this.$emit('delete', issue);
},
formatDate(timestamp) {
return format(timestamp, 'MMM dd, hh:mm a');
},
},
};
</script>
@@ -1,147 +0,0 @@
<template>
<div class="flex flex-col">
<div class="flex flex-row gap-2">
<woot-button
v-if="!isLoading && !issues.length"
variant="smooth"
icon="add"
size="tiny"
@click="shoeCreateIssuePopup"
>
Create or Link Issue
</woot-button>
</div>
<div v-if="isLoading" class="flex items-center justify-center">
<span class="text-sm font-medium text-slate-800 dark:text-slate-100">
Loading..
</span>
</div>
<div v-if="!isLoading && issues.length" class="flex flex-col gap-2">
<issue-item
v-for="issue in issues"
:key="issue.id"
:issue="issue"
:conversation-id="conversationId"
@delete="deleteIssue"
/>
</div>
<woot-modal :show.sync="showAddPopup" :on-close="hideAddPopup">
<add-issue
:conversation-id="conversationId"
:account-id="currentAccountId"
@close="hideAddPopup"
/>
</woot-modal>
</div>
</template>
<script>
import { LinearClient } from '@linear/sdk';
import { mapGetters } from 'vuex';
import IssueItem from './IssueItem.vue';
import accountMixin from 'dashboard/mixins/account.js';
import AddIssue from './AddIssue.vue';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper.js';
// const chatwootAPIKey = '';
const localAPIKey = '';
const linearClient = new LinearClient({
apiKey: localAPIKey,
});
// const BASE_URL = 'https:app.chatwoot.com';
const BASE_URL = 'http://localhost:3000';
export default {
components: {
IssueItem,
AddIssue,
},
mixins: [accountMixin],
props: {
conversationId: {
type: [Number, String],
required: true,
},
},
data() {
return {
isLoading: false,
showAddPopup: false,
issues: [],
};
},
computed: {
...mapGetters({
currentAccountId: 'getCurrentAccountId',
}),
},
mounted() {
this.loadIssues();
},
methods: {
shoeCreateIssuePopup() {
this.showAddPopup = true;
},
hideAddPopup() {
this.showAddPopup = false;
this.loadIssues();
},
async loadIssues() {
const url =
BASE_URL +
frontendURL(
conversationUrl({
accountId: this.accountId,
id: this.conversationId,
})
);
this.isLoading = true;
linearClient.attachmentsForURL(url).then(issues => {
if (issues.nodes.length) {
issues.nodes.map(issue1 => {
const linearIssueId = issue1._issue.id;
const attachmentId = issue1.id;
if (linearIssueId) {
linearClient.issue(linearIssueId).then(issue2 => {
console.log('Issue:', issue2, attachmentId);
this.issues.push({
id: issue2.id,
title: issue2.title,
link: issue2.url,
identifier: issue2.identifier,
attachmentId,
...issue2,
});
this.isLoading = false;
});
}
});
} else {
this.isLoading = false;
console.log('No issues');
}
});
},
async deleteIssue(issue) {
const index = this.issues.findIndex(i => i.id === issue.id);
if (index > -1) {
this.issues.splice(index, 1);
}
await linearClient.deleteAttachment(issue.attachmentId);
},
},
};
</script>
<style scoped lang="scss">
.macros_list--empty-state {
padding: var(--space-slab);
p {
margin: 0;
}
}
.macros_add-button {
margin: var(--space-small) auto 0;
}
</style>
@@ -1,12 +0,0 @@
import { required } from 'vuelidate/lib/validators';
export default {
title: {
required,
},
description: {
required,
},
teamId: {},
assigneeId: {},
};
@@ -33,7 +33,6 @@
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { mapGetters } from 'vuex';
import timeMixin from '../../../../mixins/time';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
@@ -50,7 +49,7 @@ export default {
},
},
},
mixins: [timeMixin, messageFormatterMixin, clickaway],
mixins: [timeMixin, messageFormatterMixin],
props: {
isOnExpandedLayout: {
type: Boolean,
@@ -34,7 +34,6 @@
<script>
import { debounce } from '@chatwoot/utils';
import { mixin as clickaway } from 'vue-clickaway';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import SearchHeader from './Header.vue';
@@ -52,7 +51,7 @@ export default {
SearchResults,
ArticleView,
},
mixins: [clickaway, portalMixin, alertMixin, keyboardEventListenerMixins],
mixins: [portalMixin, alertMixin, keyboardEventListenerMixins],
props: {
selectedPortalSlug: {
type: String,
@@ -138,8 +138,6 @@
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import MultiselectDropdownItems from 'shared/components/ui/MultiselectDropdownItems.vue';
@@ -152,7 +150,6 @@ export default {
WootDropdownMenu,
MultiselectDropdownItems,
},
mixins: [clickaway],
props: {
headerTitle: {
type: String,
@@ -109,14 +109,13 @@
<script>
import alertMixin from 'shared/mixins/alertMixin';
import { mixin as clickaway } from 'vue-clickaway';
import wootConstants from 'dashboard/constants/globals';
import { PORTALS_EVENTS } from '../../../../../helper/AnalyticsHelper/events';
const { ARTICLE_STATUS_TYPES } = wootConstants;
export default {
mixins: [alertMixin, clickaway],
mixins: [alertMixin],
props: {
isSidebarOpen: {
type: Boolean,
@@ -0,0 +1,34 @@
<script setup>
defineProps({
title: {
type: String,
default: null,
},
});
</script>
<template>
<div
class="flex-grow-0 flex-shrink-0 w-full h-full max-w-full bg-white border border-transparent border-solid dark:bg-slate-900 dark:border-transparent md:max-w-2xl"
>
<h3
v-if="$slots.title || title"
class="text-lg text-black-900 dark:text-slate-200"
>
<slot name="title">{{ title }}</slot>
</h3>
<div
class="mx-0 my-4 border-b border-solid border-slate-25 dark:border-slate-800"
>
<slot />
</div>
<div class="flex justify-between">
<div>
<slot name="footer-left" />
</div>
<div>
<slot name="footer-right" />
</div>
</div>
</div>
</template>
@@ -47,13 +47,11 @@
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import PortalSwitch from './PortalSwitch.vue';
export default {
components: {
PortalSwitch,
},
mixins: [clickaway],
props: {
portals: {
type: Array,
@@ -1,256 +1,241 @@
<template>
<div
class="pt-3 bg-white dark:bg-slate-900 h-full border border-solid border-transparent px-6 pb-6 dark:border-transparent w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0"
>
<div class="w-full">
<h3 class="text-lg text-black-900 dark:text-slate-200">
{{
$t(
'HELP_CENTER.PORTAL.ADD.CREATE_FLOW_PAGE.BASIC_SETTINGS_PAGE.TITLE'
)
}}
</h3>
</div>
<div
class="my-4 mx-0 border-b border-solid border-slate-25 dark:border-slate-800"
>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<div class="mb-4">
<div class="flex items-center flex-row">
<woot-avatar-uploader
ref="imageUpload"
:label="$t('HELP_CENTER.PORTAL.ADD.LOGO.LABEL')"
:src="logoUrl"
@change="onFileChange"
/>
<div v-if="showDeleteButton" class="avatar-delete-btn">
<woot-button
type="button"
color-scheme="alert"
variant="hollow"
size="small"
@click="deleteAvatar"
>
{{ $t('PROFILE_SETTINGS.DELETE_AVATAR') }}
</woot-button>
</div>
</div>
<p
class="mt-1 mb-0 text-xs text-slate-600 dark:text-slate-400 not-italic"
>
{{ $t('HELP_CENTER.PORTAL.ADD.LOGO.HELP_TEXT') }}
</p>
</div>
<div class="mb-4">
<woot-input
v-model.trim="name"
:class="{ error: $v.name.$error }"
:error="nameError"
:label="$t('HELP_CENTER.PORTAL.ADD.NAME.LABEL')"
:placeholder="$t('HELP_CENTER.PORTAL.ADD.NAME.PLACEHOLDER')"
:help-text="$t('HELP_CENTER.PORTAL.ADD.NAME.HELP_TEXT')"
@blur="$v.name.$touch"
@input="onNameChange"
/>
</div>
<div class="mb-4">
<woot-input
v-model.trim="slug"
:class="{ error: $v.slug.$error }"
:error="slugError"
:label="$t('HELP_CENTER.PORTAL.ADD.SLUG.LABEL')"
:placeholder="$t('HELP_CENTER.PORTAL.ADD.SLUG.PLACEHOLDER')"
:help-text="domainHelpText"
@blur="$v.slug.$touch"
/>
</div>
<div class="mb-4">
<woot-input
v-model.trim="domain"
:class="{ error: $v.domain.$error }"
:label="$t('HELP_CENTER.PORTAL.ADD.DOMAIN.LABEL')"
:placeholder="$t('HELP_CENTER.PORTAL.ADD.DOMAIN.PLACEHOLDER')"
:help-text="domainExampleHelpText"
:error="domainError"
@blur="$v.domain.$touch"
/>
</div>
</div>
</div>
<div class="flex justify-end">
<woot-button
:is-loading="isSubmitting"
:is-disabled="$v.$invalid"
@click="onSubmitClick"
>
{{ submitButtonText }}
</woot-button>
</div>
</div>
</template>
<script setup>
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
<script>
import { required, minLength } from 'vuelidate/lib/validators';
import { isDomain } from 'shared/helpers/Validators';
import { defineComponent, reactive, computed, onMounted } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useAlert } from 'dashboard/composables';
import alertMixin from 'shared/mixins/alertMixin';
import { convertToCategorySlug } from 'dashboard/helper/commons.js';
import { buildPortalURL } from 'dashboard/helper/portalHelper';
import wootConstants from 'dashboard/constants/globals';
import { hasValidAvatarUrl } from 'dashboard/helper/URLHelper';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { uploadFile } from 'dashboard/helper/uploadHelper';
import { isDomain } from 'shared/helpers/Validators';
import SettingsLayout from './Layout/SettingsLayout.vue';
const { EXAMPLE_URL } = wootConstants;
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
export default {
mixins: [alertMixin],
props: {
portal: {
type: Object,
default: () => {},
},
isSubmitting: {
type: Boolean,
default: false,
},
submitButtonText: {
type: String,
default: '',
},
},
data() {
return {
name: '',
slug: '',
domain: '',
alertMessage: '',
const { t } = useI18n();
// Logouploader keys
avatarBlobId: '',
logoUrl: '',
};
},
validations: {
name: {
required,
minLength: minLength(2),
},
slug: {
required,
},
domain: {
isDomain,
},
},
computed: {
nameError() {
if (this.$v.name.$error) {
return this.$t('HELP_CENTER.CATEGORY.ADD.NAME.ERROR');
}
return '';
},
slugError() {
if (this.$v.slug.$error) {
return this.$t('HELP_CENTER.CATEGORY.ADD.SLUG.ERROR');
}
return '';
},
domainError() {
if (this.$v.domain.$error) {
return this.$t('HELP_CENTER.PORTAL.ADD.DOMAIN.ERROR');
}
return '';
},
domainHelpText() {
return buildPortalURL(this.slug);
},
domainExampleHelpText() {
return this.$t('HELP_CENTER.PORTAL.ADD.DOMAIN.HELP_TEXT', {
exampleURL: EXAMPLE_URL,
});
},
showDeleteButton() {
return hasValidAvatarUrl(this.logoUrl);
},
},
mounted() {
const portal = this.portal || {};
this.name = portal.name || '';
this.slug = portal.slug || '';
this.domain = portal.custom_domain || '';
this.alertMessage = '';
if (portal.logo) {
const {
logo: { file_url: logoURL, blob_id: blobId },
} = portal;
this.logoUrl = logoURL;
this.avatarBlobId = blobId;
}
},
methods: {
onNameChange() {
this.slug = convertToCategorySlug(this.name);
},
onSubmitClick() {
this.$v.$touch();
if (this.$v.$invalid) {
return;
}
defineComponent({
name: 'PortalSettingsBasicForm',
});
const portal = {
name: this.name,
slug: this.slug,
custom_domain: this.domain,
blob_id: this.avatarBlobId || null,
};
this.$emit('submit', portal);
},
async deleteAvatar() {
this.logoUrl = '';
this.avatarBlobId = '';
this.$emit('delete-logo');
},
onFileChange({ file }) {
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
this.uploadLogoToStorage(file);
} else {
this.showAlert(
this.$t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SIZE_ERROR',
{
size: MAXIMUM_FILE_UPLOAD_SIZE,
}
)
);
}
const props = defineProps({
portal: {
type: Object,
default: () => {},
},
isSubmitting: {
type: Boolean,
default: false,
},
submitButtonText: {
type: String,
default: '',
},
});
this.$refs.imageUpload.value = '';
},
async uploadLogoToStorage(file) {
try {
const { fileUrl, blobId } = await uploadFile(file);
if (fileUrl) {
this.logoUrl = fileUrl;
this.avatarBlobId = blobId;
}
} catch (error) {
this.showAlert(
this.$t('HELP_CENTER.PORTAL.ADD.LOGO.IMAGE_DELETE_ERROR')
);
}
},
const state = reactive({
name: '',
slug: '',
domain: '',
logoUrl: '',
avatarBlobId: '',
});
const rules = {
name: {
required,
minLength: minLength(2),
},
slug: {
required,
},
domain: {
isDomain,
},
};
</script>
<style lang="scss" scoped>
::v-deep {
input {
@apply mb-1;
const v$ = useVuelidate(rules, state);
const nameError = computed(() => {
if (v$.value.name.$error) {
return t('HELP_CENTER.CATEGORY.ADD.NAME.ERROR');
}
.help-text {
@apply mb-0;
return '';
});
const slugError = computed(() => {
if (v$.value.slug.$error) {
return t('HELP_CENTER.CATEGORY.ADD.SLUG.ERROR');
}
return '';
});
const domainError = computed(() => {
if (v$.value.domain.$error) {
return t('HELP_CENTER.PORTAL.ADD.DOMAIN.ERROR');
}
return '';
});
const domainHelpText = computed(() => {
return buildPortalURL(state.slug);
});
const domainExampleHelpText = computed(() => {
return t('HELP_CENTER.PORTAL.ADD.DOMAIN.HELP_TEXT', {
exampleURL: EXAMPLE_URL,
});
});
const showDeleteButton = computed(() => {
return hasValidAvatarUrl(state.logoUrl);
});
const emit = defineEmits(['submit', 'delete-logo']);
onMounted(() => {
const portal = props.portal || {};
state.name = portal.name || '';
state.slug = portal.slug || '';
state.domain = portal.custom_domain || '';
if (portal.logo) {
const {
logo: { file_url: logoURL, blob_id: blobId },
} = portal;
state.logoUrl = logoURL;
state.avatarBlobId = blobId;
}
});
function onNameChange() {
state.slug = convertToCategorySlug(state.name);
}
function onSubmitClick() {
v$.value.$touch();
if (v$.value.$invalid) {
return;
}
const portal = {
name: state.name,
slug: state.slug,
custom_domain: state.domain,
blob_id: state.avatarBlobId || null,
};
emit('submit', portal);
}
async function deleteAvatar() {
state.logoUrl = '';
state.avatarBlobId = '';
emit('delete-logo');
}
async function uploadLogoToStorage(file) {
try {
const { fileUrl, blobId } = await uploadFile(file);
if (fileUrl) {
state.logoUrl = fileUrl;
state.avatarBlobId = blobId;
}
} catch (error) {
useAlert(t('HELP_CENTER.PORTAL.ADD.LOGO.IMAGE_UPLOAD_ERROR'));
}
}
</style>
function onFileChange({ file }) {
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
uploadLogoToStorage(file);
} else {
const errorKey =
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SIZE_ERROR';
useAlert(t(errorKey, { size: MAXIMUM_FILE_UPLOAD_SIZE }));
}
}
</script>
<template>
<SettingsLayout
:title="
$t('HELP_CENTER.PORTAL.ADD.CREATE_FLOW_PAGE.BASIC_SETTINGS_PAGE.TITLE')
"
>
<div>
<div class="mb-4">
<div class="flex flex-row items-center">
<woot-avatar-uploader
:label="$t('HELP_CENTER.PORTAL.ADD.LOGO.LABEL')"
:src="state.logoUrl"
@change="onFileChange"
/>
<div v-if="showDeleteButton" class="avatar-delete-btn">
<woot-button
type="button"
color-scheme="alert"
variant="hollow"
size="small"
@click="deleteAvatar"
>
{{ $t('PROFILE_SETTINGS.DELETE_AVATAR') }}
</woot-button>
</div>
</div>
<p
class="mt-1 mb-0 text-xs not-italic text-slate-600 dark:text-slate-400"
>
{{ $t('HELP_CENTER.PORTAL.ADD.LOGO.HELP_TEXT') }}
</p>
</div>
<div class="mb-4">
<woot-input
v-model="state.name"
:class="{ error: v$.name.$error }"
:error="nameError"
:label="$t('HELP_CENTER.PORTAL.ADD.NAME.LABEL')"
:placeholder="$t('HELP_CENTER.PORTAL.ADD.NAME.PLACEHOLDER')"
:help-text="$t('HELP_CENTER.PORTAL.ADD.NAME.HELP_TEXT')"
@blur="v$.name.$touch"
@input="onNameChange"
/>
</div>
<div class="mb-4">
<woot-input
v-model="state.slug"
:class="{ error: v$.slug.$error }"
:error="slugError"
:label="$t('HELP_CENTER.PORTAL.ADD.SLUG.LABEL')"
:placeholder="$t('HELP_CENTER.PORTAL.ADD.SLUG.PLACEHOLDER')"
:help-text="domainHelpText"
@blur="v$.slug.$touch"
/>
</div>
<div class="mb-4">
<woot-input
v-model="state.domain"
:class="{ error: v$.domain.$error }"
:label="$t('HELP_CENTER.PORTAL.ADD.DOMAIN.LABEL')"
:placeholder="$t('HELP_CENTER.PORTAL.ADD.DOMAIN.PLACEHOLDER')"
:help-text="domainExampleHelpText"
:error="domainError"
@blur="v$.domain.$touch"
/>
</div>
</div>
<template #footer-right>
<woot-button
:is-loading="isSubmitting"
:is-disabled="v$.$invalid"
@click="onSubmitClick"
>
{{ submitButtonText }}
</woot-button>
</template>
</SettingsLayout>
</template>
@@ -1,68 +1,140 @@
<script setup>
import { getRandomColor } from 'dashboard/helper/labelColor';
import SettingsLayout from './Layout/SettingsLayout.vue';
import wootConstants from 'dashboard/constants/globals';
const { EXAMPLE_URL } = wootConstants;
import { useVuelidate } from '@vuelidate/core';
import { url } from '@vuelidate/validators';
import { defineComponent, reactive, computed, onMounted } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
defineComponent({
name: 'PortalSettingsCustomizationForm',
});
const { t } = useI18n();
const props = defineProps({
portal: {
type: Object,
default: () => ({}),
},
isSubmitting: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['submit']);
const state = reactive({
color: getRandomColor(),
pageTitle: '',
headerText: '',
homePageLink: '',
});
const rules = {
homePageLink: { url },
};
const homepageExampleHelpText = computed(() => {
return t('HELP_CENTER.PORTAL.ADD.HOME_PAGE_LINK.HELP_TEXT', {
exampleURL: EXAMPLE_URL,
});
});
const v$ = useVuelidate(rules, state);
function updateDataFromStore() {
const { portal } = props;
if (portal) {
state.color = portal.color || getRandomColor();
state.pageTitle = portal.page_title || '';
state.headerText = portal.header_text || '';
state.homePageLink = portal.homepage_link || '';
}
}
function onSubmitClick() {
v$.value.$touch();
if (v$.value.$invalid) {
return;
}
const portal = {
id: props.portal.id,
slug: props.portal.slug,
color: state.color,
page_title: state.pageTitle,
header_text: state.headerText,
homepage_link: state.homePageLink,
};
emit('submit', portal);
}
onMounted(() => {
updateDataFromStore();
});
</script>
<template>
<div
class="pt-3 bg-white dark:bg-slate-900 h-full border border-solid border-transparent px-6 pb-6 dark:border-transparent w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0"
<SettingsLayout
:title="
$t('HELP_CENTER.PORTAL.ADD.CREATE_FLOW_PAGE.CUSTOMIZATION_PAGE.TITLE')
"
>
<div class="w-full">
<h3 class="text-lg text-black-900 dark:text-slate-200">
{{
$t('HELP_CENTER.PORTAL.ADD.CREATE_FLOW_PAGE.CUSTOMIZATION_PAGE.TITLE')
}}
</h3>
</div>
<div
class="my-4 mx-0 border-b border-solid border-slate-25 dark:border-slate-800"
>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<div class="mb-4">
<label>
{{ $t('HELP_CENTER.PORTAL.ADD.THEME_COLOR.LABEL') }}
</label>
<woot-color-picker v-model="color" />
<p
class="mt-1 mb-0 text-xs text-slate-600 dark:text-slate-400 not-italic"
>
{{ $t('HELP_CENTER.PORTAL.ADD.THEME_COLOR.HELP_TEXT') }}
</p>
</div>
<div class="mb-4">
<woot-input
v-model.trim="pageTitle"
:label="$t('HELP_CENTER.PORTAL.ADD.PAGE_TITLE.LABEL')"
:placeholder="$t('HELP_CENTER.PORTAL.ADD.PAGE_TITLE.PLACEHOLDER')"
:help-text="$t('HELP_CENTER.PORTAL.ADD.PAGE_TITLE.HELP_TEXT')"
/>
</div>
<div class="mb-4">
<woot-input
v-model.trim="headerText"
:label="$t('HELP_CENTER.PORTAL.ADD.HEADER_TEXT.LABEL')"
:placeholder="$t('HELP_CENTER.PORTAL.ADD.HEADER_TEXT.PLACEHOLDER')"
:help-text="$t('HELP_CENTER.PORTAL.ADD.HEADER_TEXT.HELP_TEXT')"
/>
</div>
<div class="mb-4">
<woot-input
v-model.trim="homePageLink"
:label="$t('HELP_CENTER.PORTAL.ADD.HOME_PAGE_LINK.LABEL')"
:placeholder="
$t('HELP_CENTER.PORTAL.ADD.HOME_PAGE_LINK.PLACEHOLDER')
"
:help-text="homepageExampleHelpText"
:error="
$v.homePageLink.$error
? $t('HELP_CENTER.PORTAL.ADD.HOME_PAGE_LINK.ERROR')
: ''
"
:class="{ error: $v.homePageLink.$error }"
@blur="$v.homePageLink.$touch"
/>
</div>
<div class="flex-grow-0 flex-shrink-0">
<div class="mb-4">
<label>
{{ $t('HELP_CENTER.PORTAL.ADD.THEME_COLOR.LABEL') }}
</label>
<woot-color-picker v-model="state.color" />
<p
class="mt-1 mb-0 text-xs not-italic text-slate-600 dark:text-slate-400"
>
{{ $t('HELP_CENTER.PORTAL.ADD.THEME_COLOR.HELP_TEXT') }}
</p>
</div>
<div class="mb-4">
<woot-input
v-model="state.pageTitle"
:label="$t('HELP_CENTER.PORTAL.ADD.PAGE_TITLE.LABEL')"
:placeholder="$t('HELP_CENTER.PORTAL.ADD.PAGE_TITLE.PLACEHOLDER')"
:help-text="$t('HELP_CENTER.PORTAL.ADD.PAGE_TITLE.HELP_TEXT')"
/>
</div>
<div class="mb-4">
<woot-input
v-model="state.headerText"
:label="$t('HELP_CENTER.PORTAL.ADD.HEADER_TEXT.LABEL')"
:placeholder="$t('HELP_CENTER.PORTAL.ADD.HEADER_TEXT.PLACEHOLDER')"
:help-text="$t('HELP_CENTER.PORTAL.ADD.HEADER_TEXT.HELP_TEXT')"
/>
</div>
<div class="mb-4">
<woot-input
v-model="state.homePageLink"
:label="$t('HELP_CENTER.PORTAL.ADD.HOME_PAGE_LINK.LABEL')"
:placeholder="$t('HELP_CENTER.PORTAL.ADD.HOME_PAGE_LINK.PLACEHOLDER')"
:help-text="homepageExampleHelpText"
:error="
v$.homePageLink.$error
? $t('HELP_CENTER.PORTAL.ADD.HOME_PAGE_LINK.ERROR')
: ''
"
:class="{ error: v$.homePageLink.$error }"
@blur="v$.homePageLink.$touch"
/>
</div>
</div>
<div class="flex justify-end">
<template #footer-right>
<woot-button
:is-loading="isSubmitting"
:is-disabled="$v.$invalid"
:is-disabled="v$.$invalid"
@click="onSubmitClick"
>
{{
@@ -71,94 +143,12 @@
)
}}
</woot-button>
</div>
</div>
</template>
</SettingsLayout>
</template>
<script>
import { url } from 'vuelidate/lib/validators';
import { getRandomColor } from 'dashboard/helper/labelColor';
import alertMixin from 'shared/mixins/alertMixin';
import wootConstants from 'dashboard/constants/globals';
const { EXAMPLE_URL } = wootConstants;
export default {
components: {},
mixins: [alertMixin],
props: {
portal: {
type: Object,
default: () => ({}),
},
isSubmitting: {
type: Boolean,
default: false,
},
},
data() {
return {
color: '#000',
pageTitle: '',
headerText: '',
homePageLink: '',
alertMessage: '',
};
},
validations: {
homePageLink: {
url,
},
},
computed: {
homepageExampleHelpText() {
return this.$t('HELP_CENTER.PORTAL.ADD.HOME_PAGE_LINK.HELP_TEXT', {
exampleURL: EXAMPLE_URL,
});
},
},
mounted() {
this.color = getRandomColor();
this.updateDataFromStore();
},
methods: {
updateDataFromStore() {
const { portal } = this;
if (portal) {
this.color = portal.color || getRandomColor();
this.pageTitle = portal.page_title || '';
this.headerText = portal.header_text || '';
this.homePageLink = portal.homepage_link || '';
this.alertMessage = '';
}
},
onSubmitClick() {
this.$v.$touch();
if (this.$v.$invalid) {
return;
}
const portal = {
id: this.portal.id,
slug: this.portal.slug,
color: this.color,
page_title: this.pageTitle,
header_text: this.headerText,
homepage_link: this.homePageLink,
};
this.$emit('submit', portal);
},
},
};
</script>
<style lang="scss" scoped>
::v-deep {
input {
@apply mb-1;
}
.help-text {
@apply mb-0;
}
.colorpicker--selected {
@apply mb-0;
}
@@ -1,15 +1,115 @@
<script setup>
import { useRoute } from 'dashboard/composables/route';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import { useI18n } from 'dashboard/composables/useI18n';
import { useAlert, useTrack } from 'dashboard/composables';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { defineComponent, ref, computed } from 'vue';
import CategoryListItem from './CategoryListItem.vue';
import AddCategory from './AddCategory.vue';
import EditCategory from './EditCategory.vue';
defineComponent({
name: 'ListAllCategories',
});
const selectedCategory = ref({});
const currentLocaleCode = ref('en');
const showEditCategoryModal = ref(false);
const showAddCategoryModal = ref(false);
const getters = useStoreGetters();
const store = useStore();
const route = useRoute();
const track = useTrack();
const { t } = useI18n();
const currentPortalSlug = computed(() => {
return route.params.portalSlug;
});
const categoriesByLocaleCode = computed(() => {
return getters['categories/categoriesByLocaleCode'].value(
currentLocaleCode.value
);
});
const currentPortal = computed(() => {
const slug = currentPortalSlug.value;
if (slug) return getters['portals/portalBySlug'].value(slug);
return getters['portals/allPortals'].value[0];
});
const currentPortalName = computed(() => {
return currentPortal.value ? currentPortal.value.name : '';
});
const allLocales = computed(() => {
return currentPortal.value ? currentPortal.value.config.allowed_locales : [];
});
const allowedLocaleCodes = computed(() => {
return allLocales.value.map(locale => locale.code);
});
function openAddCategoryModal() {
showAddCategoryModal.value = true;
}
function openEditCategoryModal(category) {
selectedCategory.value = category;
showEditCategoryModal.value = true;
}
function closeAddCategoryModal() {
showAddCategoryModal.value = false;
}
function closeEditCategoryModal() {
showEditCategoryModal.value = false;
}
async function fetchCategoriesByPortalSlugAndLocale(localeCode) {
await store.dispatch('categories/index', {
portalSlug: currentPortalSlug.value,
locale: localeCode,
});
}
async function deleteCategory(category) {
let alertMessage = '';
try {
await store.dispatch('categories/delete', {
portalSlug: currentPortalSlug.value,
categoryId: category.id,
});
alertMessage = t('HELP_CENTER.CATEGORY.DELETE.API.SUCCESS_MESSAGE');
track(PORTALS_EVENTS.DELETE_CATEGORY, {
hasArticles: category?.meta?.articles_count !== 0,
});
} catch (error) {
const errorMessage = error?.message;
alertMessage =
errorMessage || t('HELP_CENTER.CATEGORY.DELETE.API.ERROR_MESSAGE');
} finally {
useAlert(alertMessage);
}
}
function changeCurrentCategory(event) {
const localeCode = event.target.value;
currentLocaleCode.value = localeCode;
fetchCategoriesByPortalSlugAndLocale(localeCode);
}
</script>
<template>
<div class="w-full pl-4">
<header class="flex justify-between items-center mb-4">
<div class="w-full max-w-6xl">
<header class="flex items-center justify-between mb-4">
<div class="flex items-center w-full gap-3">
<label
class="font-normal mb-0 text-base text-slate-800 dark:text-slate-100"
class="mb-0 text-base font-normal text-slate-800 dark:text-slate-100"
>
{{ $t('HELP_CENTER.PORTAL.EDIT.CATEGORIES.TITLE') }}
</label>
<select
:value="currentLocaleCode"
class="w-[15%] select-locale"
class="w-[15%] h-8 mb-0 py-0.5"
@change="changeCurrentCategory"
>
<option
@@ -21,7 +121,7 @@
</option>
</select>
</div>
<div class="flex-none items-center">
<div class="items-center flex-none">
<woot-button
size="small"
variant="smooth"
@@ -58,119 +158,3 @@
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import portalMixin from '../../mixins/portalMixin';
import CategoryListItem from './CategoryListItem.vue';
import AddCategory from './AddCategory.vue';
import EditCategory from './EditCategory.vue';
import { PORTALS_EVENTS } from '../../../../../helper/AnalyticsHelper/events';
export default {
components: {
CategoryListItem,
AddCategory,
EditCategory,
},
mixins: [alertMixin, portalMixin],
data() {
return {
selectedCategory: {},
selectedLocaleCode: '',
currentLocaleCode: 'en',
showEditCategoryModal: false,
showAddCategoryModal: false,
alertMessage: '',
};
},
computed: {
...mapGetters({
portals: 'portals/allPortals',
meta: 'portals/getMeta',
isFetching: 'portals/isFetchingPortals',
}),
currentPortalSlug() {
return this.$route.params.portalSlug;
},
categoriesByLocaleCode() {
return this.$store.getters['categories/categoriesByLocaleCode'](
this.currentLocaleCode
);
},
currentPortal() {
const slug = this.currentPortalSlug;
if (slug) return this.$store.getters['portals/portalBySlug'](slug);
return this.$store.getters['portals/allPortals'][0];
},
currentPortalName() {
return this.currentPortal ? this.currentPortal.name : '';
},
currentPortalLocale() {
return this.currentPortal ? this.currentPortal?.meta?.default_locale : '';
},
allLocales() {
return this.currentPortal
? this.currentPortal.config.allowed_locales
: [];
},
allowedLocaleCodes() {
return this.allLocales.map(locale => locale.code);
},
},
methods: {
openAddCategoryModal() {
this.showAddCategoryModal = true;
},
openEditCategoryModal(category) {
this.selectedCategory = category;
this.showEditCategoryModal = true;
},
closeAddCategoryModal() {
this.showAddCategoryModal = false;
},
closeEditCategoryModal() {
this.showEditCategoryModal = false;
},
async fetchCategoriesByPortalSlugAndLocale(localeCode) {
await this.$store.dispatch('categories/index', {
portalSlug: this.currentPortalSlug,
locale: localeCode,
});
},
async deleteCategory(category) {
try {
await this.$store.dispatch('categories/delete', {
portalSlug: this.currentPortalSlug,
categoryId: category.id,
});
this.alertMessage = this.$t(
'HELP_CENTER.CATEGORY.DELETE.API.SUCCESS_MESSAGE'
);
this.$track(PORTALS_EVENTS.DELETE_CATEGORY, {
hasArticles: category?.meta?.articles_count !== 0,
});
} catch (error) {
const errorMessage = error?.message;
this.alertMessage =
errorMessage ||
this.$t('HELP_CENTER.CATEGORY.DELETE.API.ERROR_MESSAGE');
} finally {
this.showAlert(this.alertMessage);
}
},
changeCurrentCategory(event) {
const localeCode = event.target.value;
this.currentLocaleCode = localeCode;
this.fetchCategoriesByPortalSlugAndLocale(localeCode);
},
},
};
</script>
<style lang="scss" scoped>
.select-locale {
@apply h-8 mb-0 py-0.5;
}
</style>
@@ -36,13 +36,10 @@
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
const EmojiInput = () => import('shared/components/emoji/EmojiInput');
export default {
components: { EmojiInput },
mixins: [clickaway],
props: {
label: {
type: String,
@@ -24,7 +24,7 @@
/>
</woot-tabs>
</setting-intro-banner>
<div class="p-4 max-w-full my-auto flex flex-wrap">
<div class="flex flex-wrap max-w-full px-8 py-4 my-auto">
<router-view />
</div>
</div>
@@ -1,3 +1,75 @@
<script setup>
import PortalSettingsBasicForm from 'dashboard/routes/dashboard/helpcenter/components/PortalSettingsBasicForm.vue';
import { useAlert } from 'dashboard/composables';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import { useRoute, useRouter } from 'dashboard/composables/route';
import { useI18n } from 'dashboard/composables/useI18n';
import { defineComponent, computed, ref, onMounted } from 'vue';
defineComponent({ name: 'EditPortalBasic' });
const getters = useStoreGetters();
const route = useRoute();
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const uiFlags = getters['portals/uiFlagsIn'];
const lastPortalSlug = ref(null);
const currentPortalSlug = computed(() => {
return route.params.portalSlug;
});
const currentPortal = computed(() => {
const slug = route.params.portalSlug;
return getters['portals/portalBySlug'].value(slug);
});
onMounted(() => {
lastPortalSlug.value = currentPortalSlug.value;
});
async function updatePortalSettings(portalObj) {
let alertMessage = '';
try {
const portalSlug = lastPortalSlug.value;
await store.dispatch('portals/update', { ...portalObj, portalSlug });
alertMessage = t('HELP_CENTER.PORTAL.ADD.API.SUCCESS_MESSAGE_FOR_UPDATE');
if (lastPortalSlug.value !== portalObj.slug) {
await store.dispatch('portals/index');
router.replace({
name: route.name,
params: { portalSlug: portalObj.slug },
});
}
} catch (error) {
alertMessage =
error?.message ||
t('HELP_CENTER.PORTAL.ADD.API.ERROR_MESSAGE_FOR_UPDATE');
} finally {
useAlert(alertMessage);
}
}
async function deleteLogo() {
try {
const portalSlug = lastPortalSlug.value;
await store.dispatch('portals/deleteLogo', {
portalSlug,
});
} catch (error) {
useAlert(
error?.message || t('HELP_CENTER.PORTAL.ADD.LOGO.IMAGE_DELETE_ERROR')
);
}
}
</script>
<template>
<portal-settings-basic-form
v-if="currentPortal"
@@ -10,80 +82,3 @@
@delete-logo="deleteLogo"
/>
</template>
<script>
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import PortalSettingsBasicForm from 'dashboard/routes/dashboard/helpcenter/components/PortalSettingsBasicForm.vue';
export default {
components: {
PortalSettingsBasicForm,
},
mixins: [alertMixin],
data() {
return {
lastPortalSlug: undefined,
alertMessage: '',
};
},
computed: {
...mapGetters({
uiFlags: 'portals/uiFlagsIn',
}),
currentPortalSlug() {
return this.$route.params.portalSlug;
},
currentPortal() {
return this.$store.getters['portals/portalBySlug'](
this.currentPortalSlug
);
},
},
mounted() {
this.lastPortalSlug = this.currentPortalSlug;
},
methods: {
async updatePortalSettings(portalObj) {
try {
const portalSlug = this.lastPortalSlug;
await this.$store.dispatch('portals/update', {
...portalObj,
portalSlug,
});
this.alertMessage = this.$t(
'HELP_CENTER.PORTAL.ADD.API.SUCCESS_MESSAGE_FOR_UPDATE'
);
if (this.lastPortalSlug !== portalObj.slug) {
await this.$store.dispatch('portals/index');
this.$router.replace({
name: this.$route.name,
params: { portalSlug: portalObj.slug },
});
}
} catch (error) {
this.alertMessage =
error?.message ||
this.$t('HELP_CENTER.PORTAL.ADD.API.ERROR_MESSAGE_FOR_UPDATE');
} finally {
this.showAlert(this.alertMessage);
}
},
async deleteLogo() {
try {
const portalSlug = this.lastPortalSlug;
await this.$store.dispatch('portals/deleteLogo', {
portalSlug,
});
} catch (error) {
this.alertMessage =
error?.message ||
this.$t('HELP_CENTER.PORTAL.ADD.LOGO.IMAGE_DELETE_ERROR');
this.showAlert(this.alertMessage);
}
},
},
};
</script>
@@ -1,3 +1,48 @@
<script setup>
import PortalSettingsCustomizationForm from 'dashboard/routes/dashboard/helpcenter/components/PortalSettingsCustomizationForm.vue';
import { useAlert } from 'dashboard/composables';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import { useRoute } from 'dashboard/composables/route';
import { useI18n } from 'dashboard/composables/useI18n';
import { defineComponent, computed } from 'vue';
defineComponent({
name: 'EditPortalCustomization',
});
const getters = useStoreGetters();
const route = useRoute();
const store = useStore();
const { t } = useI18n();
const uiFlags = getters['portals/uiFlagsIn'];
const currentPortal = computed(() => {
const slug = route.params.portalSlug;
return getters['portals/portalBySlug'].value(slug);
});
async function updatePortalSettings(portalObj) {
const portalSlug = route.params.portalSlug;
let alertMessage = '';
try {
await store.dispatch('portals/update', {
...portalObj,
portalSlug,
});
alertMessage = t('HELP_CENTER.PORTAL.ADD.API.SUCCESS_MESSAGE_FOR_UPDATE');
} catch (error) {
alertMessage =
error?.message ||
t('HELP_CENTER.PORTAL.ADD.API.ERROR_MESSAGE_FOR_UPDATE');
} finally {
useAlert(alertMessage);
}
}
</script>
<template>
<portal-settings-customization-form
v-if="currentPortal"
@@ -9,51 +54,3 @@
@submit="updatePortalSettings"
/>
</template>
<script>
import alertMixin from 'shared/mixins/alertMixin';
import PortalSettingsCustomizationForm from 'dashboard/routes/dashboard/helpcenter/components/PortalSettingsCustomizationForm.vue';
import { mapGetters } from 'vuex';
export default {
components: {
PortalSettingsCustomizationForm,
},
mixins: [alertMixin],
data() {
return {
alertMessage: '',
};
},
computed: {
...mapGetters({
uiFlags: 'portals/uiFlagsIn',
}),
currentPortal() {
const slug = this.$route.params.portalSlug;
return this.$store.getters['portals/portalBySlug'](slug);
},
},
methods: {
async updatePortalSettings(portalObj) {
const portalSlug = this.$route.params.portalSlug;
try {
await this.$store.dispatch('portals/update', {
...portalObj,
portalSlug,
});
this.alertMessage = this.$t(
'HELP_CENTER.PORTAL.ADD.API.SUCCESS_MESSAGE_FOR_UPDATE'
);
} catch (error) {
this.alertMessage =
error?.message ||
this.$t('HELP_CENTER.PORTAL.ADD.API.ERROR_MESSAGE_FOR_UPDATE');
} finally {
this.showAlert(this.alertMessage);
}
},
},
};
</script>
@@ -1,6 +1,117 @@
<script setup>
import LocaleItemTable from 'dashboard/routes/dashboard/helpcenter/components/PortalListItemTable.vue';
import AddLocale from 'dashboard/routes/dashboard/helpcenter/components/AddLocale.vue';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { useAlert, useTrack } from 'dashboard/composables';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import { useRoute } from 'dashboard/composables/route';
import { useI18n } from 'dashboard/composables/useI18n';
import { defineComponent, ref, onBeforeMount, computed } from 'vue';
defineComponent({
name: 'EditPortalLocales',
});
const isAddLocaleModalOpen = ref(false);
const getters = useStoreGetters();
const store = useStore();
const route = useRoute();
const track = useTrack();
const { t } = useI18n();
const currentPortalSlug = computed(() => {
return route.params.portalSlug;
});
const currentPortal = computed(() => {
const slug = currentPortalSlug.value;
if (slug) return getters['portals/portalBySlug'].value(slug);
return getters['portals/allPortals'].value[0];
});
const locales = computed(() => {
return currentPortal.value?.config.allowed_locales;
});
const allowedLocales = computed(() => {
return Object.keys(locales.value).map(key => {
return this.locales.value[key].code;
});
});
async function fetchPortals() {
await store.dispatch('portals/index');
}
onBeforeMount(() => {
fetchPortals();
});
async function updatePortalLocales({
newAllowedLocales,
defaultLocale,
messageKey,
}) {
let alertMessage = '';
try {
await store.dispatch('portals/update', {
portalSlug: currentPortalSlug.value,
config: {
default_locale: defaultLocale,
allowed_locales: newAllowedLocales,
},
});
alertMessage = t(`HELP_CENTER.PORTAL.${messageKey}.API.SUCCESS_MESSAGE`);
} catch (error) {
alertMessage =
error?.message || t(`HELP_CENTER.PORTAL.${messageKey}.API.ERROR_MESSAGE`);
} finally {
useAlert(alertMessage);
}
}
function changeDefaultLocale({ localeCode }) {
updatePortalLocales({
allowedLocales: allowedLocales.value,
defaultLocale: localeCode,
messageKey: 'CHANGE_DEFAULT_LOCALE',
});
track(PORTALS_EVENTS.SET_DEFAULT_LOCALE, {
newLocale: localeCode,
from: route.name,
});
}
function deletePortalLocale({ localeCode }) {
const updatedLocales = allowedLocales.value.filter(
code => code !== localeCode
);
const defaultLocale = currentPortal.value?.meta.default_locale;
updatePortalLocales({
allowedLocales: updatedLocales,
defaultLocale,
messageKey: 'DELETE_LOCALE',
});
track(PORTALS_EVENTS.DELETE_LOCALE, {
deletedLocale: localeCode,
from: route.name,
});
}
function closeAddLocaleModal() {
isAddLocaleModalOpen.value = false;
}
function addLocale() {
isAddLocaleModalOpen.value = true;
}
</script>
<template>
<div class="portal-locales">
<div class="button-container">
<div class="w-full h-full max-w-6xl space-y-4 bg-white dark:bg-slate-900">
<div class="flex justify-end">
<woot-button
variant="smooth"
size="small"
@@ -11,19 +122,18 @@
{{ $t('HELP_CENTER.PORTAL.PORTAL_SETTINGS.LIST_ITEM.HEADER.ADD') }}
</woot-button>
</div>
<div class="locale-container">
<locale-item-table
:locales="locales"
:selected-locale-code="currentPortal.meta.default_locale"
@change-default-locale="changeDefaultLocale"
@delete="deletePortalLocale"
/>
</div>
<LocaleItemTable
v-if="currentPortal"
:locales="locales"
:selected-locale-code="currentPortal.meta.default_locale"
@change-default-locale="changeDefaultLocale"
@delete="deletePortalLocale"
/>
<woot-modal
:show.sync="isAddLocaleModalOpen"
:on-close="closeAddLocaleModal"
>
<add-locale
<AddLocale
:show="isAddLocaleModalOpen"
:portal="currentPortal"
@cancel="closeAddLocaleModal"
@@ -31,126 +141,3 @@
</woot-modal>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import LocaleItemTable from 'dashboard/routes/dashboard/helpcenter/components/PortalListItemTable.vue';
import AddLocale from 'dashboard/routes/dashboard/helpcenter/components/AddLocale.vue';
import { PORTALS_EVENTS } from '../../../../../helper/AnalyticsHelper/events';
export default {
components: {
LocaleItemTable,
AddLocale,
},
mixins: [alertMixin],
data() {
return {
isAddLocaleModalOpen: false,
lastPortalSlug: undefined,
alertMessage: '',
};
},
computed: {
...mapGetters({
uiFlags: 'portals/uiFlagsIn',
}),
currentPortalSlug() {
return this.$route.params.portalSlug;
},
currentPortal() {
return this.$store.getters['portals/portalBySlug'](
this.currentPortalSlug
);
},
locales() {
return this.currentPortal.config.allowed_locales;
},
allowedLocales() {
return Object.keys(this.locales).map(key => {
return this.locales[key].code;
});
},
},
mounted() {
this.lastPortalSlug = this.currentPortalSlug;
},
methods: {
changeDefaultLocale({ localeCode }) {
this.updatePortalLocales({
allowedLocales: this.allowedLocales,
defaultLocale: localeCode,
successMessage: this.$t(
'HELP_CENTER.PORTAL.CHANGE_DEFAULT_LOCALE.API.SUCCESS_MESSAGE'
),
errorMessage: this.$t(
'HELP_CENTER.PORTAL.CHANGE_DEFAULT_LOCALE.API.ERROR_MESSAGE'
),
});
this.$track(PORTALS_EVENTS.SET_DEFAULT_LOCALE, {
newLocale: localeCode,
from: this.$route.name,
});
},
deletePortalLocale({ localeCode }) {
const updatedLocales = this.allowedLocales.filter(
code => code !== localeCode
);
const defaultLocale = this.currentPortal.meta.default_locale;
this.updatePortalLocales({
allowedLocales: updatedLocales,
defaultLocale,
successMessage: this.$t(
'HELP_CENTER.PORTAL.DELETE_LOCALE.API.SUCCESS_MESSAGE'
),
errorMessage: this.$t(
'HELP_CENTER.PORTAL.DELETE_LOCALE.API.ERROR_MESSAGE'
),
});
this.$track(PORTALS_EVENTS.DELETE_LOCALE, {
deletedLocale: localeCode,
from: this.$route.name,
});
},
async updatePortalLocales({
allowedLocales,
defaultLocale,
successMessage,
errorMessage,
}) {
try {
await this.$store.dispatch('portals/update', {
portalSlug: this.currentPortal.slug,
config: {
default_locale: defaultLocale,
allowed_locales: allowedLocales,
},
});
this.alertMessage = successMessage;
} catch (error) {
this.alertMessage = error?.message || errorMessage;
} finally {
this.showAlert(this.alertMessage);
}
},
closeAddLocaleModal() {
this.isAddLocaleModalOpen = false;
this.selectedPortal = {};
},
addLocale() {
this.isAddLocaleModalOpen = true;
},
},
};
</script>
<style lang="scss" scoped>
.portal-locales {
@apply w-full bg-white dark:bg-slate-900 h-full py-0 pr-0 pl-4;
.button-container {
@apply flex justify-end;
}
.locale-container {
@apply mt-4;
}
}
</style>
@@ -1,5 +1,5 @@
<template>
<div class="flex-1">
<section class="flex-1">
<settings-header
button-route="new"
:header-title="portalHeaderText"
@@ -10,16 +10,20 @@
:show-new-button="false"
/>
<div
class="flex flex-row overflow-auto py-4 pl-4 rtl:pl-0 rtl:pr-4 h-full bg-slate-50 dark:bg-slate-800"
class="grid grid-cols-[20rem_1fr] w-full h-full overflow-auto rtl:pl-0 rtl:pr-4 bg-slate-50 dark:bg-slate-800 p-5"
>
<woot-wizard
class="hidden md:block w-1/4"
class="hidden md:block"
:global-config="globalConfig"
:items="items"
/>
<router-view />
<div
class="w-full p-5 bg-white border border-transparent border-solid rounded-md shadow-sm dark:bg-slate-900 dark:border-transparent"
>
<router-view />
</div>
</div>
</div>
</section>
</template>
<script>
@@ -1,3 +1,63 @@
<script setup>
import PortalSettingsCustomizationForm from 'dashboard/routes/dashboard/helpcenter/components/PortalSettingsCustomizationForm.vue';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { useAlert, useTrack } from 'dashboard/composables';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import { useRoute, useRouter } from 'dashboard/composables/route';
import { useI18n } from 'dashboard/composables/useI18n';
import { defineComponent, onMounted, computed } from 'vue';
defineComponent({
name: 'PortalCustomization',
});
const getters = useStoreGetters();
const route = useRoute();
const router = useRouter();
const store = useStore();
const track = useTrack();
const { t } = useI18n();
const uiFlags = getters['portals/uiFlagsIn'];
const currentPortal = computed(() => {
const slug = route.params.portalSlug;
if (slug) return getters['portals/portalBySlug'].value(slug);
return {};
});
onMounted(() => {
store.dispatch('portals/index');
});
async function updatePortalSettings(portalObj) {
const portalSlug = route.params.portalSlug;
let alertMessage = '';
try {
await store.dispatch('portals/update', {
portalSlug,
...portalObj,
});
alertMessage = t('HELP_CENTER.PORTAL.ADD.API.SUCCESS_MESSAGE_FOR_UPDATE');
track(PORTALS_EVENTS.ONBOARD_CUSTOMIZATION, {
hasHomePageLink: Boolean(portalObj.homepage_link),
hasPageTitle: Boolean(portalObj.page_title),
hasHeaderText: Boolean(portalObj.headerText),
});
} catch (error) {
alertMessage =
error?.message ||
t('HELP_CENTER.PORTAL.ADD.API.ERROR_MESSAGE_FOR_UPDATE');
} finally {
useAlert(alertMessage);
router.push({ name: 'portal_finish' });
}
}
</script>
<template>
<portal-settings-customization-form
v-if="currentPortal"
@@ -9,75 +69,3 @@
@submit="updatePortalSettings"
/>
</template>
<script>
import alertMixin from 'shared/mixins/alertMixin';
import PortalSettingsCustomizationForm from 'dashboard/routes/dashboard/helpcenter/components/PortalSettingsCustomizationForm.vue';
import { mapGetters } from 'vuex';
import { getRandomColor } from 'dashboard/helper/labelColor';
import { PORTALS_EVENTS } from '../../../../../helper/AnalyticsHelper/events';
export default {
components: {
PortalSettingsCustomizationForm,
},
mixins: [alertMixin],
data() {
return {
color: '#000',
pageTitle: '',
headerText: '',
homePageLink: '',
alertMessage: '',
};
},
computed: {
...mapGetters({
uiFlags: 'portals/uiFlagsIn',
portals: 'portals/allPortals',
}),
currentPortal() {
const slug = this.$route.params.portalSlug;
return this.$store.getters['portals/portalBySlug'](slug);
},
},
mounted() {
this.fetchPortals();
this.color = getRandomColor();
},
methods: {
fetchPortals() {
this.$store.dispatch('portals/index');
},
async updatePortalSettings(portalObj) {
const portalSlug = this.$route.params.portalSlug;
try {
await this.$store.dispatch('portals/update', {
portalSlug,
...portalObj,
});
this.alertMessage = this.$t(
'HELP_CENTER.PORTAL.ADD.API.SUCCESS_MESSAGE_FOR_UPDATE'
);
this.$track(PORTALS_EVENTS.ONBOARD_CUSTOMIZATION, {
hasHomePageLink: Boolean(portalObj.homepage_link),
hasPageTitle: Boolean(portalObj.page_title),
hasHeaderText: Boolean(portalObj.headerText),
});
} catch (error) {
this.alertMessage =
error?.message ||
this.$t('HELP_CENTER.PORTAL.ADD.API.ERROR_MESSAGE_FOR_UPDATE');
} finally {
this.showAlert(this.alertMessage);
this.$router.push({
name: 'portal_finish',
});
}
},
},
};
</script>
@@ -1,6 +1,6 @@
<template>
<div
class="pt-3 bg-white dark:bg-slate-900 h-full border border-solid border-transparent px-6 pb-6 dark:border-transparent w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0"
class="flex-grow-0 flex-shrink-0 w-full h-full max-w-full px-6 pt-3 pb-6 bg-white border border-transparent border-solid dark:bg-slate-900 dark:border-transparent"
>
<empty-state
:title="$t('HELP_CENTER.PORTAL.ADD.CREATE_FLOW_PAGE.FINISH_PAGE.TITLE')"
@@ -10,7 +10,7 @@
>
<div class="w-full text-center">
<router-link
class="button success nice rounded"
class="rounded button success nice"
:to="{
name: 'list_all_portals',
}"
@@ -22,18 +22,10 @@
</div>
</template>
<script>
<script setup>
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
export default {
components: {
EmptyState,
},
methods: {
changeRoute() {
this.$router.push({
name: 'list_all_portals',
});
},
},
};
import { defineComponent } from 'vue';
defineComponent({
name: 'PortalSettingsFinish',
});
</script>
@@ -57,7 +57,6 @@
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import InboxOptionMenu from './InboxOptionMenu.vue';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import InboxDisplayMenu from './InboxDisplayMenu.vue';
@@ -68,7 +67,7 @@ export default {
InboxOptionMenu,
InboxDisplayMenu,
},
mixins: [clickaway, alertMixin],
mixins: [alertMixin],
props: {
isContextMenuOpen: {
type: Boolean,
@@ -115,7 +115,6 @@
<script>
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import rtlMixin from 'shared/mixins/rtlMixin';
import NotificationPanelList from './NotificationPanelList.vue';
@@ -125,7 +124,7 @@ export default {
components: {
NotificationPanelList,
},
mixins: [clickaway, rtlMixin],
mixins: [rtlMixin],
data() {
return {
pageSize: 15,
@@ -21,7 +21,6 @@
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import Spinner from 'shared/components/Spinner.vue';
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
import campaignMixin from 'shared/mixins/campaignMixin';
@@ -34,7 +33,7 @@ export default {
CampaignCard,
},
mixins: [clickaway, campaignMixin],
mixins: [campaignMixin],
props: {
campaigns: {
@@ -0,0 +1,40 @@
<template>
<div class="flex flex-row justify-between gap-4">
<woot-input
name="access_token"
class="flex-1 focus:[&>input]:!border-slate-200 focus:[&>input]:dark:!border-slate-600 [&>input]:cursor-not-allowed"
:styles="{
borderRadius: '12px',
padding: '6px 12px',
fontSize: '14px',
marginBottom: '2px',
}"
type="password"
:value="value"
readonly
/>
<form-button
type="submit"
size="large"
icon="text-copy"
variant="outline"
color-scheme="secondary"
@click="onClick"
>
{{ $t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.COPY') }}
</form-button>
</div>
</template>
<script setup>
import FormButton from 'v3/components/Form/Button.vue';
const props = defineProps({
value: {
type: String,
default: '',
},
});
const emit = defineEmits(['on-copy']);
const onClick = () => {
emit('on-copy', props.value);
};
</script>
@@ -0,0 +1,42 @@
<template>
<div>
<label
class="flex justify-between pb-1 text-sm font-medium leading-6 text-ash-900"
>
{{ label }}
</label>
<div class="flex flex-col gap-4">
<div
v-for="item in items"
:key="item.id"
class="flex flex-row items-start gap-2"
>
<CheckBox
:is-checked="item.model"
:value="item.value"
@update="onChange"
/>
<label class="text-sm font-normal text-ash-900">
{{ item.label }}
</label>
</div>
</div>
</div>
</template>
<script setup>
import CheckBox from 'v3/components/Form/CheckBox.vue';
defineProps({
label: {
type: String,
required: true,
},
items: {
type: Array,
required: true,
},
});
const emit = defineEmits(['change']);
const onChange = (id, value) => {
emit('change', id, value);
};
</script>
@@ -0,0 +1,66 @@
<template>
<div>
<label
class="flex justify-between pb-1 text-sm font-medium leading-6 text-ash-900"
>
{{ label }}
</label>
<div
class="flex flex-row justify-between h-10 max-w-xl p-2 border border-solid rounded-xl border-ash-200"
>
<div
v-for="option in alertEvents"
:key="option.value"
class="flex flex-row items-center justify-center gap-2 px-4 border-r border-ash-200 grow last:border-r-0"
>
<input
:id="`radio-${option.value}`"
v-model="selectedValue"
class="shadow cursor-pointer grid place-items-center border-2 border-ash-200 appearance-none rounded-full w-4 h-4 checked:bg-primary-600 before:content-[''] before:bg-primary-600 before:border-4 before:rounded-full before:border-ash-25 checked:before:w-[14px] checked:before:h-[14px] checked:border checked:border-primary-600"
type="radio"
:value="option.value"
/>
<label
:for="`radio-${option.value}`"
class="text-sm font-medium"
:class="
selectedValue === option.value ? 'text-ash-900' : 'text-ash-800'
"
>
{{
$t(
`PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.ALERT_TYPES.${option.label.toUpperCase()}`
)
}}
</label>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { ALERT_EVENTS } from './constants';
const props = defineProps({
label: {
type: String,
default: '',
},
value: {
type: String,
default: 'all',
},
});
const alertEvents = ALERT_EVENTS;
const emit = defineEmits(['update']);
const selectedValue = computed({
get: () => props.value,
set: value => {
emit('update', value);
},
});
</script>
@@ -0,0 +1,56 @@
<template>
<form-select
v-model="selectedValue"
name="alertTone"
spacing="compact"
:value="selectedValue"
:options="alertTones"
:label="label"
class="max-w-xl"
>
<option
v-for="tone in alertTones"
:key="tone.label"
:value="tone.value"
:selected="tone.value === selectedValue"
>
{{ tone.label }}
</option>
</form-select>
</template>
<script setup>
import { computed } from 'vue';
import FormSelect from 'v3/components/Form/Select.vue';
const props = defineProps({
value: {
type: String,
required: true,
validator: value => ['ding', 'bell'].includes(value),
},
label: {
type: String,
default: '',
},
});
const alertTones = computed(() => [
{
value: 'ding',
label: 'Ding',
},
{
value: 'bell',
label: 'Bell',
},
]);
const emit = defineEmits(['change']);
const selectedValue = computed({
get: () => props.value,
set: value => {
emit('change', value);
},
});
</script>
@@ -0,0 +1,128 @@
<template>
<div id="profile-settings-notifications" class="flex flex-col gap-6">
<audio-alert-tone
:value="alertTone"
:label="
$t(
'PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.DEFAULT_TONE.TITLE'
)
"
@change="handleAudioToneChange"
/>
<audio-alert-event
:label="
$t('PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.ALERT_TYPE.TITLE')
"
:value="audioAlert"
@update="handAudioAlertChange"
/>
<audio-alert-condition
:items="audioAlertConditions"
:label="
$t('PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.CONDITIONS.TITLE')
"
@change="handleAudioAlertConditions"
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import configMixin from 'shared/mixins/configMixin';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import AudioAlertTone from './AudioAlertTone.vue';
import AudioAlertEvent from './AudioAlertEvent.vue';
import AudioAlertCondition from './AudioAlertCondition.vue';
export default {
components: {
AudioAlertEvent,
AudioAlertTone,
AudioAlertCondition,
},
mixins: [alertMixin, configMixin, uiSettingsMixin],
data() {
return {
audioAlert: '',
playAudioWhenTabIsInactive: false,
alertIfUnreadConversationExist: false,
alertTone: 'ding',
audioAlertConditions: [],
};
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
uiSettings: 'getUISettings',
}),
},
watch: {
uiSettings(value) {
this.notificationUISettings(value);
},
},
mounted() {
this.notificationUISettings(this.uiSettings);
this.$store.dispatch('userNotificationSettings/get');
},
methods: {
notificationUISettings(uiSettings) {
const {
enable_audio_alerts: audioAlert = '',
always_play_audio_alert: alwaysPlayAudioAlert,
alert_if_unread_assigned_conversation_exist:
alertIfUnreadConversationExist,
notification_tone: alertTone,
} = uiSettings;
this.audioAlert = audioAlert;
this.playAudioWhenTabIsInactive = !alwaysPlayAudioAlert;
this.alertIfUnreadConversationExist = alertIfUnreadConversationExist;
this.audioAlertConditions = [
{
id: 'audio1',
label: this.$t(
'PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.CONDITIONS.CONDITION_ONE'
),
model: this.playAudioWhenTabIsInactive,
value: 'tab_is_inactive',
},
{
id: 'audio2',
label: this.$t(
'PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.CONDITIONS.CONDITION_TWO'
),
model: this.alertIfUnreadConversationExist,
value: 'conversations_are_read',
},
];
this.alertTone = alertTone || 'ding';
},
handAudioAlertChange(value) {
this.audioAlert = value;
this.updateUISettings({
enable_audio_alerts: this.audioAlert,
});
this.showAlert(this.$t('PROFILE_SETTINGS.FORM.API.UPDATE_SUCCESS'));
},
handleAudioAlertConditions(id, value) {
if (id === 'tab_is_inactive') {
this.updateUISettings({
always_play_audio_alert: !value,
});
} else if (id === 'conversations_are_read') {
this.updateUISettings({
alert_if_unread_assigned_conversation_exist: value,
});
}
this.showAlert(this.$t('PROFILE_SETTINGS.FORM.API.UPDATE_SUCCESS'));
},
handleAudioToneChange(value) {
this.updateUISettings({ notification_tone: value });
this.showAlert(this.$t('PROFILE_SETTINGS.FORM.API.UPDATE_SUCCESS'));
},
},
};
</script>
@@ -0,0 +1,137 @@
<template>
<form @submit.prevent="changePassword()">
<div class="flex flex-col w-full gap-4">
<woot-input
v-model="currentPassword"
type="password"
:styles="inputStyles"
:class="{ error: $v.currentPassword.$error }"
:label="$t('PROFILE_SETTINGS.FORM.CURRENT_PASSWORD.LABEL')"
:placeholder="$t('PROFILE_SETTINGS.FORM.CURRENT_PASSWORD.PLACEHOLDER')"
:error="`${
$v.currentPassword.$error
? $t('PROFILE_SETTINGS.FORM.CURRENT_PASSWORD.ERROR')
: ''
}`"
@input="$v.currentPassword.$touch"
/>
<woot-input
v-model="password"
type="password"
:styles="inputStyles"
:class="{ error: $v.password.$error }"
:label="$t('PROFILE_SETTINGS.FORM.PASSWORD.LABEL')"
:placeholder="$t('PROFILE_SETTINGS.FORM.PASSWORD.PLACEHOLDER')"
:error="`${
$v.password.$error ? $t('PROFILE_SETTINGS.FORM.PASSWORD.ERROR') : ''
}`"
@input="$v.password.$touch"
/>
<woot-input
v-model="passwordConfirmation"
type="password"
:styles="inputStyles"
:class="{ error: $v.passwordConfirmation.$error }"
:label="$t('PROFILE_SETTINGS.FORM.PASSWORD_CONFIRMATION.LABEL')"
:placeholder="
$t('PROFILE_SETTINGS.FORM.PASSWORD_CONFIRMATION.PLACEHOLDER')
"
:error="`${
$v.passwordConfirmation.$error
? $t('PROFILE_SETTINGS.FORM.PASSWORD_CONFIRMATION.ERROR')
: ''
}`"
@input="$v.passwordConfirmation.$touch"
/>
<form-button
type="submit"
color-scheme="primary"
variant="solid"
size="large"
:disabled="isButtonDisabled"
>
{{ $t('PROFILE_SETTINGS.FORM.PASSWORD_SECTION.BTN_TEXT') }}
</form-button>
</div>
</form>
</template>
<script>
import { required, minLength } from 'vuelidate/lib/validators';
import alertMixin from 'shared/mixins/alertMixin';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import FormButton from 'v3/components/Form/Button.vue';
export default {
components: {
FormButton,
},
mixins: [alertMixin],
data() {
return {
currentPassword: '',
password: '',
passwordConfirmation: '',
isPasswordChanging: false,
errorMessage: '',
inputStyles: {
borderRadius: '12px',
padding: '6px 12px',
fontSize: '14px',
marginBottom: '2px',
},
};
},
validations: {
currentPassword: {
required,
},
password: {
minLength: minLength(6),
},
passwordConfirmation: {
minLength: minLength(6),
isEqPassword(value) {
if (value !== this.password) {
return false;
}
return true;
},
},
},
computed: {
isButtonDisabled() {
return (
!this.currentPassword ||
!this.passwordConfirmation ||
!this.$v.passwordConfirmation.isEqPassword
);
},
},
methods: {
async changePassword() {
this.$v.$touch();
if (this.$v.$invalid) {
this.showAlert(this.$t('PROFILE_SETTINGS.FORM.ERROR'));
return;
}
let alertMessage = this.$t('PROFILE_SETTINGS.PASSWORD_UPDATE_SUCCESS');
try {
await this.$store.dispatch('updateProfile', {
password: this.password,
password_confirmation: this.passwordConfirmation,
current_password: this.currentPassword,
});
} catch (error) {
alertMessage =
parseAPIErrorResponse(error) ||
this.$t('RESET_PASSWORD.API.ERROR_MESSAGE');
} finally {
this.showAlert(alertMessage);
}
},
},
};
</script>
@@ -0,0 +1,64 @@
<template>
<div
class="flex flex-col gap-4 w-full h-fit sm:max-h-[220px] p-4 sm:max-w-[350px] rounded-md border border-solid border-ash-200"
:class="{
'border-primary-300 ': active,
}"
@click="$emit('click')"
>
<div class="flex flex-col gap-2 items-center w-full rounded-t-[5px]">
<div class="flex items-center justify-between w-full gap-1">
<div class="flex items-center text-base font-medium text-ash-900">
{{ title }}
</div>
<input
:checked="active"
type="radio"
:name="`hotkey-${title}`"
class="shadow cursor-pointer grid place-items-center border-2 border-ash-200 appearance-none rounded-full w-5 h-5 checked:bg-primary-600 before:content-[''] before:bg-primary-600 before:border-4 before:rounded-full before:border-ash-25 checked:before:w-[18px] checked:before:h-[18px] checked:border checked:border-primary-600"
/>
</div>
<span class="text-ash-900 text-sm line-clamp-2 leading-[1.4] text-start">
{{ description }}
</span>
</div>
<div>
<img
:src="lightImage"
:alt="`Light themed image for ${title}`"
class="block object-cover w-full dark:hidden"
/>
<img
:src="darkImage"
:alt="`Dark themed image for ${title}`"
class="hidden object-cover w-full dark:block"
/>
</div>
</div>
</template>
<script setup>
defineProps({
active: {
type: Boolean,
default: false,
},
title: {
type: String,
default: '',
},
description: {
type: String,
default: '',
},
lightImage: {
type: String,
default: '',
},
darkImage: {
type: String,
default: '',
},
});
</script>
@@ -0,0 +1,264 @@
<template>
<div class="flex items-center w-full overflow-y-auto">
<div class="flex flex-col h-full p-5 pt-16 mx-auto my-0 font-inter">
<div class="flex flex-col gap-16 pb-8 sm:max-w-[720px]">
<div class="flex flex-col gap-6">
<h2 class="mt-4 text-2xl font-medium text-ash-900">
{{ $t('PROFILE_SETTINGS.TITLE') }}
</h2>
<user-profile-picture
:src="avatarUrl"
:name="name"
size="72px"
@change="updateProfilePicture"
@delete="deleteProfilePicture"
/>
<user-basic-details
:name="name"
:display-name="displayName"
:email="email"
:email-enabled="!globalConfig.disableUserProfileUpdate"
@update-user="updateProfile"
/>
</div>
<form-section
:title="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.TITLE')"
:description="
$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.NOTE')
"
>
<message-signature
:message-signature="messageSignature"
@update-signature="updateSignature"
/>
</form-section>
<form-section
:title="$t('PROFILE_SETTINGS.FORM.SEND_MESSAGE.TITLE')"
:description="$t('PROFILE_SETTINGS.FORM.SEND_MESSAGE.NOTE')"
>
<div
class="flex flex-col justify-between w-full gap-5 sm:gap-4 sm:flex-row"
>
<button
v-for="hotKey in hotKeys"
:key="hotKey.key"
class="px-0 reset-base"
>
<hot-key-card
:key="hotKey.title"
:title="hotKey.title"
:description="hotKey.description"
:light-image="hotKey.lightImage"
:dark-image="hotKey.darkImage"
:active="isEditorHotKeyEnabled(uiSettings, hotKey.key)"
@click="toggleHotKey(hotKey.key)"
/>
</button>
</div>
</form-section>
<form-section
:title="$t('PROFILE_SETTINGS.FORM.PASSWORD_SECTION.TITLE')"
>
<change-password v-if="!globalConfig.disableUserProfileUpdate" />
</form-section>
<form-section
:title="$t('PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.TITLE')"
:description="
$t('PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.NOTE')
"
>
<audio-notifications />
</form-section>
<form-section :title="$t('PROFILE_SETTINGS.FORM.NOTIFICATIONS.TITLE')">
<notification-preferences />
</form-section>
<form-section
:title="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.TITLE')"
:description="
useInstallationName(
$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE'),
globalConfig.installationName
)
"
>
<access-token
:value="currentUser.access_token"
@on-copy="onCopyToken"
/>
</form-section>
</div>
</div>
</div>
</template>
<script>
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import uiSettingsMixin, {
isEditorHotKeyEnabled,
} from 'dashboard/mixins/uiSettings';
import alertMixin from 'shared/mixins/alertMixin';
import { mapGetters } from 'vuex';
import { clearCookiesOnLogout } from 'dashboard/store/utils/api.js';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import UserProfilePicture from './UserProfilePicture.vue';
import UserBasicDetails from './UserBasicDetails.vue';
import MessageSignature from './MessageSignature.vue';
import HotKeyCard from './HotKeyCard.vue';
import ChangePassword from './ChangePassword.vue';
import NotificationPreferences from './NotificationPreferences.vue';
import AudioNotifications from './AudioNotifications.vue';
import FormSection from 'dashboard/components/FormSection.vue';
import AccessToken from './AccessToken.vue';
export default {
components: {
MessageSignature,
FormSection,
UserProfilePicture,
UserBasicDetails,
HotKeyCard,
ChangePassword,
NotificationPreferences,
AudioNotifications,
AccessToken,
},
mixins: [alertMixin, globalConfigMixin, uiSettingsMixin],
data() {
return {
avatarFile: '',
avatarUrl: '',
name: '',
displayName: '',
email: '',
messageSignature: '',
hotKeys: [
{
key: 'enter',
title: this.$t(
'PROFILE_SETTINGS.FORM.SEND_MESSAGE.CARD.ENTER_KEY.HEADING'
),
description: this.$t(
'PROFILE_SETTINGS.FORM.SEND_MESSAGE.CARD.ENTER_KEY.CONTENT'
),
lightImage: '/assets/images/dashboard/profile/hot-key-enter.svg',
darkImage: '/assets/images/dashboard/profile/hot-key-enter-dark.svg',
},
{
key: 'cmd_enter',
title: this.$t(
'PROFILE_SETTINGS.FORM.SEND_MESSAGE.CARD.CMD_ENTER_KEY.HEADING'
),
description: this.$t(
'PROFILE_SETTINGS.FORM.SEND_MESSAGE.CARD.CMD_ENTER_KEY.CONTENT'
),
lightImage: '/assets/images/dashboard/profile/hot-key-ctrl-enter.svg',
darkImage:
'/assets/images/dashboard/profile/hot-key-ctrl-enter-dark.svg',
},
],
};
},
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
currentUserId: 'getCurrentUserID',
globalConfig: 'globalConfig/get',
}),
},
mounted() {
if (this.currentUserId) {
this.initializeUser();
}
},
methods: {
initializeUser() {
this.name = this.currentUser.name;
this.email = this.currentUser.email;
this.avatarUrl = this.currentUser.avatar_url;
this.displayName = this.currentUser.display_name;
this.messageSignature = this.currentUser.message_signature;
},
isEditorHotKeyEnabled,
async dispatchUpdate(payload, successMessage, errorMessage) {
let alertMessage = '';
try {
await this.$store.dispatch('updateProfile', payload);
alertMessage = successMessage;
return true; // return the value so that the status can be known
} catch (error) {
alertMessage = error?.response?.data?.error
? error.response.data.error
: errorMessage;
return false; // return the value so that the status can be known
} finally {
this.showAlert(alertMessage);
}
},
async updateProfile(userAttributes) {
const { name, email, displayName } = userAttributes;
const hasEmailChanged = this.currentUser.email !== email;
this.name = name || this.name;
this.email = email || this.email;
this.displayName = displayName || this.displayName;
const updatePayload = {
name: this.name,
email: this.email,
displayName: this.displayName,
avatar: this.avatarFile,
};
const success = await this.dispatchUpdate(
updatePayload,
hasEmailChanged
? this.$t('PROFILE_SETTINGS.AFTER_EMAIL_CHANGED')
: this.$t('PROFILE_SETTINGS.UPDATE_SUCCESS'),
this.$t('RESET_PASSWORD.API.ERROR_MESSAGE')
);
if (hasEmailChanged && success) clearCookiesOnLogout();
},
async updateSignature(signature) {
const payload = { message_signature: signature };
let successMessage = this.$t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.API_SUCCESS'
);
let errorMessage = this.$t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.API_ERROR'
);
await this.dispatchUpdate(payload, successMessage, errorMessage);
},
updateProfilePicture({ file, url }) {
this.avatarFile = file;
this.avatarUrl = url;
},
async deleteProfilePicture() {
try {
await this.$store.dispatch('deleteAvatar');
this.avatarUrl = '';
this.avatarFile = '';
this.showAlert(this.$t('PROFILE_SETTINGS.AVATAR_DELETE_SUCCESS'));
} catch (error) {
this.showAlert(this.$t('PROFILE_SETTINGS.AVATAR_DELETE_FAILED'));
}
},
toggleHotKey(key) {
this.hotKeys = this.hotKeys.map(hotKey =>
hotKey.key === key ? { ...hotKey, active: !hotKey.active } : hotKey
);
this.updateUISettings({ editor_message_key: key });
this.showAlert(
this.$t('PROFILE_SETTINGS.FORM.SEND_MESSAGE.UPDATE_SUCCESS')
);
},
async onCopyToken(value) {
await copyTextToClipboard(value);
this.showAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
},
},
};
</script>
@@ -0,0 +1,50 @@
<template>
<form class="flex flex-col gap-6" @submit.prevent="updateSignature()">
<woot-message-editor
id="message-signature-input"
v-model="signature"
class="message-editor h-[10rem] !px-3"
:is-format-mode="true"
:placeholder="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')"
:enabled-menu-options="customEditorMenuList"
:enable-suggestions="false"
:show-image-resize-toolbar="true"
/>
<form-button
type="submit"
color-scheme="primary"
variant="solid"
size="large"
>
{{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.BTN_TEXT') }}
</form-button>
</form>
</template>
<script setup>
import { ref, watch } from 'vue';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import { MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import FormButton from 'v3/components/Form/Button.vue';
const props = defineProps({
messageSignature: {
type: String,
default: '',
},
});
const customEditorMenuList = MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS;
const signature = ref(props.messageSignature);
const emit = defineEmits(['update-signature']);
watch(
() => props.messageSignature,
newValue => {
signature.value = newValue;
}
);
const updateSignature = () => {
emit('update-signature', signature.value);
};
</script>
@@ -0,0 +1,53 @@
<template>
<div
class="flex items-start gap-2 px-0 text-sm tracking-[0.5] text-left rtl:text-right"
:class="`col-span-${span}`"
>
<input
v-model="localFlags"
class="mt-1 flex-shrink-0 border-ash-200 border checked:border-none checked:bg-primary-600 dark:checked:bg-primary-600 shadow appearance-none rounded-[4px] w-4 h-4 focus:ring-1 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center after:text-center after:text-xs after:font-bold after:relative"
type="checkbox"
:value="localValue"
@input="handleInput"
/>
</div>
</template>
<script setup>
import { ref, watch } from 'vue';
const props = defineProps({
value: {
type: String,
required: true,
},
span: {
type: Number,
default: 2,
},
selectedFlags: {
type: Array,
required: true,
},
type: {
type: String,
required: true,
},
});
const emit = defineEmits(['input']);
const localValue = ref(props.value);
const localFlags = ref(props.selectedFlags);
watch(
() => props.selectedFlags,
newValue => {
localFlags.value = newValue;
}
);
const handleInput = e => {
emit('input', props.type, e);
};
</script>
@@ -0,0 +1,284 @@
<template>
<div id="profile-settings-notifications" class="flex flex-col gap-6">
<!-- Layout for desktop devices -->
<div class="hidden sm:block">
<div
class="grid content-center h-12 grid-cols-12 gap-4 py-0 rounded-t-xl"
>
<table-header-cell
:span="7"
label="`${$t('PROFILE_SETTINGS.FORM.NOTIFICATIONS.TYPE_TITLE')}`"
>
<span class="text-sm font-normal normal-case text-ash-800">
{{ $t('PROFILE_SETTINGS.FORM.NOTIFICATIONS.TYPE_TITLE') }}
</span>
</table-header-cell>
<table-header-cell
:span="2"
label="`${$t('PROFILE_SETTINGS.FORM.NOTIFICATIONS.EMAIL')}`"
>
<span class="text-sm font-medium normal-case text-ash-900">
{{ $t('PROFILE_SETTINGS.FORM.NOTIFICATIONS.EMAIL') }}
</span>
</table-header-cell>
<table-header-cell
:span="3"
label="`${$t('PROFILE_SETTINGS.FORM.NOTIFICATIONS.PUSH')}`"
>
<div class="flex items-center justify-between gap-1">
<span
class="text-sm font-medium normal-case text-ash-900 whitespace-nowrap"
>
{{ $t('PROFILE_SETTINGS.FORM.NOTIFICATIONS.PUSH') }}
</span>
<form-switch
:value="hasEnabledPushPermissions"
@input="onRequestPermissions"
/>
</div>
</table-header-cell>
</div>
<div
v-for="(notification, index) in filteredNotificationTypes"
:key="index"
>
<div
class="grid items-center content-center h-12 grid-cols-12 gap-4 py-0 rounded-t-xl"
>
<div
class="flex flex-row items-start gap-2 col-span-7 px-0 py-2 text-sm tracking-[0.5] rtl:text-right"
>
<span class="text-sm text-ash-900">
{{ $t(notification.label) }}
</span>
</div>
<div
v-for="(type, typeIndex) in ['email', 'push']"
:key="typeIndex"
class="flex items-start gap-2 px-0 text-sm tracking-[0.5] text-left rtl:text-right"
:class="`col-span-${type === 'push' ? 3 : 2}`"
>
<CheckBox
:value="`${type}_${notification.value}`"
:is-checked="
checkFlagStatus(type, notification.value, selectedPushFlags)
"
@update="id => handleInput(type, id)"
/>
</div>
</div>
</div>
</div>
<!-- Layout for mobile devices -->
<div class="flex flex-col gap-6 sm:hidden">
<span class="text-sm font-medium normal-case text-ash-900">
{{ $t('PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.TITLE') }}
</span>
<div class="flex flex-col gap-4">
<div
v-for="(notification, index) in filteredNotificationTypes"
:key="index"
class="flex flex-row items-start gap-2"
>
<CheckBox
:id="`email_${notification.value}`"
:value="`email_${notification.value}`"
:is-checked="checkFlagStatus('email', notification.value)"
@update="handleEmailInput"
/>
<span class="text-sm text-ash-900">{{ $t(notification.label) }}</span>
</div>
</div>
<div class="flex items-center justify-start gap-2">
<span class="text-sm font-medium normal-case text-ash-900">
{{ $t('PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.TITLE') }}
</span>
<form-switch
:value="hasEnabledPushPermissions"
@input="onRequestPermissions"
/>
</div>
<div class="flex flex-col gap-4">
<div
v-for="(notification, index) in filteredNotificationTypes"
:key="index"
class="flex flex-row items-start gap-2"
>
<CheckBox
:id="`push_${notification.value}`"
:value="`push_${notification.value}`"
:is-checked="checkFlagStatus('push', notification.value)"
@update="handlePushInput"
/>
<span class="text-sm text-ash-900">{{ $t(notification.label) }}</span>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import configMixin from 'shared/mixins/configMixin';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import TableHeaderCell from 'dashboard/components/widgets/TableHeaderCell.vue';
import CheckBox from 'v3/components/Form/CheckBox.vue';
import {
hasPushPermissions,
requestPushPermissions,
verifyServiceWorkerExistence,
} from 'dashboard/helper/pushHelper.js';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import FormSwitch from 'v3/components/Form/Switch.vue';
import { NOTIFICATION_TYPES } from './constants';
export default {
components: {
TableHeaderCell,
FormSwitch,
CheckBox,
},
mixins: [alertMixin, configMixin, uiSettingsMixin],
data() {
return {
selectedEmailFlags: [],
selectedPushFlags: [],
enableAudioAlerts: false,
hasEnabledPushPermissions: false,
notificationTypes: NOTIFICATION_TYPES,
};
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
emailFlags: 'userNotificationSettings/getSelectedEmailFlags',
pushFlags: 'userNotificationSettings/getSelectedPushFlags',
uiSettings: 'getUISettings',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
hasPushAPISupport() {
return !!('Notification' in window);
},
isSLAEnabled() {
return this.isFeatureEnabledonAccount(this.accountId, FEATURE_FLAGS.SLA);
},
filteredNotificationTypes() {
return this.notificationTypes.filter(notification =>
this.isSLAEnabled
? true
: ![
'sla_missed_first_response',
'sla_missed_next_response',
'sla_missed_resolution',
].includes(notification.value)
);
},
},
watch: {
emailFlags(value) {
this.selectedEmailFlags = value;
},
pushFlags(value) {
this.selectedPushFlags = value;
},
},
mounted() {
if (hasPushPermissions()) {
this.getPushSubscription();
}
this.$store.dispatch('userNotificationSettings/get');
},
methods: {
checkFlagStatus(type, flagType) {
const selectedFlags =
type === 'email' ? this.selectedEmailFlags : this.selectedPushFlags;
return selectedFlags.includes(`${type}_${flagType}`);
},
onRegistrationSuccess() {
this.hasEnabledPushPermissions = true;
},
onRequestPermissions() {
requestPushPermissions({
onSuccess: this.onRegistrationSuccess,
});
},
getPushSubscription() {
verifyServiceWorkerExistence(registration =>
registration.pushManager
.getSubscription()
.then(subscription => {
if (!subscription) {
this.hasEnabledPushPermissions = false;
} else {
this.hasEnabledPushPermissions = true;
}
})
// eslint-disable-next-line no-console
.catch(error => console.log(error))
);
},
async updateNotificationSettings() {
try {
this.$store.dispatch('userNotificationSettings/update', {
selectedEmailFlags: this.selectedEmailFlags,
selectedPushFlags: this.selectedPushFlags,
});
this.showAlert(this.$t('PROFILE_SETTINGS.FORM.API.UPDATE_SUCCESS'));
} catch (error) {
this.showAlert(this.$t('PROFILE_SETTINGS.FORM.API.UPDATE_ERROR'));
}
},
handleAudioInput(e) {
this.enableAudioAlerts = e.target.value;
this.updateUISettings({
enable_audio_alerts: this.enableAudioAlerts,
});
this.showAlert(this.$t('PROFILE_SETTINGS.FORM.API.UPDATE_SUCCESS'));
},
handleAudioAlertConditions(e) {
let condition = e.target.value;
if (condition === 'tab_is_inactive') {
this.updateUISettings({
always_play_audio_alert: !e.target.checked,
});
} else if (condition === 'conversations_are_read') {
this.updateUISettings({
alert_if_unread_assigned_conversation_exist: e.target.checked,
});
}
this.showAlert(this.$t('PROFILE_SETTINGS.FORM.API.UPDATE_SUCCESS'));
},
handleAudioToneChange(value) {
this.updateUISettings({ notification_tone: value });
this.showAlert(this.$t('PROFILE_SETTINGS.FORM.API.UPDATE_SUCCESS'));
},
handleInput(type, id) {
if (type === 'email') {
this.handleEmailInput(id);
} else {
this.handlePushInput(id);
}
},
handleEmailInput(id) {
this.selectedEmailFlags = this.toggleInput(this.selectedEmailFlags, id);
this.updateNotificationSettings();
},
handlePushInput(id) {
this.selectedPushFlags = this.toggleInput(this.selectedPushFlags, id);
this.updateNotificationSettings();
},
toggleInput(selected, current) {
if (selected.includes(current)) {
const newSelectedFlags = selected.filter(flag => flag !== current);
return newSelectedFlags;
}
return [...selected, current];
},
},
};
</script>
@@ -0,0 +1,135 @@
<template>
<form class="flex flex-col gap-4" @submit.prevent="updateUser('profile')">
<woot-input
v-model="userName"
:styles="inputStyles"
:class="{ error: $v.userName.$error }"
:label="$t('PROFILE_SETTINGS.FORM.NAME.LABEL')"
:placeholder="$t('PROFILE_SETTINGS.FORM.NAME.PLACEHOLDER')"
:error="`${
$v.userName.$error ? $t('PROFILE_SETTINGS.FORM.NAME.ERROR') : ''
}`"
@input="$v.userName.$touch"
/>
<woot-input
v-model="userDisplayName"
:styles="inputStyles"
:class="{ error: $v.userDisplayName.$error }"
:label="$t('PROFILE_SETTINGS.FORM.DISPLAY_NAME.LABEL')"
:placeholder="$t('PROFILE_SETTINGS.FORM.DISPLAY_NAME.PLACEHOLDER')"
:error="`${
$v.userDisplayName.$error
? $t('PROFILE_SETTINGS.FORM.DISPLAY_NAME.ERROR')
: ''
}`"
@input="$v.userDisplayName.$touch"
/>
<woot-input
v-if="emailEnabled"
v-model="userEmail"
:styles="inputStyles"
:class="{ error: $v.userEmail.$error }"
:label="$t('PROFILE_SETTINGS.FORM.EMAIL.LABEL')"
:placeholder="$t('PROFILE_SETTINGS.FORM.EMAIL.PLACEHOLDER')"
:error="`${
$v.userEmail.$error ? $t('PROFILE_SETTINGS.FORM.EMAIL.ERROR') : ''
}`"
@input="$v.userEmail.$touch"
/>
<form-button
type="submit"
color-scheme="primary"
variant="solid"
size="large"
>
{{ $t('PROFILE_SETTINGS.BTN_TEXT') }}
</form-button>
</form>
</template>
<script>
import FormButton from 'v3/components/Form/Button.vue';
import { required, minLength, email } from 'vuelidate/lib/validators';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: {
FormButton,
},
mixins: [alertMixin],
props: {
name: {
type: String,
default: '',
},
email: {
type: String,
default: '',
},
displayName: {
type: String,
default: '',
},
emailEnabled: {
type: Boolean,
default: false,
},
},
data() {
return {
userName: this.name,
userDisplayName: this.displayName,
userEmail: this.email,
inputStyles: {
borderRadius: '12px',
padding: '6px 12px',
fontSize: '14px',
marginBottom: '2px',
},
};
},
validations: {
userName: {
required,
minLength: minLength(1),
},
userDisplayName: {},
userEmail: {
required,
email,
},
},
watch: {
name: {
handler(value) {
this.userName = value;
},
immediate: true,
},
displayName: {
handler(value) {
this.userDisplayName = value;
},
immediate: true,
},
email: {
handler(value) {
this.userEmail = value;
},
immediate: true,
},
},
methods: {
async updateUser() {
this.$v.$touch();
if (this.$v.$invalid) {
this.showAlert(this.$t('PROFILE_SETTINGS.FORM.ERROR'));
return;
}
this.$emit('update-user', {
name: this.userName,
displayName: this.userDisplayName,
email: this.userEmail,
});
},
},
};
</script>
@@ -0,0 +1,40 @@
<template>
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-ash-900">
{{ $t('PROFILE_SETTINGS.FORM.PICTURE') }}
</span>
<profile-avatar
:src="src"
:name="userNameWithoutEmoji"
@change="updateProfilePicture"
@delete="deleteProfilePicture"
/>
</div>
</template>
<script setup>
import { computed } from 'vue';
import ProfileAvatar from 'v3/components/Form/ProfileAvatar.vue';
import { removeEmoji } from 'shared/helpers/emoji';
const props = defineProps({
src: {
type: String,
default: '',
},
name: {
type: String,
default: '',
},
});
const emits = defineEmits(['change', 'delete']);
const userNameWithoutEmoji = computed(() => removeEmoji(props.name));
const updateProfilePicture = e => {
emits('change', e);
};
const deleteProfilePicture = () => {
emits('delete');
};
</script>
@@ -0,0 +1,52 @@
export const NOTIFICATION_TYPES = [
{
label: 'PROFILE_SETTINGS.FORM.NOTIFICATIONS.TYPES.CONVERSATION_CREATED',
value: 'conversation_creation',
},
{
label: 'PROFILE_SETTINGS.FORM.NOTIFICATIONS.TYPES.CONVERSATION_ASSIGNED',
value: 'conversation_assignment',
},
{
label: 'PROFILE_SETTINGS.FORM.NOTIFICATIONS.TYPES.CONVERSATION_MENTION',
value: 'conversation_mention',
},
{
label:
'PROFILE_SETTINGS.FORM.NOTIFICATIONS.TYPES.ASSIGNED_CONVERSATION_NEW_MESSAGE',
value: 'assigned_conversation_new_message',
},
{
label:
'PROFILE_SETTINGS.FORM.NOTIFICATIONS.TYPES.PARTICIPATING_CONVERSATION_NEW_MESSAGE',
value: 'participating_conversation_new_message',
},
{
label:
'PROFILE_SETTINGS.FORM.NOTIFICATIONS.TYPES.SLA_MISSED_FIRST_RESPONSE',
value: 'sla_missed_first_response',
},
{
label: 'PROFILE_SETTINGS.FORM.NOTIFICATIONS.TYPES.SLA_MISSED_NEXT_RESPONSE',
value: 'sla_missed_next_response',
},
{
label: 'PROFILE_SETTINGS.FORM.NOTIFICATIONS.TYPES.SLA_MISSED_RESOLUTION',
value: 'sla_missed_resolution',
},
];
export const ALERT_EVENTS = [
{
value: 'none',
label: 'none',
},
{
value: 'mine',
label: 'mine',
},
{
value: 'all',
label: 'all',
},
];
@@ -0,0 +1,20 @@
import { frontendURL } from 'dashboard/helper/URLHelper';
const Index = () => import('./Index.vue');
export default {
routes: [
{
path: frontendURL('accounts/:accountId/personal'),
name: 'personal_settings',
roles: ['administrator', 'agent'],
component: Index,
props: {
headerTitle: 'PROFILE_SETTINGS.TITLE',
icon: 'edit',
showNewButton: false,
showSidemenuIcon: false,
},
},
],
};
@@ -1,24 +1,10 @@
<template>
<div class="flex flex-col flex-wrap w-full gap-3 md:flex-row">
<reports-filters-date-range
class="sm:min-w-[200px] tiny h-8"
@on-range-change="onDateRangeChange"
/>
<woot-date-range-picker
v-if="isDateRangeSelected"
show-range
class="no-margin auto-width sm:min-w-[240px] small h-8"
:value="customDateRange"
:confirm-text="$t('REPORT.CUSTOM_DATE_RANGE.CONFIRM')"
:placeholder="$t('REPORT.CUSTOM_DATE_RANGE.PLACEHOLDER')"
@change="onCustomDateRangeChange"
/>
<woot-date-picker @dateRangeChanged="onDateRangeChange" />
<SLA-filter @filter-change="emitFilterChange" />
</div>
</template>
<script>
import WootDateRangePicker from 'dashboard/components/ui/DateRangePicker.vue';
import ReportsFiltersDateRange from '../Filters/DateRange.vue';
import SLAFilter from '../SLA/SLAFilter.vue';
import subDays from 'date-fns/subDays';
import { DATE_RANGE_OPTIONS } from '../../constants';
@@ -26,8 +12,6 @@ import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
export default {
components: {
WootDateRangePicker,
ReportsFiltersDateRange,
SLAFilter,
},
@@ -39,25 +23,11 @@ export default {
};
},
computed: {
isDateRangeSelected() {
return (
this.selectedDateRange.id === DATE_RANGE_OPTIONS.CUSTOM_DATE_RANGE.id
);
},
to() {
if (this.isDateRangeSelected) {
return getUnixEndOfDay(this.customDateRange[1]);
}
return getUnixEndOfDay(new Date());
return getUnixEndOfDay(this.customDateRange[1]);
},
from() {
if (this.isDateRangeSelected) {
return getUnixStartOfDay(this.customDateRange[0]);
}
const { offset } = this.selectedDateRange;
const fromDate = subDays(new Date(), offset);
return getUnixStartOfDay(fromDate);
return getUnixStartOfDay(this.customDateRange[0]);
},
},
watch: {
@@ -66,9 +36,20 @@ export default {
},
},
mounted() {
this.emitChange();
this.setInitialRange();
},
methods: {
setInitialRange() {
const { offset } = this.selectedDateRange;
const fromDate = subDays(new Date(), offset);
const from = getUnixStartOfDay(fromDate);
const to = getUnixEndOfDay(new Date());
this.$emit('filter-change', {
from,
to,
...this.selectedGroupByFilter,
});
},
emitChange() {
const { from, to } = this;
this.$emit('filter-change', {
@@ -81,11 +62,7 @@ export default {
this.selectedGroupByFilter = params;
this.emitChange();
},
onDateRangeChange(selectedRange) {
this.selectedDateRange = selectedRange;
this.emitChange();
},
onCustomDateRangeChange(value) {
onDateRangeChange(value) {
this.customDateRange = value;
this.emitChange();
},
@@ -22,14 +22,11 @@
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import SLAPopoverCard from 'dashboard/components/widgets/conversation/components/SLAPopoverCard.vue';
export default {
components: {
SLAPopoverCard,
},
mixins: [clickaway],
props: {
slaEvents: {
type: Array,
@@ -18,6 +18,7 @@ import reports from './reports/reports.routes';
import store from '../../../store';
import sla from './sla/sla.routes';
import teams from './teams/teams.routes';
import personal from './personal/personal.routes';
export default {
routes: [
@@ -50,5 +51,6 @@ export default {
...reports.routes,
...sla.routes,
...teams.routes,
...personal.routes,
],
};
@@ -23,8 +23,6 @@
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import SearchSuggestions from './SearchSuggestions.vue';
import PublicSearchInput from './PublicSearchInput.vue';
@@ -35,7 +33,6 @@ export default {
PublicSearchInput,
SearchSuggestions,
},
mixins: [clickaway],
props: {
value: {
type: [String, Number],
@@ -58,6 +58,7 @@
"M21 6.25A3.25 3.25 0 0 0 17.75 3H6.25A3.25 3.25 0 0 0 3 6.25v11.5A3.25 3.25 0 0 0 6.25 21h5.772a6.471 6.471 0 0 1-.709-1.5H6.25a1.75 1.75 0 0 1-1.75-1.75V8.5h15v2.813a6.471 6.471 0 0 1 1.5.709V6.25ZM6.25 4.5h11.5c.966 0 1.75.784 1.75 1.75V7h-15v-.75c0-.966.784-1.75 1.75-1.75Z",
"M23 17.5a5.5 5.5 0 1 0-11 0 5.5 5.5 0 0 0 11 0Zm-5.5 0h2a.5.5 0 0 1 0 1H17a.5.5 0 0 1-.5-.491v-3.01a.5.5 0 0 1 1 0V17.5Z"
],
"calendar-outline": "M17.75 3A3.25 3.25 0 0 1 21 6.25v11.5A3.25 3.25 0 0 1 17.75 21H6.25A3.25 3.25 0 0 1 3 17.75V6.25A3.25 3.25 0 0 1 6.25 3h11.5Zm1.75 5.5h-15v9.25c0 .966.784 1.75 1.75 1.75h11.5a1.75 1.75 0 0 0 1.75-1.75V8.5Zm-11.75 6a1.25 1.25 0 1 1 0 2.5a1.25 1.25 0 0 1 0-2.5Zm4.25 0a1.25 1.25 0 1 1 0 2.5a1.25 1.25 0 0 1 0-2.5Zm-4.25-4a1.25 1.25 0 1 1 0 2.5a1.25 1.25 0 0 1 0-2.5Zm4.25 0a1.25 1.25 0 1 1 0 2.5a1.25 1.25 0 0 1 0-2.5Zm4.25 0a1.25 1.25 0 1 1 0 2.5a1.25 1.25 0 0 1 0-2.5Zm1.5-6H6.25A1.75 1.75 0 0 0 4.5 6.25V7h15v-.75a1.75 1.75 0 0 0-1.75-1.75Z",
"call-outline": "m7.056 2.418 1.167-.351a2.75 2.75 0 0 1 3.302 1.505l.902 2.006a2.75 2.75 0 0 1-.633 3.139L10.3 10.11a.25.25 0 0 0-.078.155c-.044.397.225 1.17.845 2.245.451.781.86 1.33 1.207 1.637.242.215.375.261.432.245l2.01-.615a2.75 2.75 0 0 1 3.034 1.02l1.281 1.776a2.75 2.75 0 0 1-.339 3.605l-.886.84a3.75 3.75 0 0 1-3.587.889c-2.754-.769-5.223-3.093-7.435-6.924-2.215-3.836-2.992-7.14-2.276-9.913a3.75 3.75 0 0 1 2.548-2.652Zm.433 1.437a2.25 2.25 0 0 0-1.529 1.59c-.602 2.332.087 5.261 2.123 8.788 2.033 3.522 4.222 5.582 6.54 6.23a2.25 2.25 0 0 0 2.151-.534l.887-.84a1.25 1.25 0 0 0 .154-1.639l-1.28-1.775a1.25 1.25 0 0 0-1.38-.464l-2.015.617c-1.17.348-2.232-.593-3.372-2.568C9 11.93 8.642 10.9 8.731 10.099c.047-.416.24-.8.546-1.086l1.494-1.393a1.25 1.25 0 0 0 .288-1.427l-.902-2.006a1.25 1.25 0 0 0-1.5-.684l-1.168.352Z",
"chat-help-outline": "M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10a9.96 9.96 0 0 1-4.587-1.112l-3.826 1.067a1.25 1.25 0 0 1-1.54-1.54l1.068-3.823A9.96 9.96 0 0 1 2 12C2 6.477 6.477 2 12 2Zm0 1.5A8.5 8.5 0 0 0 3.5 12c0 1.47.373 2.883 1.073 4.137l.15.27-1.112 3.984 3.987-1.112.27.15A8.5 8.5 0 1 0 12 3.5Zm0 12a1 1 0 1 1 0 2 1 1 0 0 1 0-2Zm0-8.75a2.75 2.75 0 0 1 2.75 2.75c0 1.01-.297 1.574-1.051 2.359l-.169.171c-.622.622-.78.886-.78 1.47a.75.75 0 0 1-1.5 0c0-1.01.297-1.574 1.051-2.359l.169-.171c.622-.622.78-.886.78-1.47a1.25 1.25 0 0 0-2.493-.128l-.007.128a.75.75 0 0 1-1.5 0A2.75 2.75 0 0 1 12 6.75Z",
"chat-multiple-outline": "M9.562 3a7.5 7.5 0 0 0-6.798 10.673l-.724 2.842a1.25 1.25 0 0 0 1.504 1.524c.75-.18 1.903-.457 2.93-.702A7.5 7.5 0 1 0 9.561 3Zm-6 7.5a6 6 0 1 1 3.33 5.375l-.244-.121-.264.063c-.923.22-1.99.475-2.788.667l.69-2.708.07-.276-.13-.253a5.971 5.971 0 0 1-.664-2.747Zm11 10.5c-1.97 0-3.762-.759-5.1-2h.1c.718 0 1.415-.089 2.08-.257.865.482 1.86.757 2.92.757.96 0 1.866-.225 2.67-.625l.243-.121.264.063c.922.22 1.966.445 2.74.61-.175-.751-.414-1.756-.642-2.651l-.07-.276.13-.253a5.971 5.971 0 0 0 .665-2.747 5.995 5.995 0 0 0-2.747-5.042 8.44 8.44 0 0 0-.8-2.047 7.503 7.503 0 0 1 4.344 10.263c.253 1.008.509 2.1.671 2.803a1.244 1.244 0 0 1-1.467 1.5 132.62 132.62 0 0 1-2.913-.64 7.476 7.476 0 0 1-3.088.663Z",
@@ -272,5 +273,7 @@
"chevrons-left-outline": ["m11 17-5-5 5-5", "m18 17-5-5 5-5"],
"chevron-left-single-outline": "m15 18-6-6 6-6",
"chevrons-right-outline": ["m6 17 5-5-5-5", "m13 17 5-5-5-5"],
"chevron-right-single-outline": "m9 18 6-6-6-6"
"chevron-right-single-outline": "m9 18 6-6-6-6",
"avatar-upload-outline": "M19.754 11a.75.75 0 0 1 .743.648l.007.102v7a3.25 3.25 0 0 1-3.065 3.246l-.185.005h-11a3.25 3.25 0 0 1-3.244-3.066l-.006-.184V11.75a.75.75 0 0 1 1.494-.102l.006.102v7a1.75 1.75 0 0 0 1.607 1.745l.143.006h11A1.75 1.75 0 0 0 19 18.894l.005-.143V11.75a.75.75 0 0 1 .75-.75ZM6.22 7.216l4.996-4.996a.75.75 0 0 1 .976-.073l.084.072l5.005 4.997a.75.75 0 0 1-.976 1.134l-.084-.073l-3.723-3.716l.001 11.694a.75.75 0 0 1-.648.743l-.102.007a.75.75 0 0 1-.743-.648L11 16.255V4.558L7.28 8.277a.75.75 0 0 1-.976.073l-.084-.073a.75.75 0 0 1-.073-.977l.073-.084l4.996-4.996L6.22 7.216Z",
"text-copy-outline": "M5.503 4.627L5.5 6.75v10.504a3.25 3.25 0 0 0 3.25 3.25h8.616a2.25 2.25 0 0 1-2.122 1.5H8.75A4.75 4.75 0 0 1 4 17.254V6.75c0-.98.627-1.815 1.503-2.123M17.75 2A2.25 2.25 0 0 1 20 4.25v13a2.25 2.25 0 0 1-2.25 2.25h-9a2.25 2.25 0 0 1-2.25-2.25v-13A2.25 2.25 0 0 1 8.75 2zm0 1.5h-9a.75.75 0 0 0-.75.75v13c0 .414.336.75.75.75h9a.75.75 0 0 0 .75-.75v-13a.75.75 0 0 0-.75-.75"
}
@@ -74,13 +74,11 @@
<script>
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import MultiselectDropdownItems from 'shared/components/ui/MultiselectDropdownItems.vue';
import { mixin as clickaway } from 'vue-clickaway';
export default {
components: {
Thumbnail,
MultiselectDropdownItems,
},
mixins: [clickaway],
props: {
options: {
type: Array,
@@ -39,12 +39,14 @@ export default {
ArrowUp: {
action: e => {
e.preventDefault();
console.log('ArrowUp in Dropdown Menu');
this.focusPreviousButton(menuButtons);
},
allowOnFocusedInput: true,
},
ArrowDown: {
action: e => {
console.log('ArrowDown in Dropdown Menu');
e.preventDefault();
this.focusNextButton(menuButtons);
},
@@ -1,6 +1,8 @@
/* eslint-disable no-console */
import { isActiveElementTypeable, isEscape } from '../helpers/KeyboardHelpers';
import { createKeybindingsHandler } from 'tinykeys';
import * as Sentry from '@sentry/browser';
// this is a store that stores the handler globally, and only gets reset on reload
const taggedHandlers = [];
@@ -11,22 +13,26 @@ export default {
if (events) {
const wrappedEvents = this.wrapEventsInKeybindingsHandler(events);
const keydownHandler = createKeybindingsHandler(wrappedEvents);
this.appendToHandler(keydownHandler);
document.addEventListener('keydown', keydownHandler);
this.addEventHandler(keydownHandler);
}
},
beforeDestroy() {
if (this.$el && this.$el.dataset.keydownHandlerIndex) {
if (this.$el && this.$el.dataset?.keydownHandlerIndex) {
const handlerToRemove =
taggedHandlers[this.$el.dataset.keydownHandlerIndex];
document.removeEventListener('keydown', handlerToRemove);
}
},
methods: {
appendToHandler(keydownHandler) {
addEventHandler(keydownHandler) {
const indexToAppend = taggedHandlers.push(keydownHandler) - 1;
const root = this.$el;
root.dataset.keydownHandlerIndex = indexToAppend;
if (root && root.dataset) {
// For the components with a top level v-if Vue renders it as an empty comment in the DOM
// so we need to check if the root element has a dataset property to ensure it is a valid element
document.addEventListener('keydown', keydownHandler);
root.dataset.keydownHandlerIndex = indexToAppend;
}
},
getKeyboardEvents() {
return null;
@@ -41,22 +47,31 @@ export default {
},
keydownWrapper(handler) {
return e => {
const actionToPerform =
typeof handler === 'function' ? handler : handler.action;
const allowOnFocusedInput =
typeof handler === 'function' ? false : handler.allowOnFocusedInput;
const isFunction = typeof handler === 'function';
const actionToPerform = isFunction ? handler : handler.action;
const allowOnFocusedInput = isFunction
? false
: handler.allowOnFocusedInput;
const isTypeable = isActiveElementTypeable(e);
try {
const isTypeable = isActiveElementTypeable(e);
if (isTypeable) {
if (isEscape(e)) {
e.target.blur();
if (isTypeable) {
if (isEscape(e)) e.target.blur();
if (!allowOnFocusedInput) return;
}
if (!allowOnFocusedInput) return;
actionToPerform(e);
} catch (err) {
// ignore errors
Sentry.captureException(err, {
context: {
component: this.$options?.name,
isFunction: isFunction,
allowOnFocusedInput: allowOnFocusedInput,
},
});
}
actionToPerform(e);
};
},
},
+23 -12
View File
@@ -3,6 +3,7 @@
class="inline-flex items-center gap-1 text-sm font-medium reset-base rounded-xl w-fit"
:class="buttonClasses"
v-bind="$attrs"
@click="onClick"
>
<fluent-icon
v-if="icon && !trailingIcon"
@@ -56,9 +57,10 @@ const props = defineProps({
const attrs = useAttrs();
const baseClasses = {
outline: 'outline outline-1 -outline-offset-1 focus:ring focus:ring-offset-1',
ghost: 'hover:text-600 active:text-600 focus:ring focus:ring-offset-1',
solid: 'hover:bg-700 active:bg-700 focus:ring focus:ring-offset-1',
outline: 'outline outline-1 -outline-offset-1',
ghost: 'hover:text-600 active:text-600 focus:outline focus:outline-offset-1',
solid:
'hover:bg-700 active:bg-700 focus:outline focus:outline-offset-1 focus:outline-2',
};
const colorClass = computed(() => {
@@ -68,19 +70,19 @@ const colorClass = computed(() => {
const styleMap = {
primary: {
outline: `${baseClasses.outline} outline-primary-400 hover:text-primary-600 active:text-primary-600 focus:ring-primary-400`,
ghost: `${baseClasses.ghost} focus:ring-primary-400`,
solid: `${baseClasses.solid} bg-primary-600 text-white focus:ring-primary-400`,
outline: `${baseClasses.outline} outline-primary-400 hover:text-primary-600 active:text-primary-600`,
ghost: `${baseClasses.ghost} focus:outline-primary-400`,
solid: `${baseClasses.solid} bg-primary-600 text-white focus:outline-primary-400`,
},
secondary: {
outline: `${baseClasses.outline} outline-ash-400 hover:text-ash-600 active:text-ash-600 focus:ring-ash-400`,
ghost: `${baseClasses.ghost} focus:ring-ash-400`,
solid: `${baseClasses.solid} bg-ash-100 text-ash-900 focus:ring-ash-400`,
outline: `${baseClasses.outline} outline-ash-400 hover:text-ash-900 active:text-ash-900 `,
ghost: `${baseClasses.ghost} focus:outline-ash-400`,
solid: `${baseClasses.solid} bg-ash-100 text-ash-900 focus:outline-ash-400`,
},
danger: {
outline: `${baseClasses.outline} outline-ruby-400 hover:text-ruby-600 active:text-ruby-600 focus:ring-ruby-400`,
ghost: `${baseClasses.ghost} focus:ring-ruby-400`,
solid: `${baseClasses.solid} bg-ruby-600 text-white focus:ring-ruby-400`,
outline: `${baseClasses.outline} outline-ruby-400 hover:text-ruby-600 active:text-ruby-600`,
ghost: `${baseClasses.ghost} focus:outline-ruby-400`,
solid: `${baseClasses.solid} bg-ruby-600 text-white focus:outline-ruby-400`,
},
};
@@ -96,4 +98,13 @@ const sizeClass = computed(() => {
});
const buttonClasses = computed(() => [colorClass.value, sizeClass.value]);
const emit = defineEmits(['click']);
const onClick = () => {
if (props.disabled) {
return;
}
emit('click');
};
</script>
@@ -0,0 +1,31 @@
<template>
<input
:id="value"
v-model="checked"
type="checkbox"
:value="value"
class="flex-shrink-0 mt-0.5 border-ash-200 border checked:border-none checked:bg-primary-600 dark:checked:bg-primary-600 shadow appearance-none rounded-[4px] w-4 h-4 focus:ring-1 after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center after:text-center after:text-xs after:font-bold after:relative"
/>
</template>
<script setup>
import { defineProps, defineEmits, computed } from 'vue';
const props = defineProps({
isChecked: {
type: Boolean,
default: false,
},
value: {
type: String,
default: null,
},
});
const emit = defineEmits(['input']);
const checked = computed({
get: () => props.isChecked,
set: value => emit('update', props.value, value),
});
</script>

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