Merge branch 'develop' into feat/CW-3082
This commit is contained in:
@@ -42,19 +42,20 @@
|
||||
}
|
||||
|
||||
@layer base {
|
||||
// scss-lint:disable PropertySortOrder
|
||||
:root {
|
||||
--color-amber-100: 255 243 208;
|
||||
--color-amber-200: 255 236 183;
|
||||
--color-amber-25: 254 253 251;
|
||||
--color-amber-300: 255 224 161;
|
||||
--color-amber-400: 245 208 140;
|
||||
--color-amber-50: 255 249 237;
|
||||
--color-amber-500: 228 187 120;
|
||||
--color-amber-75: 255 243 208;
|
||||
--color-amber-100: 255 236 183;
|
||||
--color-amber-200: 255 224 161;
|
||||
--color-amber-300: 245 208 140;
|
||||
--color-amber-400: 228 187 120;
|
||||
--color-amber-500: 214 163 92;
|
||||
--color-amber-600: 214 163 92;
|
||||
--color-amber-700: 214 163 92;
|
||||
--color-amber-800: 255 186 26;
|
||||
--color-amber-900: 145 89 48;
|
||||
--color-amber-950: 79 52 34;
|
||||
--color-amber-700: 255 186 26;
|
||||
--color-amber-800: 145 89 48;
|
||||
--color-amber-900: 79 52 34;
|
||||
|
||||
--color-ash-100: 235 235 239;
|
||||
--color-ash-200: 228 228 233;
|
||||
@@ -69,18 +70,18 @@
|
||||
--color-ash-800: 96 100 108;
|
||||
--color-ash-900: 28 32 36;
|
||||
|
||||
--color-primary-100: 222 234 255;
|
||||
--color-primary-200: 206 223 255;
|
||||
--color-primary-25: 252 253 254;
|
||||
--color-primary-300: 186 209 255;
|
||||
--color-primary-400: 163 190 255;
|
||||
--color-primary-50: 246 249 255;
|
||||
--color-primary-500: 131 164 250;
|
||||
--color-primary-600: 69 110 255;
|
||||
--color-primary-700: 57 95 241;
|
||||
--color-primary-75: 236 242 255;
|
||||
--color-primary-800: 53 88 217;
|
||||
--color-primary-900: 27 44 99;
|
||||
--color-primary-25: 251 253 255;
|
||||
--color-primary-50: 245 249 255;
|
||||
--color-primary-75: 233 243 255;
|
||||
--color-primary-100: 218 236 255;
|
||||
--color-primary-200: 201 226 255;
|
||||
--color-primary-300: 181 213 255;
|
||||
--color-primary-400: 155 195 252;
|
||||
--color-primary-500: 117 171 247;
|
||||
--color-primary-600: 39 129 246;
|
||||
--color-primary-700: 16 115 233;
|
||||
--color-primary-800: 8 109 224;
|
||||
--color-primary-900: 11 50 101;
|
||||
|
||||
--color-ruby-100: 255 220 225;
|
||||
--color-ruby-200: 255 206 214;
|
||||
@@ -107,21 +108,125 @@
|
||||
--color-teal-75: 236 249 255;
|
||||
--color-teal-800: 0 133 115;
|
||||
--color-teal-900: 13 61 56;
|
||||
|
||||
--color-green-25: 251 254 252;
|
||||
--color-green-50: 244 251 246;
|
||||
--color-green-75: 230 246 235;
|
||||
--color-green-100: 214 241 223;
|
||||
--color-green-200: 196 232 209;
|
||||
--color-green-300: 173 221 192;
|
||||
--color-green-400: 142 206 170;
|
||||
--color-green-500: 91 185 139;
|
||||
--color-green-600: 48 164 108;
|
||||
--color-green-700: 43 154 102;
|
||||
--color-green-800: 33 131 88;
|
||||
--color-green-900: 25 59 45;
|
||||
|
||||
--color-mint-25: 249 254 253;
|
||||
--color-mint-50: 242 251 249;
|
||||
--color-mint-75: 221 249 242;
|
||||
--color-mint-100: 200 244 233;
|
||||
--color-mint-200: 179 236 222;
|
||||
--color-mint-300: 156 224 208;
|
||||
--color-mint-400: 126 207 189;
|
||||
--color-mint-500: 76 187 165;
|
||||
--color-mint-600: 134 234 212;
|
||||
--color-mint-700: 125 224 203;
|
||||
--color-mint-800: 2 120 100;
|
||||
--color-mint-900: 22 67 60;
|
||||
|
||||
--color-sky-25: 249 254 255;
|
||||
--color-sky-50: 241 250 253;
|
||||
--color-sky-75: 225 246 253;
|
||||
--color-sky-100: 209 240 250;
|
||||
--color-sky-200: 190 231 245;
|
||||
--color-sky-300: 169 218 237;
|
||||
--color-sky-400: 141 202 227;
|
||||
--color-sky-500: 96 179 215;
|
||||
--color-sky-600: 124 226 254;
|
||||
--color-sky-700: 116 218 248;
|
||||
--color-sky-800: 0 116 158;
|
||||
--color-sky-900: 29 62 86;
|
||||
|
||||
--color-indigo-25: 253 253 254;
|
||||
--color-indigo-50: 247 249 255;
|
||||
--color-indigo-75: 237 242 254;
|
||||
--color-indigo-100: 225 233 255;
|
||||
--color-indigo-200: 210 222 255;
|
||||
--color-indigo-300: 193 208 255;
|
||||
--color-indigo-400: 171 189 249;
|
||||
--color-indigo-500: 141 164 239;
|
||||
--color-indigo-600: 62 99 221;
|
||||
--color-indigo-700: 51 88 212;
|
||||
--color-indigo-800: 58 91 199;
|
||||
--color-indigo-900: 31 45 92;
|
||||
|
||||
--color-iris-25: 253 253 255;
|
||||
--color-iris-50: 248 248 255;
|
||||
--color-iris-75: 240 241 254;
|
||||
--color-iris-100: 230 231 255;
|
||||
--color-iris-200: 218 220 255;
|
||||
--color-iris-300: 203 205 255;
|
||||
--color-iris-400: 184 186 248;
|
||||
--color-iris-500: 155 158 240;
|
||||
--color-iris-600: 91 91 214;
|
||||
--color-iris-700: 81 81 205;
|
||||
--color-iris-800: 87 83 198;
|
||||
--color-iris-900: 39 41 98;
|
||||
|
||||
--color-violet-25: 253 252 254;
|
||||
--color-violet-50: 250 248 255;
|
||||
--color-violet-75: 244 240 254;
|
||||
--color-violet-100: 235 228 255;
|
||||
--color-violet-200: 225 217 255;
|
||||
--color-violet-300: 212 202 254;
|
||||
--color-violet-400: 194 181 245;
|
||||
--color-violet-500: 170 153 236;
|
||||
--color-violet-600: 110 86 207;
|
||||
--color-violet-700: 101 77 196;
|
||||
--color-violet-800: 101 80 185;
|
||||
--color-violet-900: 47 38 95;
|
||||
|
||||
--color-pink-25: 255 252 254;
|
||||
--color-pink-50: 254 247 251;
|
||||
--color-pink-75: 254 233 245;
|
||||
--color-pink-100: 251 220 239;
|
||||
--color-pink-200: 246 206 231;
|
||||
--color-pink-300: 239 191 221;
|
||||
--color-pink-400: 231 172 208;
|
||||
--color-pink-500: 221 147 194;
|
||||
--color-pink-600: 214 64 159;
|
||||
--color-pink-700: 207 56 151;
|
||||
--color-pink-800: 194 41 138;
|
||||
--color-pink-900: 101 18 73;
|
||||
|
||||
--color-orange-25: 254 252 251;
|
||||
--color-orange-50: 255 247 237;
|
||||
--color-orange-75: 255 239 214;
|
||||
--color-orange-100: 255 223 181;
|
||||
--color-orange-200: 255 209 154;
|
||||
--color-orange-300: 255 193 130;
|
||||
--color-orange-400: 245 174 115;
|
||||
--color-orange-500: 236 148 85;
|
||||
--color-orange-600: 247 107 21;
|
||||
--color-orange-700: 239 95 0;
|
||||
--color-orange-800: 204 78 0;
|
||||
--color-orange-900: 88 45 29;
|
||||
}
|
||||
// scss-lint:disable QualifyingElement
|
||||
body.dark {
|
||||
--color-amber-25: 31 19 0;
|
||||
--color-amber-50: 37 24 4;
|
||||
--color-amber-75: 48 32 11;
|
||||
--color-amber-100: 57 39 15;
|
||||
--color-amber-200: 67 46 18;
|
||||
--color-amber-25: 31 19 0;
|
||||
--color-amber-300: 83 57 22;
|
||||
--color-amber-400: 111 77 29;
|
||||
--color-amber-50: 37 24 4;
|
||||
--color-amber-500: 169 118 42;
|
||||
--color-amber-600: 169 118 42;
|
||||
--color-amber-700: 255 203 71;
|
||||
--color-amber-800: 255 204 77;
|
||||
--color-amber-900: 255 231 179;
|
||||
--color-amber-950: 255 231 179;
|
||||
|
||||
--color-ash-100: 46 48 53;
|
||||
--color-ash-200: 53 55 60;
|
||||
@@ -136,18 +241,18 @@
|
||||
--color-ash-800: 173 177 184;
|
||||
--color-ash-900: 237 238 240;
|
||||
|
||||
--color-primary-100: 24 43 109;
|
||||
--color-primary-200: 31 53 129;
|
||||
--color-primary-25: 11 16 31;
|
||||
--color-primary-300: 40 64 146;
|
||||
--color-primary-400: 48 76 168;
|
||||
--color-primary-50: 16 23 41;
|
||||
--color-primary-500: 57 89 196;
|
||||
--color-primary-600: 69 110 255;
|
||||
--color-primary-700: 58 96 240;
|
||||
--color-primary-75: 20 34 81;
|
||||
--color-primary-800: 140 177 255;
|
||||
--color-primary-900: 210 225 255;
|
||||
--color-primary-25: 10 17 28;
|
||||
--color-primary-50: 15 24 38;
|
||||
--color-primary-75: 15 39 72;
|
||||
--color-primary-100: 10 49 99;
|
||||
--color-primary-200: 18 61 117;
|
||||
--color-primary-300: 29 74 134;
|
||||
--color-primary-400: 40 89 156;
|
||||
--color-primary-500: 48 106 186;
|
||||
--color-primary-600: 39 129 246;
|
||||
--color-primary-700: 21 116 231;
|
||||
--color-primary-800: 126 182 255;
|
||||
--color-primary-900: 205 227 255;
|
||||
|
||||
--color-ruby-100: 78 19 37;
|
||||
--color-ruby-200: 94 26 46;
|
||||
@@ -174,5 +279,108 @@
|
||||
--color-teal-75: 13 45 42;
|
||||
--color-teal-800: 11 216 182;
|
||||
--color-teal-900: 173 240 221;
|
||||
|
||||
--color-green-25: 14 21 18;
|
||||
--color-green-50: 18 27 23;
|
||||
--color-green-75: 19 45 33;
|
||||
--color-green-100: 17 59 41;
|
||||
--color-green-200: 23 73 51;
|
||||
--color-green-300: 32 87 62;
|
||||
--color-green-400: 40 104 74;
|
||||
--color-green-500: 47 124 87;
|
||||
--color-green-600: 48 164 108;
|
||||
--color-green-700: 51 176 116;
|
||||
--color-green-800: 61 214 140;
|
||||
--color-green-900: 177 241 203;
|
||||
|
||||
--color-mint-25: 14 21 21;
|
||||
--color-mint-50: 15 27 27;
|
||||
--color-mint-75: 9 44 43;
|
||||
--color-mint-100: 0 58 56;
|
||||
--color-mint-200: 0 71 68;
|
||||
--color-mint-300: 16 86 80;
|
||||
--color-mint-400: 30 104 95;
|
||||
--color-mint-500: 39 127 112;
|
||||
--color-mint-600: 134 234 212;
|
||||
--color-mint-700: 168 245 229;
|
||||
--color-mint-800: 88 213 186;
|
||||
--color-mint-900: 196 245 225;
|
||||
|
||||
--color-sky-25: 14 21 21;
|
||||
--color-sky-50: 15 27 27;
|
||||
--color-sky-75: 9 44 43;
|
||||
--color-sky-100: 0 58 56;
|
||||
--color-sky-200: 0 71 68;
|
||||
--color-sky-300: 16 86 80;
|
||||
--color-sky-400: 30 104 95;
|
||||
--color-sky-500: 39 127 112;
|
||||
--color-sky-600: 134 234 212;
|
||||
--color-sky-700: 168 245 229;
|
||||
--color-sky-800: 88 213 186;
|
||||
--color-sky-900: 196 245 225;
|
||||
|
||||
--color-indigo-25: 17 19 31;
|
||||
--color-indigo-50: 20 23 38;
|
||||
--color-indigo-75: 24 36 73;
|
||||
--color-indigo-100: 29 46 98;
|
||||
--color-indigo-200: 37 57 116;
|
||||
--color-indigo-300: 48 67 132;
|
||||
--color-indigo-400: 58 79 151;
|
||||
--color-indigo-500: 67 93 177;
|
||||
--color-indigo-600: 62 99 221;
|
||||
--color-indigo-700: 84 114 228;
|
||||
--color-indigo-800: 158 177 255;
|
||||
--color-indigo-900: 214 225 255;
|
||||
|
||||
--color-iris-25: 19 19 30;
|
||||
--color-iris-50: 23 22 37;
|
||||
--color-iris-75: 32 34 72;
|
||||
--color-iris-100: 38 42 101;
|
||||
--color-iris-200: 48 51 116;
|
||||
--color-iris-300: 61 62 130;
|
||||
--color-iris-400: 74 74 149;
|
||||
--color-iris-500: 89 88 177;
|
||||
--color-iris-600: 91 91 214;
|
||||
--color-iris-700: 110 106 222;
|
||||
--color-iris-800: 177 169 255;
|
||||
--color-iris-900: 224 223 254;
|
||||
|
||||
--color-violet-25: 20 18 31;
|
||||
--color-violet-50: 27 21 37;
|
||||
--color-violet-75: 41 31 67;
|
||||
--color-violet-100: 51 37 91;
|
||||
--color-violet-200: 60 46 105;
|
||||
--color-violet-300: 71 56 118;
|
||||
--color-violet-400: 86 70 139;
|
||||
--color-violet-500: 105 88 173;
|
||||
--color-violet-600: 110 86 207;
|
||||
--color-violet-700: 125 102 217;
|
||||
--color-violet-800: 186 167 255;
|
||||
--color-violet-900: 226 221 254;
|
||||
|
||||
--color-pink-25: 25 17 23;
|
||||
--color-pink-50: 33 18 29;
|
||||
--color-pink-75: 55 23 47;
|
||||
--color-pink-100: 75 20 61;
|
||||
--color-pink-200: 89 28 71;
|
||||
--color-pink-300: 105 41 85;
|
||||
--color-pink-400: 131 56 105;
|
||||
--color-pink-500: 168 72 133;
|
||||
--color-pink-600: 214 64 159;
|
||||
--color-pink-700: 222 81 168;
|
||||
--color-pink-800: 255 141 204;
|
||||
--color-pink-900: 253 209 234;
|
||||
--color-orange-25: 23 18 14;
|
||||
--color-orange-50: 30 22 15;
|
||||
--color-orange-75: 51 30 11;
|
||||
--color-orange-100: 70 33 0;
|
||||
--color-orange-200: 86 40 0;
|
||||
--color-orange-300: 102 53 12;
|
||||
--color-orange-400: 126 69 29;
|
||||
--color-orange-500: 163 88 41;
|
||||
--color-orange-600: 247 107 21;
|
||||
--color-orange-700: 255 128 31;
|
||||
--color-orange-800: 255 160 87;
|
||||
--color-orange-900: 255 224 194;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ import ConversationBasicFilter from './widgets/conversation/ConversationBasicFil
|
||||
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
|
||||
import ConversationItem from './ConversationItem.vue';
|
||||
import timeMixin from '../mixins/time';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import conversationMixin from '../mixins/conversations';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import advancedFilterTypes from './widgets/conversation/advancedFilterItems';
|
||||
@@ -199,11 +199,6 @@ import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
|
||||
import countries from 'shared/constants/countries';
|
||||
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
|
||||
|
||||
import {
|
||||
hasPressedAltAndJKey,
|
||||
hasPressedAltAndKKey,
|
||||
} from 'shared/helpers/KeyboardHelpers';
|
||||
import { conversationListPageURL } from '../helper/URLHelper';
|
||||
import {
|
||||
isOnMentionsView,
|
||||
@@ -228,7 +223,7 @@ export default {
|
||||
mixins: [
|
||||
timeMixin,
|
||||
conversationMixin,
|
||||
eventListenerMixins,
|
||||
keyboardEventListenerMixins,
|
||||
alertMixin,
|
||||
filterMixin,
|
||||
uiSettingsMixin,
|
||||
@@ -691,30 +686,40 @@ export default {
|
||||
lastConversationIndex,
|
||||
};
|
||||
},
|
||||
handleKeyEvents(e) {
|
||||
if (hasPressedAltAndJKey(e)) {
|
||||
const { allConversations, activeConversationIndex } =
|
||||
this.getKeyboardListenerParams();
|
||||
if (activeConversationIndex === -1) {
|
||||
allConversations[0].click();
|
||||
}
|
||||
if (activeConversationIndex >= 1) {
|
||||
allConversations[activeConversationIndex - 1].click();
|
||||
}
|
||||
handlePreviousConversation() {
|
||||
const { allConversations, activeConversationIndex } =
|
||||
this.getKeyboardListenerParams();
|
||||
if (activeConversationIndex === -1) {
|
||||
allConversations[0].click();
|
||||
}
|
||||
if (hasPressedAltAndKKey(e)) {
|
||||
const {
|
||||
allConversations,
|
||||
activeConversationIndex,
|
||||
lastConversationIndex,
|
||||
} = this.getKeyboardListenerParams();
|
||||
if (activeConversationIndex === -1) {
|
||||
allConversations[lastConversationIndex].click();
|
||||
} else if (activeConversationIndex < lastConversationIndex) {
|
||||
allConversations[activeConversationIndex + 1].click();
|
||||
}
|
||||
if (activeConversationIndex >= 1) {
|
||||
allConversations[activeConversationIndex - 1].click();
|
||||
}
|
||||
},
|
||||
handleNextConversation() {
|
||||
const {
|
||||
allConversations,
|
||||
activeConversationIndex,
|
||||
lastConversationIndex,
|
||||
} = this.getKeyboardListenerParams();
|
||||
if (activeConversationIndex === -1) {
|
||||
allConversations[lastConversationIndex].click();
|
||||
} else if (activeConversationIndex < lastConversationIndex) {
|
||||
allConversations[activeConversationIndex + 1].click();
|
||||
}
|
||||
},
|
||||
getKeyboardEvents() {
|
||||
return {
|
||||
'Alt+KeyJ': {
|
||||
action: () => this.handlePreviousConversation(),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
'Alt+KeyK': {
|
||||
action: () => this.handleNextConversation(),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
resetAndFetchData() {
|
||||
this.appliedFilter = [];
|
||||
this.resetBulkActions();
|
||||
|
||||
@@ -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>
|
||||
@@ -91,12 +91,7 @@ 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 eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import {
|
||||
hasPressedAltAndEKey,
|
||||
hasPressedCommandPlusAltAndEKey,
|
||||
hasPressedAltAndMKey,
|
||||
} from 'shared/helpers/KeyboardHelpers';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
|
||||
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
|
||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
||||
@@ -114,7 +109,7 @@ export default {
|
||||
WootDropdownMenu,
|
||||
CustomSnoozeModal,
|
||||
},
|
||||
mixins: [clickaway, alertMixin, eventListenerMixins],
|
||||
mixins: [clickaway, alertMixin, keyboardEventListenerMixins],
|
||||
props: { conversationId: { type: [String, Number], required: true } },
|
||||
data() {
|
||||
return {
|
||||
@@ -159,37 +154,52 @@ export default {
|
||||
bus.$off(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
|
||||
},
|
||||
methods: {
|
||||
async handleKeyEvents(e) {
|
||||
getKeyboardEvents() {
|
||||
return {
|
||||
'Alt+KeyM': {
|
||||
action: () => this.$refs.arrowDownButton?.$el.click(),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
'Alt+KeyE': this.resolveOrToast,
|
||||
'$mod+Alt+KeyE': async event => {
|
||||
const { all, activeIndex, lastIndex } = this.getConversationParams();
|
||||
await this.resolveOrToast();
|
||||
|
||||
if (activeIndex < lastIndex) {
|
||||
all[activeIndex + 1].click();
|
||||
} else if (all.length > 1) {
|
||||
all[0].click();
|
||||
document.querySelector('.conversations-list').scrollTop = 0;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
},
|
||||
};
|
||||
},
|
||||
getConversationParams() {
|
||||
const allConversations = document.querySelectorAll(
|
||||
'.conversations-list .conversation'
|
||||
);
|
||||
if (hasPressedAltAndMKey(e)) {
|
||||
if (this.$refs.arrowDownButton) {
|
||||
this.$refs.arrowDownButton.$el.click();
|
||||
}
|
||||
}
|
||||
if (hasPressedAltAndEKey(e)) {
|
||||
const activeConversation = document.querySelector(
|
||||
'div.conversations-list div.conversation.active'
|
||||
);
|
||||
const activeConversationIndex = [...allConversations].indexOf(
|
||||
activeConversation
|
||||
);
|
||||
const lastConversationIndex = allConversations.length - 1;
|
||||
try {
|
||||
await this.toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
|
||||
} catch (error) {
|
||||
// error
|
||||
}
|
||||
if (hasPressedCommandPlusAltAndEKey(e)) {
|
||||
if (activeConversationIndex < lastConversationIndex) {
|
||||
allConversations[activeConversationIndex + 1].click();
|
||||
} else if (allConversations.length > 1) {
|
||||
allConversations[0].click();
|
||||
document.querySelector('.conversations-list').scrollTop = 0;
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
const activeConversation = document.querySelector(
|
||||
'div.conversations-list div.conversation.active'
|
||||
);
|
||||
const activeConversationIndex = [...allConversations].indexOf(
|
||||
activeConversation
|
||||
);
|
||||
const lastConversationIndex = allConversations.length - 1;
|
||||
|
||||
return {
|
||||
all: allConversations,
|
||||
activeIndex: activeConversationIndex,
|
||||
lastIndex: lastConversationIndex,
|
||||
};
|
||||
},
|
||||
async resolveOrToast() {
|
||||
try {
|
||||
await this.toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
|
||||
} catch (error) {
|
||||
// error
|
||||
}
|
||||
},
|
||||
onCmdSnoozeConversation(snoozeType) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<aside class="h-full flex">
|
||||
<aside class="flex h-full">
|
||||
<primary-sidebar
|
||||
:logo-source="globalConfig.logoThumbnail"
|
||||
:installation-name="globalConfig.installationName"
|
||||
@@ -36,15 +36,7 @@ import alertMixin from 'shared/mixins/alertMixin';
|
||||
|
||||
import PrimarySidebar from './sidebarComponents/Primary.vue';
|
||||
import SecondarySidebar from './sidebarComponents/Secondary.vue';
|
||||
import {
|
||||
hasPressedAltAndCKey,
|
||||
hasPressedAltAndRKey,
|
||||
hasPressedAltAndSKey,
|
||||
hasPressedAltAndVKey,
|
||||
hasPressedCommandAndForwardSlash,
|
||||
isEscape,
|
||||
} from 'shared/helpers/KeyboardHelpers';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import router from '../../routes';
|
||||
|
||||
export default {
|
||||
@@ -52,7 +44,7 @@ export default {
|
||||
PrimarySidebar,
|
||||
SecondarySidebar,
|
||||
},
|
||||
mixins: [adminMixin, alertMixin, eventListenerMixins],
|
||||
mixins: [adminMixin, alertMixin, keyboardEventListenerMixins],
|
||||
props: {
|
||||
showSecondarySidebar: {
|
||||
type: Boolean,
|
||||
@@ -173,30 +165,27 @@ export default {
|
||||
closeKeyShortcutModal() {
|
||||
this.$emit('close-key-shortcut-modal');
|
||||
},
|
||||
handleKeyEvents(e) {
|
||||
if (hasPressedCommandAndForwardSlash(e)) {
|
||||
this.toggleKeyShortcutModal();
|
||||
}
|
||||
if (isEscape(e)) {
|
||||
this.closeKeyShortcutModal();
|
||||
}
|
||||
|
||||
if (hasPressedAltAndCKey(e)) {
|
||||
if (!this.isCurrentRouteSameAsNavigation('home')) {
|
||||
router.push({ name: 'home' });
|
||||
}
|
||||
} else if (hasPressedAltAndVKey(e)) {
|
||||
if (!this.isCurrentRouteSameAsNavigation('contacts_dashboard')) {
|
||||
router.push({ name: 'contacts_dashboard' });
|
||||
}
|
||||
} else if (hasPressedAltAndRKey(e)) {
|
||||
if (!this.isCurrentRouteSameAsNavigation('settings_account_reports')) {
|
||||
router.push({ name: 'settings_account_reports' });
|
||||
}
|
||||
} else if (hasPressedAltAndSKey(e)) {
|
||||
if (!this.isCurrentRouteSameAsNavigation('agent_list')) {
|
||||
router.push({ name: 'agent_list' });
|
||||
}
|
||||
getKeyboardEvents() {
|
||||
return {
|
||||
'$mod+Slash': this.toggleKeyShortcutModal,
|
||||
'$mod+Escape': this.closeKeyShortcutModal,
|
||||
'Alt+KeyC': {
|
||||
action: () => this.navigateToRoute('home'),
|
||||
},
|
||||
'Alt+KeyV': {
|
||||
action: () => this.navigateToRoute('contacts_dashboard'),
|
||||
},
|
||||
'Alt+KeyR': {
|
||||
action: () => this.navigateToRoute('account_overview_reports'),
|
||||
},
|
||||
'Alt+KeyS': {
|
||||
action: () => this.navigateToRoute('agent_list'),
|
||||
},
|
||||
};
|
||||
},
|
||||
navigateToRoute(routeName) {
|
||||
if (!this.isCurrentRouteSameAsNavigation(routeName)) {
|
||||
router.push({ name: routeName });
|
||||
}
|
||||
},
|
||||
isCurrentRouteSameAsNavigation(routeName) {
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
<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, 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, set the start and end dates to the selected start and end dates
|
||||
// If selecting last 7 days, 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 isLast7days = newRange === LAST_7_DAYS;
|
||||
startCurrentDate.value = selectedStartDate.value;
|
||||
endCurrentDate.value =
|
||||
isLast7days && 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 }))
|
||||
)();
|
||||
};
|
||||
+309
@@ -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>
|
||||
@@ -40,8 +40,7 @@ import AIAssistanceModal from './AIAssistanceModal.vue';
|
||||
import adminMixin from 'dashboard/mixins/aiMixin';
|
||||
import aiMixin from 'dashboard/mixins/isAdmin';
|
||||
import { CMD_AI_ASSIST } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||
import AIAssistanceCTAButton from './AIAssistanceCTAButton.vue';
|
||||
|
||||
@@ -51,7 +50,7 @@ export default {
|
||||
AICTAModal,
|
||||
AIAssistanceCTAButton,
|
||||
},
|
||||
mixins: [aiMixin, eventListenerMixins, adminMixin, uiSettingsMixin],
|
||||
mixins: [aiMixin, keyboardEventListenerMixins, adminMixin, uiSettingsMixin],
|
||||
data: () => ({
|
||||
showAIAssistanceModal: false,
|
||||
showAICtaModal: false,
|
||||
@@ -87,14 +86,17 @@ export default {
|
||||
},
|
||||
|
||||
methods: {
|
||||
onKeyDownHandler(event) {
|
||||
const keyPattern = buildHotKeys(event);
|
||||
const shouldRevertTheContent =
|
||||
['meta+z', 'ctrl+z'].includes(keyPattern) && !!this.initialMessage;
|
||||
if (shouldRevertTheContent) {
|
||||
this.$emit('replace-text', this.initialMessage);
|
||||
this.initialMessage = '';
|
||||
}
|
||||
getKeyboardEvents() {
|
||||
return {
|
||||
'$mod+KeyZ': {
|
||||
action: () => {
|
||||
if (this.initialMessage) {
|
||||
this.$emit('replace-text', this.initialMessage);
|
||||
this.initialMessage = '';
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
hideAIAssistanceModal() {
|
||||
this.recordAnalytics('DISMISS_AI_SUGGESTION', {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<template>
|
||||
<div class="preview-item__wrap flex overflow-auto max-h-[12.5rem]">
|
||||
<div class="flex overflow-auto max-h-[12.5rem]">
|
||||
<div
|
||||
v-for="(attachment, index) in attachments"
|
||||
v-for="(attachment, index) in nonRecordedAudioAttachments"
|
||||
:key="attachment.id"
|
||||
class="preview-item flex items-center p-1 bg-slate-50 dark:bg-slate-800 gap-1 rounded-md w-[15rem] mb-1"
|
||||
>
|
||||
<div class="max-w-[4rem] flex-shrink-0 w-6 flex items-center">
|
||||
<img
|
||||
v-if="isTypeImage(attachment.resource)"
|
||||
class="image-thumb"
|
||||
class="object-cover w-6 h-6 rounded-sm"
|
||||
:src="attachment.thumb"
|
||||
/>
|
||||
<span v-else class="w-6 h-6 text-lg relative -top-px text-left">
|
||||
<span v-else class="relative w-6 h-6 text-lg text-left -top-px">
|
||||
📄
|
||||
</span>
|
||||
</div>
|
||||
@@ -23,73 +23,62 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-[30%] justify-center">
|
||||
<span
|
||||
class="item overflow-hidden text-xs text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
<span class="overflow-hidden text-xs text-ellipsis whitespace-nowrap">
|
||||
{{ formatFileSize(attachment.resource) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-center">
|
||||
<woot-button
|
||||
v-if="!isTypeAudio(attachment.resource)"
|
||||
class="remove--attachment clear secondary"
|
||||
class="!w-6 !h-6 text-sm rounded-md hover:bg-slate-50 dark:hover:bg-slate-800 clear secondary"
|
||||
icon="dismiss"
|
||||
@click="() => onRemoveAttachment(index)"
|
||||
@click="onRemoveAttachment(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { formatBytes } from 'shared/helpers/FileHelper';
|
||||
export default {
|
||||
props: {
|
||||
attachments: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
removeAttachment: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onRemoveAttachment(index) {
|
||||
this.removeAttachment(index);
|
||||
},
|
||||
formatFileSize(file) {
|
||||
const size = file.byte_size || file.size;
|
||||
return formatBytes(size, 0);
|
||||
},
|
||||
isTypeImage(file) {
|
||||
const type = file.content_type || file.type;
|
||||
return type.includes('image');
|
||||
},
|
||||
isTypeAudio(file) {
|
||||
const type = file.content_type || file.type;
|
||||
return type.includes('audio');
|
||||
},
|
||||
fileName(file) {
|
||||
return file.filename || file.name;
|
||||
},
|
||||
|
||||
const props = defineProps({
|
||||
attachments: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['remove-attachment']);
|
||||
|
||||
const nonRecordedAudioAttachments = computed(() => {
|
||||
return props.attachments.filter(attachment => !attachment?.isRecordedAudio);
|
||||
});
|
||||
|
||||
const recordedAudioAttachments = computed(() =>
|
||||
props.attachments.filter(attachment => attachment.isRecordedAudio)
|
||||
);
|
||||
|
||||
const onRemoveAttachment = itemIndex => {
|
||||
emits(
|
||||
'remove-attachment',
|
||||
nonRecordedAudioAttachments.value
|
||||
.filter((_, index) => index !== itemIndex)
|
||||
.concat(recordedAudioAttachments.value)
|
||||
);
|
||||
};
|
||||
|
||||
const formatFileSize = file => {
|
||||
const size = file.byte_size || file.size;
|
||||
return formatBytes(size, 0);
|
||||
};
|
||||
|
||||
const isTypeImage = file => {
|
||||
const type = file.content_type || file.type;
|
||||
return type.includes('image');
|
||||
};
|
||||
|
||||
const fileName = file => {
|
||||
return file.filename || file.name;
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.image-thumb {
|
||||
@apply w-6 h-6 object-cover rounded-sm;
|
||||
}
|
||||
|
||||
.file-name-wrap,
|
||||
.file-size-wrap {
|
||||
@apply flex items-center py-0 px-1;
|
||||
|
||||
> .item {
|
||||
@apply m-0 overflow-hidden text-xs font-medium;
|
||||
}
|
||||
}
|
||||
|
||||
.remove--attachment {
|
||||
@apply w-6 h-6 rounded-md text-sm cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,11 +10,10 @@
|
||||
</template>
|
||||
<script>
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import { hasPressedAltAndNKey } from 'shared/helpers/KeyboardHelpers';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
|
||||
export default {
|
||||
mixins: [eventListenerMixins],
|
||||
mixins: [keyboardEventListenerMixins],
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
@@ -31,14 +30,18 @@ export default {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleKeyEvents(e) {
|
||||
if (hasPressedAltAndNKey(e)) {
|
||||
if (this.activeTab === wootConstants.ASSIGNEE_TYPE.ALL) {
|
||||
this.onTabChange(0);
|
||||
} else {
|
||||
this.onTabChange(this.activeTabIndex + 1);
|
||||
}
|
||||
}
|
||||
getKeyboardEvents() {
|
||||
return {
|
||||
'Alt+KeyN': {
|
||||
action: () => {
|
||||
if (this.activeTab === wootConstants.ASSIGNEE_TYPE.ALL) {
|
||||
this.onTabChange(0);
|
||||
} else {
|
||||
this.onTabChange(this.activeTabIndex + 1);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
onTabChange(selectedTabIndex) {
|
||||
if (this.items[selectedTabIndex].key !== this.activeTab) {
|
||||
|
||||
@@ -31,15 +31,10 @@
|
||||
|
||||
<script>
|
||||
import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
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';
|
||||
import {
|
||||
buildHotKeys,
|
||||
isEscape,
|
||||
isActiveElementTypeable,
|
||||
} from 'shared/helpers/KeyboardHelpers';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -47,7 +42,7 @@ export default {
|
||||
LabelDropdown,
|
||||
},
|
||||
|
||||
mixins: [clickaway, adminMixin, eventListenerMixins],
|
||||
mixins: [clickaway, adminMixin, keyboardEventListenerMixins],
|
||||
|
||||
props: {
|
||||
allLabels: {
|
||||
@@ -88,17 +83,19 @@ export default {
|
||||
closeDropdownLabel() {
|
||||
this.showSearchDropdownLabel = false;
|
||||
},
|
||||
|
||||
handleKeyEvents(e) {
|
||||
const keyPattern = buildHotKeys(e);
|
||||
|
||||
if (keyPattern === 'l' && !isActiveElementTypeable(e)) {
|
||||
this.toggleLabels();
|
||||
e.preventDefault();
|
||||
} else if (isEscape(e) && this.showSearchDropdownLabel) {
|
||||
this.closeDropdownLabel();
|
||||
e.preventDefault();
|
||||
}
|
||||
getKeyboardEvents() {
|
||||
return {
|
||||
KeyL: {
|
||||
action: e => {
|
||||
this.toggleLabels();
|
||||
e.preventDefault();
|
||||
},
|
||||
},
|
||||
Escape: {
|
||||
action: () => this.closeDropdownLabel(),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -231,7 +231,18 @@ export default {
|
||||
@apply bg-transparent max-h-60 min-h-[3rem] pt-4 px-0 pb-0 resize-none;
|
||||
}
|
||||
}
|
||||
.video-js .vjs-control-bar {
|
||||
background-color: transparent;
|
||||
// Added to override the default text and bg style to support dark and light mode.
|
||||
.video-js .vjs-control-bar,
|
||||
.vjs-record.video-js .vjs-control.vjs-record-indicator:before {
|
||||
@apply text-slate-600 dark:text-slate-200 bg-transparent dark:bg-transparent;
|
||||
}
|
||||
// Added to fix div overlays the screen and takes over the button clicks
|
||||
// https://github.com/collab-project/videojs-record/issues/688
|
||||
// https://github.com/collab-project/videojs-record/pull/709
|
||||
.vjs-record.video-js .vjs-control.vjs-record-indicator.vjs-hidden,
|
||||
.vjs-record.video-js .vjs-control.vjs-record-indicator,
|
||||
.vjs-record.video-js .vjs-control.vjs-record-indicator:before,
|
||||
.vjs-record.video-js .vjs-control.vjs-record-indicator:after {
|
||||
@apply pointer-events-none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -78,10 +78,8 @@ const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
||||
import {
|
||||
hasPressedEnterAndNotCmdOrShift,
|
||||
hasPressedCommandAndEnter,
|
||||
hasPressedAltAndPKey,
|
||||
hasPressedAltAndLKey,
|
||||
} from 'shared/helpers/KeyboardHelpers';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
|
||||
import {
|
||||
@@ -121,7 +119,7 @@ const createState = (
|
||||
export default {
|
||||
name: 'WootMessageEditor',
|
||||
components: { TagAgents, CannedResponse, VariableList },
|
||||
mixins: [eventListenerMixins, uiSettingsMixin, alertMixin],
|
||||
mixins: [keyboardEventListenerMixins, uiSettingsMixin, alertMixin],
|
||||
props: {
|
||||
value: { type: String, default: '' },
|
||||
editorId: { type: String, default: '' },
|
||||
@@ -522,13 +520,21 @@ export default {
|
||||
isCmdPlusEnterToSendEnabled() {
|
||||
return isEditorHotKeyEnabled(this.uiSettings, 'cmd_enter');
|
||||
},
|
||||
handleKeyEvents(e) {
|
||||
if (hasPressedAltAndPKey(e)) {
|
||||
this.focusEditorInputField();
|
||||
}
|
||||
if (hasPressedAltAndLKey(e)) {
|
||||
this.focusEditorInputField();
|
||||
}
|
||||
getKeyboardEvents() {
|
||||
return {
|
||||
'Alt+KeyP': {
|
||||
action: () => {
|
||||
this.focusEditorInputField();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
'Alt+KeyL': {
|
||||
action: () => {
|
||||
this.focusEditorInputField();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
focusEditorInputField(pos = 'end') {
|
||||
const { tr } = this.editorView.state;
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||
|
||||
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
||||
@@ -51,7 +51,7 @@ const createState = (
|
||||
};
|
||||
|
||||
export default {
|
||||
mixins: [eventListenerMixins, uiSettingsMixin, alertMixin],
|
||||
mixins: [keyboardEventListenerMixins, uiSettingsMixin, alertMixin],
|
||||
props: {
|
||||
value: { type: String, default: '' },
|
||||
editorId: { type: String, default: '' },
|
||||
|
||||
@@ -136,8 +136,7 @@
|
||||
<script>
|
||||
import FileUpload from 'vue-upload-component';
|
||||
import * as ActiveStorage from 'activestorage';
|
||||
import { hasPressedAltAndAKey } from 'shared/helpers/KeyboardHelpers';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
@@ -154,7 +153,7 @@ import { mapGetters } from 'vuex';
|
||||
export default {
|
||||
name: 'ReplyBottomPanel',
|
||||
components: { FileUpload, VideoCallButton, AIAssistanceButton },
|
||||
mixins: [eventListenerMixins, uiSettingsMixin, inboxMixin],
|
||||
mixins: [keyboardEventListenerMixins, uiSettingsMixin, inboxMixin],
|
||||
props: {
|
||||
mode: {
|
||||
type: String,
|
||||
@@ -340,10 +339,15 @@ export default {
|
||||
ActiveStorage.start();
|
||||
},
|
||||
methods: {
|
||||
handleKeyEvents(e) {
|
||||
if (hasPressedAltAndAKey(e)) {
|
||||
this.$refs.upload.$children[1].$el.click();
|
||||
}
|
||||
getKeyboardEvents() {
|
||||
return {
|
||||
'Alt+KeyA': {
|
||||
action: () => {
|
||||
this.$refs.upload.$children[1].$el.click();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
toggleMessageSignature() {
|
||||
this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="bg-black-50 flex justify-between dark:bg-slate-800">
|
||||
<div class="flex justify-between bg-black-50 dark:bg-slate-800">
|
||||
<div class="button-group">
|
||||
<woot-button
|
||||
variant="clear"
|
||||
@@ -20,7 +20,7 @@
|
||||
{{ $t('CONVERSATION.REPLYBOX.PRIVATE_NOTE') }}
|
||||
</woot-button>
|
||||
</div>
|
||||
<div class="flex items-center my-0 mx-4">
|
||||
<div class="flex items-center mx-4 my-0">
|
||||
<div v-if="isMessageLengthReachingThreshold" class="text-xs">
|
||||
<span :class="charLengthClass">
|
||||
{{ characterLengthWarning }}
|
||||
@@ -48,14 +48,10 @@
|
||||
|
||||
<script>
|
||||
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
|
||||
import {
|
||||
hasPressedAltAndPKey,
|
||||
hasPressedAltAndLKey,
|
||||
} from 'shared/helpers/KeyboardHelpers';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
export default {
|
||||
name: 'ReplyTopPanel',
|
||||
mixins: [eventListenerMixins],
|
||||
mixins: [keyboardEventListenerMixins],
|
||||
props: {
|
||||
mode: {
|
||||
type: String,
|
||||
@@ -99,13 +95,17 @@ export default {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleKeyEvents(e) {
|
||||
if (hasPressedAltAndPKey(e)) {
|
||||
this.handleNoteClick();
|
||||
}
|
||||
if (hasPressedAltAndLKey(e)) {
|
||||
this.handleReplyClick();
|
||||
}
|
||||
getKeyboardEvents() {
|
||||
return {
|
||||
'Alt+KeyP': {
|
||||
action: () => this.handleNoteClick(),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
'Alt+KeyL': {
|
||||
action: () => this.handleReplyClick(),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
handleReplyClick() {
|
||||
this.setReplyMode(REPLY_EDITOR_MODES.REPLY);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div
|
||||
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-4 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-slate-25 dark:hover:bg-slate-800 group"
|
||||
:class="{
|
||||
'animate-card-select bg-slate-25 dark:bg-slate-800 border-woot-500':
|
||||
'active animate-card-select bg-slate-25 dark:bg-slate-800 border-woot-500':
|
||||
isActiveChat,
|
||||
'unread-chat': hasUnread,
|
||||
'has-inbox-name': showInboxName,
|
||||
|
||||
@@ -77,11 +77,10 @@
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { hasPressedAltAndOKey } from 'shared/helpers/KeyboardHelpers';
|
||||
import { mapGetters } from 'vuex';
|
||||
import agentMixin from '../../../mixins/agentMixin.js';
|
||||
import BackButton from '../BackButton.vue';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import InboxName from '../InboxName.vue';
|
||||
import MoreActions from './MoreActions.vue';
|
||||
@@ -99,7 +98,7 @@ export default {
|
||||
Thumbnail,
|
||||
SLACardLabel,
|
||||
},
|
||||
mixins: [inboxMixin, agentMixin, eventListenerMixins],
|
||||
mixins: [inboxMixin, agentMixin, keyboardEventListenerMixins],
|
||||
props: {
|
||||
chat: {
|
||||
type: Object,
|
||||
@@ -182,10 +181,12 @@ export default {
|
||||
},
|
||||
|
||||
methods: {
|
||||
handleKeyEvents(e) {
|
||||
if (hasPressedAltAndOKey(e)) {
|
||||
this.$emit('contact-panel-toggle');
|
||||
}
|
||||
getKeyboardEvents() {
|
||||
return {
|
||||
'Alt+KeyO': {
|
||||
action: () => this.$emit('contact-panel-toggle'),
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -116,13 +116,12 @@ import conversationMixin, {
|
||||
} from '../../../mixins/conversations';
|
||||
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
|
||||
import configMixin from 'shared/mixins/configMixin';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import aiMixin from 'dashboard/mixins/aiMixin';
|
||||
|
||||
// utils
|
||||
import { getTypingUsersText } from '../../../helper/commons';
|
||||
import { calculateScrollTop } from './helpers/scrollTopCalculationHelper';
|
||||
import { isEscape } from 'shared/helpers/KeyboardHelpers';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
|
||||
// constants
|
||||
@@ -141,7 +140,7 @@ export default {
|
||||
mixins: [
|
||||
conversationMixin,
|
||||
inboxMixin,
|
||||
eventListenerMixins,
|
||||
keyboardEventListenerMixins,
|
||||
configMixin,
|
||||
aiMixin,
|
||||
],
|
||||
@@ -418,10 +417,12 @@ export default {
|
||||
closePopoutReplyBox() {
|
||||
this.isPopoutReplyBox = false;
|
||||
},
|
||||
handleKeyEvents(e) {
|
||||
if (isEscape(e)) {
|
||||
this.closePopoutReplyBox();
|
||||
}
|
||||
getKeyboardEvents() {
|
||||
return {
|
||||
Escape: {
|
||||
action: () => this.closePopoutReplyBox(),
|
||||
},
|
||||
};
|
||||
},
|
||||
addScrollListener() {
|
||||
this.conversationPanel = this.$el.querySelector('.conversation-panel');
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
<attachment-preview
|
||||
class="flex-col mt-4"
|
||||
:attachments="attachedFiles"
|
||||
:remove-attachment="removeAttachment"
|
||||
@remove-attachment="removeAttachment"
|
||||
/>
|
||||
</div>
|
||||
<message-signature-missing-alert
|
||||
@@ -155,6 +155,7 @@
|
||||
import { mapGetters } from 'vuex';
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
|
||||
import CannedResponse from './CannedResponse.vue';
|
||||
import ReplyToMessage from './ReplyToMessage.vue';
|
||||
@@ -178,7 +179,6 @@ import {
|
||||
replaceVariablesInMessage,
|
||||
} from '@chatwoot/utils';
|
||||
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
|
||||
import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
|
||||
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
|
||||
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
|
||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||
@@ -225,6 +225,7 @@ export default {
|
||||
messageFormatterMixin,
|
||||
rtlMixin,
|
||||
fileUploadMixin,
|
||||
keyboardEventListenerMixins,
|
||||
],
|
||||
props: {
|
||||
popoutReplyBox: {
|
||||
@@ -701,24 +702,41 @@ export default {
|
||||
this.$store.dispatch('draftMessages/delete', { key });
|
||||
}
|
||||
},
|
||||
handleKeyEvents(e) {
|
||||
const keyCode = buildHotKeys(e);
|
||||
if (keyCode === 'escape') {
|
||||
this.hideEmojiPicker();
|
||||
this.hideMentions();
|
||||
} else if (keyCode === 'meta+k') {
|
||||
const ninja = document.querySelector('ninja-keys');
|
||||
ninja.open();
|
||||
e.preventDefault();
|
||||
} else if (keyCode === 'enter' && this.isAValidEvent('enter')) {
|
||||
this.onSendReply();
|
||||
e.preventDefault();
|
||||
} else if (
|
||||
['meta+enter', 'ctrl+enter'].includes(keyCode) &&
|
||||
this.isAValidEvent('cmd_enter')
|
||||
) {
|
||||
this.onSendReply();
|
||||
}
|
||||
getKeyboardEvents() {
|
||||
return {
|
||||
Escape: {
|
||||
action: () => {
|
||||
this.hideEmojiPicker();
|
||||
this.hideMentions();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
'$mod+KeyK': {
|
||||
action: e => {
|
||||
e.preventDefault();
|
||||
const ninja = document.querySelector('ninja-keys');
|
||||
ninja.open();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
Enter: {
|
||||
action: e => {
|
||||
if (this.isAValidEvent('enter')) {
|
||||
this.onSendReply();
|
||||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
'$mod+Enter': {
|
||||
action: () => {
|
||||
if (this.isAValidEvent('cmd_enter')) {
|
||||
this.onSendReply();
|
||||
}
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
isAValidEvent(selectedKey) {
|
||||
return (
|
||||
@@ -937,6 +955,13 @@ export default {
|
||||
this.bccEmails = '';
|
||||
this.toEmails = '';
|
||||
},
|
||||
clearRecorder() {
|
||||
this.isRecordingAudio = false;
|
||||
// Only clear the recorded audio when we click toggle button.
|
||||
this.attachedFiles = this.attachedFiles.filter(
|
||||
file => !file?.isRecordedAudio
|
||||
);
|
||||
},
|
||||
toggleEmojiPicker() {
|
||||
this.showEmojiPicker = !this.showEmojiPicker;
|
||||
},
|
||||
@@ -944,8 +969,7 @@ export default {
|
||||
this.isRecordingAudio = !this.isRecordingAudio;
|
||||
this.isRecorderAudioStopped = !this.isRecordingAudio;
|
||||
if (!this.isRecordingAudio) {
|
||||
this.clearMessage();
|
||||
this.clearEmailField();
|
||||
this.clearRecorder();
|
||||
}
|
||||
},
|
||||
toggleAudioRecorderPlayPause() {
|
||||
@@ -989,7 +1013,13 @@ export default {
|
||||
}
|
||||
},
|
||||
onFinishRecorder(file) {
|
||||
return file && this.onFileUpload(file);
|
||||
// Added a new key isRecordedAudio to the file to find it's and recorded audio
|
||||
// Because to filter and show only non recorded audio and other attachments
|
||||
const autoRecordedFile = {
|
||||
...file,
|
||||
isRecordedAudio: true,
|
||||
};
|
||||
return file && this.onFileUpload(autoRecordedFile);
|
||||
},
|
||||
toggleTyping(status) {
|
||||
const conversationId = this.currentChat.id;
|
||||
@@ -1015,13 +1045,12 @@ export default {
|
||||
isPrivate: this.isPrivate,
|
||||
thumb: reader.result,
|
||||
blobSignedId: blob ? blob.signed_id : undefined,
|
||||
isRecordedAudio: file?.isRecordedAudio || false,
|
||||
});
|
||||
};
|
||||
},
|
||||
removeAttachment(itemIndex) {
|
||||
this.attachedFiles = this.attachedFiles.filter(
|
||||
(item, index) => itemIndex !== index
|
||||
);
|
||||
removeAttachment(attachments) {
|
||||
this.attachedFiles = attachments;
|
||||
},
|
||||
setReplyToInPayload(payload) {
|
||||
if (this.inReplyTo?.id) {
|
||||
|
||||
@@ -75,9 +75,10 @@ export default {
|
||||
},
|
||||
|
||||
methods: {
|
||||
handleKeyboardEvent(e) {
|
||||
this.processKeyDownEvent(e);
|
||||
this.$el.scrollTop = 50 * this.selectedIndex;
|
||||
adjustScroll() {
|
||||
this.$nextTick(() => {
|
||||
this.$el.scrollTop = 50 * this.selectedIndex;
|
||||
});
|
||||
},
|
||||
onHover(index) {
|
||||
this.selectedIndex = index;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
@error="onImgError"
|
||||
@click="onClick"
|
||||
/>
|
||||
<audio v-else-if="isAudio" controls class="skip-context-menu">
|
||||
<audio v-else-if="isAudio" controls class="skip-context-menu mb-0.5">
|
||||
<source :src="`${dataUrl}?t=${Date.now()}`" />
|
||||
</audio>
|
||||
<gallery-view
|
||||
|
||||
+26
-21
@@ -182,12 +182,7 @@
|
||||
<script>
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import { mapGetters } from 'vuex';
|
||||
import {
|
||||
isEscape,
|
||||
hasPressedArrowLeftKey,
|
||||
hasPressedArrowRightKey,
|
||||
} from 'shared/helpers/KeyboardHelpers';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import timeMixin from 'dashboard/mixins/time';
|
||||
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
@@ -205,7 +200,7 @@ export default {
|
||||
components: {
|
||||
Thumbnail,
|
||||
},
|
||||
mixins: [eventListenerMixins, clickaway, timeMixin],
|
||||
mixins: [keyboardEventListenerMixins, clickaway, timeMixin],
|
||||
props: {
|
||||
show: {
|
||||
type: Boolean,
|
||||
@@ -304,20 +299,30 @@ export default {
|
||||
this.activeAttachment = attachment;
|
||||
this.activeFileType = type;
|
||||
},
|
||||
onKeyDownHandler(e) {
|
||||
if (isEscape(e)) {
|
||||
this.onClose();
|
||||
} else if (hasPressedArrowLeftKey(e)) {
|
||||
this.onClickChangeAttachment(
|
||||
this.allAttachments[this.activeImageIndex - 1],
|
||||
this.activeImageIndex - 1
|
||||
);
|
||||
} else if (hasPressedArrowRightKey(e)) {
|
||||
this.onClickChangeAttachment(
|
||||
this.allAttachments[this.activeImageIndex + 1],
|
||||
this.activeImageIndex + 1
|
||||
);
|
||||
}
|
||||
getKeyboardEvents() {
|
||||
return {
|
||||
Escape: {
|
||||
action: () => {
|
||||
this.onClose();
|
||||
},
|
||||
},
|
||||
ArrowLeft: {
|
||||
action: () => {
|
||||
this.onClickChangeAttachment(
|
||||
this.allAttachments[this.activeImageIndex - 1],
|
||||
this.activeImageIndex - 1
|
||||
);
|
||||
},
|
||||
},
|
||||
ArrowRight: {
|
||||
action: () => {
|
||||
this.onClickChangeAttachment(
|
||||
this.allAttachments[this.activeImageIndex + 1],
|
||||
this.activeImageIndex + 1
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
onClickDownload() {
|
||||
const { file_type: type, data_url: url } = this.activeAttachment;
|
||||
|
||||
@@ -74,15 +74,10 @@
|
||||
<script>
|
||||
import countries from 'shared/constants/countries.js';
|
||||
import parsePhoneNumber from 'libphonenumber-js';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import {
|
||||
hasPressedArrowUpKey,
|
||||
hasPressedArrowDownKey,
|
||||
isEnter,
|
||||
} from 'shared/helpers/KeyboardHelpers';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
|
||||
export default {
|
||||
mixins: [eventListenerMixins],
|
||||
mixins: [keyboardEventListenerMixins],
|
||||
props: {
|
||||
value: {
|
||||
type: [String, Number],
|
||||
@@ -183,6 +178,7 @@ export default {
|
||||
this.$emit('blur', e.target.value);
|
||||
},
|
||||
dropdownItem() {
|
||||
if (!this.showDropdown) return [];
|
||||
return Array.from(
|
||||
this.$refs.dropdown.querySelectorAll(
|
||||
'div.country-dropdown div.country-dropdown--item'
|
||||
@@ -190,34 +186,27 @@ export default {
|
||||
);
|
||||
},
|
||||
focusedItem() {
|
||||
if (!this.showDropdown) return [];
|
||||
return Array.from(
|
||||
this.$refs.dropdown.querySelectorAll('div.country-dropdown div.focus')
|
||||
);
|
||||
},
|
||||
focusedItemIndex() {
|
||||
if (!this.showDropdown) return -1;
|
||||
return Array.from(this.dropdownItem()).indexOf(this.focusedItem()[0]);
|
||||
},
|
||||
onKeyDownHandler(e) {
|
||||
const { showDropdown, filteredCountriesBySearch, onSelectCountry } = this;
|
||||
const { selectedIndex } = this;
|
||||
|
||||
if (showDropdown) {
|
||||
if (hasPressedArrowDownKey(e)) {
|
||||
e.preventDefault();
|
||||
this.selectedIndex = Math.min(
|
||||
selectedIndex + 1,
|
||||
filteredCountriesBySearch.length - 1
|
||||
);
|
||||
this.$refs.dropdown.scrollTop = this.focusedItemIndex() * 28;
|
||||
} else if (hasPressedArrowUpKey(e)) {
|
||||
e.preventDefault();
|
||||
this.selectedIndex = Math.max(selectedIndex - 1, 0);
|
||||
this.$refs.dropdown.scrollTop = this.focusedItemIndex() * 28 - 56;
|
||||
} else if (isEnter(e)) {
|
||||
e.preventDefault();
|
||||
onSelectCountry(filteredCountriesBySearch[selectedIndex]);
|
||||
}
|
||||
}
|
||||
moveUp() {
|
||||
if (!this.showDropdown) return;
|
||||
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
|
||||
this.$refs.dropdown.scrollTop = this.focusedItemIndex() * 28 - 56;
|
||||
},
|
||||
moveDown() {
|
||||
if (!this.showDropdown) return;
|
||||
this.selectedIndex = Math.min(
|
||||
this.selectedIndex + 1,
|
||||
this.filteredCountriesBySearch.length - 1
|
||||
);
|
||||
this.$refs.dropdown.scrollTop = this.focusedItemIndex() * 28 - 56;
|
||||
},
|
||||
onSelectCountry(country) {
|
||||
this.activeCountryCode = country.id;
|
||||
@@ -235,6 +224,33 @@ export default {
|
||||
this.activeDialCode = number.countryCallingCode;
|
||||
}
|
||||
},
|
||||
getKeyboardEvents() {
|
||||
return {
|
||||
ArrowUp: {
|
||||
action: e => {
|
||||
e.preventDefault();
|
||||
this.moveUp();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
ArrowDown: {
|
||||
action: e => {
|
||||
e.preventDefault();
|
||||
this.moveDown();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
Enter: {
|
||||
action: e => {
|
||||
e.preventDefault();
|
||||
this.onSelectCountry(
|
||||
this.filteredCountriesBySearch[this.selectedIndex]
|
||||
);
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
toggleCountryDropdown() {
|
||||
this.showDropdown = !this.showDropdown;
|
||||
this.selectedIndex = -1;
|
||||
|
||||
@@ -84,9 +84,7 @@ export default {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleKeyboardEvent(e) {
|
||||
this.processKeyDownEvent(e);
|
||||
},
|
||||
adjustScroll() {},
|
||||
onHover(index) {
|
||||
this.selectedIndex = index;
|
||||
},
|
||||
|
||||
+42
-19
@@ -1,12 +1,7 @@
|
||||
import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
|
||||
export default {
|
||||
mounted() {
|
||||
document.addEventListener('keydown', this.handleKeyboardEvent);
|
||||
},
|
||||
beforeDestroy() {
|
||||
document.removeEventListener('keydown', this.handleKeyboardEvent);
|
||||
},
|
||||
mixins: [keyboardEventListenerMixins],
|
||||
methods: {
|
||||
moveSelectionUp() {
|
||||
if (!this.selectedIndex) {
|
||||
@@ -14,6 +9,7 @@ export default {
|
||||
} else {
|
||||
this.selectedIndex -= 1;
|
||||
}
|
||||
this.adjustScroll();
|
||||
},
|
||||
moveSelectionDown() {
|
||||
if (this.selectedIndex === this.items.length - 1) {
|
||||
@@ -21,19 +17,46 @@ export default {
|
||||
} else {
|
||||
this.selectedIndex += 1;
|
||||
}
|
||||
this.adjustScroll();
|
||||
},
|
||||
processKeyDownEvent(e) {
|
||||
const keyPattern = buildHotKeys(e);
|
||||
if (['arrowup', 'ctrl+p'].includes(keyPattern)) {
|
||||
this.moveSelectionUp();
|
||||
e.preventDefault();
|
||||
} else if (['arrowdown', 'ctrl+n'].includes(keyPattern)) {
|
||||
this.moveSelectionDown();
|
||||
e.preventDefault();
|
||||
} else if (keyPattern === 'enter') {
|
||||
this.onSelect();
|
||||
e.preventDefault();
|
||||
}
|
||||
getKeyboardEvents() {
|
||||
return {
|
||||
ArrowUp: {
|
||||
action: e => {
|
||||
this.moveSelectionUp();
|
||||
e.preventDefault();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
'Control+KeyP': {
|
||||
action: e => {
|
||||
this.moveSelectionUp();
|
||||
e.preventDefault();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
ArrowDown: {
|
||||
action: e => {
|
||||
this.moveSelectionDown();
|
||||
e.preventDefault();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
'Control+KeyN': {
|
||||
action: e => {
|
||||
this.moveSelectionDown();
|
||||
e.preventDefault();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
Enter: {
|
||||
action: e => {
|
||||
this.onSelect();
|
||||
e.preventDefault();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
+52
-47
@@ -1,64 +1,69 @@
|
||||
import mentionSelectionKeyboardMixin from '../mentionSelectionKeyboardMixin';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { shallowMount, createLocalVue } from '@vue/test-utils';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
|
||||
const buildComponent = ({ data = {}, methods = {} }) => ({
|
||||
render() {},
|
||||
data() {
|
||||
return data;
|
||||
return { ...data, selectedIndex: 0, items: [1, 2, 3] };
|
||||
},
|
||||
methods,
|
||||
mixins: [mentionSelectionKeyboardMixin],
|
||||
methods: { ...methods, onSelect: jest.fn(), adjustScroll: jest.fn() },
|
||||
mixins: [keyboardEventListenerMixins],
|
||||
});
|
||||
|
||||
describe('mentionSelectionKeyboardMixin', () => {
|
||||
test('register listeners', () => {
|
||||
jest.spyOn(document, 'addEventListener');
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
const Component = buildComponent({});
|
||||
shallowMount(Component);
|
||||
// undefined expected as the method is not defined in the component
|
||||
expect(document.addEventListener).toHaveBeenCalledWith(
|
||||
'keydown',
|
||||
undefined
|
||||
);
|
||||
wrapper = shallowMount(Component, { localVue });
|
||||
});
|
||||
|
||||
test('processKeyDownEvent updates index on arrow up', () => {
|
||||
const Component = buildComponent({
|
||||
data: { selectedIndex: 0, items: [1, 2, 3] },
|
||||
});
|
||||
const wrapper = shallowMount(Component);
|
||||
wrapper.vm.processKeyDownEvent({
|
||||
ctrlKey: true,
|
||||
key: 'p',
|
||||
preventDefault: jest.fn(),
|
||||
});
|
||||
expect(wrapper.vm.selectedIndex).toBe(2);
|
||||
it('ArrowUp and Control+KeyP update selectedIndex correctly', () => {
|
||||
const preventDefault = jest.fn();
|
||||
const keyboardEvents = wrapper.vm.getKeyboardEvents();
|
||||
|
||||
if (keyboardEvents && keyboardEvents.ArrowUp) {
|
||||
keyboardEvents.ArrowUp.action({ preventDefault });
|
||||
expect(wrapper.vm.selectedIndex).toBe(2);
|
||||
expect(preventDefault).toHaveBeenCalled();
|
||||
}
|
||||
|
||||
wrapper.setData({ selectedIndex: 1 });
|
||||
if (keyboardEvents && keyboardEvents['Control+KeyP']) {
|
||||
keyboardEvents['Control+KeyP'].action({ preventDefault });
|
||||
expect(wrapper.vm.selectedIndex).toBe(0);
|
||||
expect(preventDefault).toHaveBeenCalledTimes(2);
|
||||
}
|
||||
});
|
||||
|
||||
test('processKeyDownEvent updates index on arrow down', () => {
|
||||
const Component = buildComponent({
|
||||
data: { selectedIndex: 0, items: [1, 2, 3] },
|
||||
});
|
||||
const wrapper = shallowMount(Component);
|
||||
wrapper.vm.processKeyDownEvent({
|
||||
key: 'ArrowDown',
|
||||
preventDefault: jest.fn(),
|
||||
});
|
||||
expect(wrapper.vm.selectedIndex).toBe(1);
|
||||
it('ArrowDown and Control+KeyN update selectedIndex correctly', () => {
|
||||
const preventDefault = jest.fn();
|
||||
const keyboardEvents = wrapper.vm.getKeyboardEvents();
|
||||
|
||||
if (keyboardEvents && keyboardEvents.ArrowDown) {
|
||||
keyboardEvents.ArrowDown.action({ preventDefault });
|
||||
expect(wrapper.vm.selectedIndex).toBe(1);
|
||||
expect(preventDefault).toHaveBeenCalled();
|
||||
}
|
||||
|
||||
wrapper.setData({ selectedIndex: 1 });
|
||||
if (keyboardEvents && keyboardEvents['Control+KeyN']) {
|
||||
keyboardEvents['Control+KeyN'].action({ preventDefault });
|
||||
expect(wrapper.vm.selectedIndex).toBe(2);
|
||||
expect(preventDefault).toHaveBeenCalledTimes(2);
|
||||
}
|
||||
});
|
||||
|
||||
test('processKeyDownEvent calls select methods on Enter Key', () => {
|
||||
const onSelectMockFn = jest.fn();
|
||||
const Component = buildComponent({
|
||||
data: { selectedIndex: 0, items: [1, 2, 3] },
|
||||
methods: { onSelect: () => onSelectMockFn('enterKey pressed') },
|
||||
});
|
||||
const wrapper = shallowMount(Component);
|
||||
wrapper.vm.processKeyDownEvent({
|
||||
key: 'Enter',
|
||||
preventDefault: jest.fn(),
|
||||
});
|
||||
expect(onSelectMockFn).toHaveBeenCalledWith('enterKey pressed');
|
||||
wrapper.vm.onSelect();
|
||||
it('Enter key triggers onSelect method', () => {
|
||||
const preventDefault = jest.fn();
|
||||
const keyboardEvents = wrapper.vm.getKeyboardEvents();
|
||||
|
||||
if (keyboardEvents && keyboardEvents.Enter) {
|
||||
keyboardEvents.Enter.action({ preventDefault });
|
||||
expect(wrapper.vm.onSelect).toHaveBeenCalled();
|
||||
expect(preventDefault).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,12 +53,6 @@ export const SHORTCUT_KEYS = [
|
||||
firstKey: 'Alt / ⌥',
|
||||
secondKey: 'S',
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
label: 'SWITCH_CONVERSATION_STATUS',
|
||||
firstKey: 'Alt / ⌥',
|
||||
secondKey: 'B',
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
label: 'SWITCH_TO_PRIVATE_NOTE',
|
||||
|
||||
@@ -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,32 @@
|
||||
import { computed, getCurrentInstance } from 'vue';
|
||||
import Vue from 'vue';
|
||||
import VueI18n from 'vue-i18n';
|
||||
|
||||
let i18nInstance = VueI18n;
|
||||
|
||||
export function useI18n() {
|
||||
if (!i18nInstance) throw new Error('vue-i18n not initialized');
|
||||
|
||||
const i18n = i18nInstance;
|
||||
|
||||
const instance = getCurrentInstance();
|
||||
const vm = instance?.proxy || instance || new Vue({});
|
||||
|
||||
const locale = computed({
|
||||
get() {
|
||||
return i18n.locale;
|
||||
},
|
||||
set(v) {
|
||||
i18n.locale = v;
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
locale,
|
||||
t: vm.$t.bind(vm),
|
||||
tc: vm.$tc.bind(vm),
|
||||
d: vm.$d.bind(vm),
|
||||
te: vm.$te.bind(vm),
|
||||
n: vm.$n.bind(vm),
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import AnalyticsHelper from './AnalyticsHelper';
|
||||
import LogRocket from 'logrocket';
|
||||
import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper';
|
||||
|
||||
export const CHATWOOT_SET_USER = 'CHATWOOT_SET_USER';
|
||||
@@ -11,12 +10,6 @@ export const ANALYTICS_RESET = 'ANALYTICS_RESET';
|
||||
export const initializeAnalyticsEvents = () => {
|
||||
window.bus.$on(ANALYTICS_IDENTITY, ({ user }) => {
|
||||
AnalyticsHelper.identify(user);
|
||||
if (window.logRocketProjectId) {
|
||||
LogRocket.identify(user.id, {
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
@@ -316,7 +317,6 @@
|
||||
"GO_TO_REPORTS_SIDEBAR": "Go to Reports sidebar",
|
||||
"MOVE_TO_NEXT_TAB": "Move to next tab in conversation list",
|
||||
"GO_TO_SETTINGS": "Go to Settings",
|
||||
"SWITCH_CONVERSATION_STATUS": "Switch to the next conversation status",
|
||||
"SWITCH_TO_PRIVATE_NOTE": "Switch to Private Note",
|
||||
"SWITCH_TO_REPLY": "Switch to Reply",
|
||||
"TOGGLE_SNOOZE_DROPDOWN": "Toggle snooze dropdown"
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"TITLE": "Upgrade to create SLAs",
|
||||
"AVAILABLE_ON": "The SLA feature is only available in the Business and Enterprise plans.",
|
||||
"UPGRADE_PROMPT": "Upgrade your plan to get access to advanced features like team management, automations, custom attributes, and more.",
|
||||
"UPGRADE_NOW": "Upgrade now"
|
||||
"UPGRADE_NOW": "Upgrade now",
|
||||
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
|
||||
},
|
||||
"ENTERPRISE_PAYWALL": {
|
||||
"AVAILABLE_ON": "The SLA feature is only available in the paid plans.",
|
||||
@@ -104,4 +105,4 @@
|
||||
"HIDE": "Hide {count} rows"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,12 +23,12 @@
|
||||
|
||||
<script>
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||
import { hasPressedCommandAndEnter } from 'shared/helpers/KeyboardHelpers';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
export default {
|
||||
components: {
|
||||
WootMessageEditor,
|
||||
},
|
||||
|
||||
mixins: [keyboardEventListenerMixins],
|
||||
data() {
|
||||
return {
|
||||
noteContent: '',
|
||||
@@ -40,21 +40,14 @@ export default {
|
||||
return this.noteContent === '';
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
document.addEventListener('keydown', this.onMetaEnter);
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
document.removeEventListener('keydown', this.onMetaEnter);
|
||||
},
|
||||
|
||||
methods: {
|
||||
onMetaEnter(e) {
|
||||
if (hasPressedCommandAndEnter(e)) {
|
||||
e.preventDefault();
|
||||
this.onAdd();
|
||||
}
|
||||
getKeyboardEvents() {
|
||||
return {
|
||||
'$mod+Enter': {
|
||||
action: () => this.onAdd(),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
onAdd() {
|
||||
if (this.noteContent !== '') {
|
||||
|
||||
+1
@@ -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"
|
||||
|
||||
@@ -50,13 +50,8 @@ 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 eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import conversationLabelMixin from 'dashboard/mixins/conversation/labelMixin';
|
||||
import {
|
||||
buildHotKeys,
|
||||
isEscape,
|
||||
isActiveElementTypeable,
|
||||
} from 'shared/helpers/KeyboardHelpers';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -65,7 +60,12 @@ export default {
|
||||
AddLabel,
|
||||
},
|
||||
|
||||
mixins: [clickaway, conversationLabelMixin, adminMixin, eventListenerMixins],
|
||||
mixins: [
|
||||
clickaway,
|
||||
conversationLabelMixin,
|
||||
adminMixin,
|
||||
keyboardEventListenerMixins,
|
||||
],
|
||||
props: {
|
||||
conversationId: {
|
||||
type: Number,
|
||||
@@ -93,16 +93,23 @@ export default {
|
||||
closeDropdownLabel() {
|
||||
this.showSearchDropdownLabel = false;
|
||||
},
|
||||
handleKeyEvents(e) {
|
||||
const keyPattern = buildHotKeys(e);
|
||||
|
||||
if (keyPattern === 'l' && !isActiveElementTypeable(e)) {
|
||||
this.toggleLabels();
|
||||
e.preventDefault();
|
||||
} else if (isEscape(e) && this.showSearchDropdownLabel) {
|
||||
this.closeDropdownLabel();
|
||||
e.preventDefault();
|
||||
}
|
||||
getKeyboardEvents() {
|
||||
return {
|
||||
KeyL: {
|
||||
action: e => {
|
||||
e.preventDefault();
|
||||
this.toggleLabels();
|
||||
},
|
||||
},
|
||||
Escape: {
|
||||
action: () => {
|
||||
if (this.showSearchDropdownLabel) {
|
||||
this.toggleLabels();
|
||||
}
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
+11
-13
@@ -34,15 +34,11 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import {
|
||||
buildHotKeys,
|
||||
isActiveElementTypeable,
|
||||
} from 'shared/helpers/KeyboardHelpers';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
|
||||
export default {
|
||||
name: 'ChatwootSearch',
|
||||
mixins: [eventListenerMixins],
|
||||
mixins: [keyboardEventListenerMixins],
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
@@ -71,13 +67,15 @@ export default {
|
||||
onBlur() {
|
||||
this.isInputFocused = false;
|
||||
},
|
||||
handleKeyEvents(e) {
|
||||
const keyPattern = buildHotKeys(e);
|
||||
|
||||
if (keyPattern === '/' && !isActiveElementTypeable(e)) {
|
||||
e.preventDefault();
|
||||
this.$refs.searchInput.focus();
|
||||
}
|
||||
getKeyboardEvents() {
|
||||
return {
|
||||
Slash: {
|
||||
action: e => {
|
||||
e.preventDefault();
|
||||
this.$refs.searchInput.focus();
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
+11
-14
@@ -35,10 +35,7 @@
|
||||
<script>
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import {
|
||||
isEscape,
|
||||
isActiveElementTypeable,
|
||||
} from 'shared/helpers/KeyboardHelpers';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
|
||||
import SearchHeader from './Header.vue';
|
||||
import SearchResults from './SearchResults.vue';
|
||||
@@ -55,7 +52,7 @@ export default {
|
||||
SearchResults,
|
||||
ArticleView,
|
||||
},
|
||||
mixins: [clickaway, portalMixin, alertMixin],
|
||||
mixins: [clickaway, portalMixin, alertMixin, keyboardEventListenerMixins],
|
||||
props: {
|
||||
selectedPortalSlug: {
|
||||
type: String,
|
||||
@@ -97,10 +94,6 @@ export default {
|
||||
mounted() {
|
||||
this.fetchArticlesByQuery(this.searchQuery);
|
||||
this.debounceSearch = debounce(this.fetchArticlesByQuery, 500, false);
|
||||
document.body.addEventListener('keydown', this.closeOnEsc);
|
||||
},
|
||||
beforeDestroy() {
|
||||
document.body.removeEventListener('keydown', this.closeOnEsc);
|
||||
},
|
||||
methods: {
|
||||
generateArticleUrl(article) {
|
||||
@@ -158,11 +151,15 @@ export default {
|
||||
);
|
||||
this.onClose();
|
||||
},
|
||||
closeOnEsc(e) {
|
||||
if (isEscape(e) && !isActiveElementTypeable(e)) {
|
||||
e.preventDefault();
|
||||
this.onClose();
|
||||
}
|
||||
getKeyboardEvents() {
|
||||
return {
|
||||
Escape: {
|
||||
action: () => {
|
||||
this.onClose();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<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 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>
|
||||
</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 UserProfilePicture from './UserProfilePicture.vue';
|
||||
import UserBasicDetails from './UserBasicDetails.vue';
|
||||
import MessageSignature from './MessageSignature.vue';
|
||||
import FormSection from 'dashboard/components/FormSection.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
MessageSignature,
|
||||
FormSection,
|
||||
UserProfilePicture,
|
||||
UserBasicDetails,
|
||||
},
|
||||
mixins: [alertMixin, globalConfigMixin, uiSettingsMixin],
|
||||
data() {
|
||||
return {
|
||||
avatarFile: '',
|
||||
avatarUrl: '',
|
||||
name: '',
|
||||
displayName: '',
|
||||
email: '',
|
||||
messageSignature: '',
|
||||
};
|
||||
},
|
||||
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'));
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</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,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,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,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
+139
-274
@@ -1,308 +1,173 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import format from 'date-fns/format';
|
||||
import getDay from 'date-fns/getDay';
|
||||
|
||||
import { getQuantileIntervals } from '@chatwoot/utils';
|
||||
|
||||
import { groupHeatmapByDay } from 'helpers/ReportsDataHelper';
|
||||
import { useI18n } from 'dashboard/composables/useI18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
const props = defineProps({
|
||||
heatData: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const processedData = computed(() => {
|
||||
return groupHeatmapByDay(props.heatData);
|
||||
});
|
||||
|
||||
const quantileRange = computed(() => {
|
||||
const flattendedData = props.heatData.map(data => data.value);
|
||||
return getQuantileIntervals(flattendedData, [0.2, 0.4, 0.6, 0.8, 0.9, 0.99]);
|
||||
});
|
||||
|
||||
function getCountTooltip(value) {
|
||||
if (!value) {
|
||||
return t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.NO_CONVERSATIONS');
|
||||
}
|
||||
|
||||
if (value === 1) {
|
||||
return t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.CONVERSATION', {
|
||||
count: value,
|
||||
});
|
||||
}
|
||||
|
||||
return t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.CONVERSATIONS', {
|
||||
count: value,
|
||||
});
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
return format(new Date(dateString), 'MMM d, yyyy');
|
||||
}
|
||||
|
||||
function getDayOfTheWeek(date) {
|
||||
const dayIndex = getDay(date);
|
||||
const days = [
|
||||
t('DAYS_OF_WEEK.SUNDAY'),
|
||||
t('DAYS_OF_WEEK.MONDAY'),
|
||||
t('DAYS_OF_WEEK.TUESDAY'),
|
||||
t('DAYS_OF_WEEK.WEDNESDAY'),
|
||||
t('DAYS_OF_WEEK.THURSDAY'),
|
||||
t('DAYS_OF_WEEK.FRIDAY'),
|
||||
t('DAYS_OF_WEEK.SATURDAY'),
|
||||
];
|
||||
return days[dayIndex];
|
||||
}
|
||||
function getHeatmapLevelClass(value) {
|
||||
if (!value)
|
||||
return 'outline-slate-100 dark:outline-slate-700 dark:bg-slate-700/40 bg-slate-50/50';
|
||||
|
||||
let level = [...quantileRange.value, Infinity].findIndex(
|
||||
range => value <= range && value > 0
|
||||
);
|
||||
|
||||
if (level > 6) level = 5;
|
||||
|
||||
if (level === 0) {
|
||||
return 'outline-slate-100 dark:outline-slate-700 dark:bg-slate-700/40 bg-slate-50/50';
|
||||
}
|
||||
|
||||
const classes = [
|
||||
'bg-woot-50 dark:bg-woot-800/40 dark:outline-woot-800/80',
|
||||
'bg-woot-100 dark:bg-woot-800/30 dark:outline-woot-800/80',
|
||||
'bg-woot-200 dark:bg-woot-500/40 dark:outline-woot-700/80',
|
||||
'bg-woot-300 dark:bg-woot-500/60 dark:outline-woot-600/80',
|
||||
'bg-woot-600 dark:bg-woot-500/80 dark:outline-woot-500/80',
|
||||
'bg-woot-800 dark:bg-woot-500 dark:outline-woot-400/80',
|
||||
];
|
||||
|
||||
return classes[level - 1];
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="heatmap-container">
|
||||
<div
|
||||
class="grid relative w-full gap-x-4 gap-y-2.5 overflow-y-scroll md:overflow-visible grid-cols-[80px_1fr] min-h-72"
|
||||
>
|
||||
<template v-if="isLoading">
|
||||
<div class="heatmap-labels">
|
||||
<div class="grid gap-[5px] flex-shrink-0">
|
||||
<div
|
||||
v-for="ii in 7"
|
||||
:key="ii"
|
||||
class="loading-cell heatmap-axis-label"
|
||||
class="w-full rounded-sm bg-slate-100 dark:bg-slate-900 animate-loader-pulse h-8 min-w-[70px]"
|
||||
/>
|
||||
</div>
|
||||
<div class="heatmap-grid">
|
||||
<div v-for="ii in 7" :key="ii" class="heatmap-grid-row">
|
||||
<div v-for="jj in 24" :key="jj" class="heatmap-tile loading-cell">
|
||||
<div class="heatmap-tile__label loading-cell" />
|
||||
</div>
|
||||
<div class="grid gap-[5px] w-full min-w-[700px]">
|
||||
<div
|
||||
v-for="ii in 7"
|
||||
:key="ii"
|
||||
class="grid gap-[5px] grid-cols-[repeat(24,_1fr)]"
|
||||
>
|
||||
<div
|
||||
v-for="jj in 24"
|
||||
:key="jj"
|
||||
class="w-full h-8 rounded-sm bg-slate-100 dark:bg-slate-900 animate-loader-pulse"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="heatmap-timeline" />
|
||||
<div class="heatmap-markers">
|
||||
<div v-for="ii in 24" :key="ii">{{ ii - 1 }} – {{ ii }}</div>
|
||||
<div />
|
||||
<div
|
||||
class="grid grid-cols-[repeat(24,_1fr)] gap-[5px] w-full text-[8px] font-semibold h-5 text-slate-800 dark:text-slate-200"
|
||||
>
|
||||
<div
|
||||
v-for="ii in 24"
|
||||
:key="ii"
|
||||
class="flex items-center justify-center"
|
||||
>
|
||||
{{ ii - 1 }} – {{ ii }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="heatmap-labels">
|
||||
<div class="grid gap-[5px] flex-shrink-0">
|
||||
<div
|
||||
v-for="dateKey in processedData.keys()"
|
||||
:key="dateKey"
|
||||
class="heatmap-axis-label"
|
||||
class="h-8 min-w-[70px] text-slate-800 dark:text-slate-200 text-[10px] font-semibold flex flex-col items-end justify-center"
|
||||
>
|
||||
{{ getDayOfTheWeek(new Date(dateKey)) }}
|
||||
<time>{{ formatDate(dateKey) }}</time>
|
||||
<time class="font-normal text-slate-700 dark:text-slate-200">
|
||||
{{ formatDate(dateKey) }}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
<div class="heatmap-grid">
|
||||
<div class="grid gap-[5px] w-full min-w-[700px]">
|
||||
<div
|
||||
v-for="dateKey in processedData.keys()"
|
||||
:key="dateKey"
|
||||
class="heatmap-grid-row"
|
||||
class="grid gap-[5px] grid-cols-[repeat(24,_1fr)]"
|
||||
>
|
||||
<div
|
||||
v-for="data in processedData.get(dateKey)"
|
||||
:key="data.timestamp"
|
||||
v-tooltip.top="getCountTooltip(data.value)"
|
||||
class="heatmap-tile"
|
||||
class="h-8 rounded-sm shadow-inner dark:outline dark:outline-1 shadow-black"
|
||||
:class="getHeatmapLevelClass(data.value)"
|
||||
>
|
||||
<div class="heatmap-tile__label" />
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="heatmap-timeline" />
|
||||
<div class="heatmap-markers">
|
||||
<div v-for="ii in 24" :key="ii">{{ ii - 1 }} – {{ ii }}</div>
|
||||
<div />
|
||||
<div
|
||||
class="grid grid-cols-[repeat(24,_1fr)] gap-[5px] w-full text-[8px] font-semibold h-5 text-slate-800 dark:text-slate-200"
|
||||
>
|
||||
<div
|
||||
v-for="ii in 24"
|
||||
:key="ii"
|
||||
class="flex items-center justify-center"
|
||||
>
|
||||
{{ ii - 1 }} – {{ ii }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { getQuantileIntervals } from '@chatwoot/utils';
|
||||
import format from 'date-fns/format';
|
||||
import getDay from 'date-fns/getDay';
|
||||
|
||||
import { groupHeatmapByDay } from 'helpers/ReportsDataHelper';
|
||||
|
||||
export default {
|
||||
name: 'Heatmap',
|
||||
props: {
|
||||
heatData: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
processedData() {
|
||||
return groupHeatmapByDay(this.heatData);
|
||||
},
|
||||
quantileRange() {
|
||||
const flattendedData = this.heatData.map(data => data.value);
|
||||
return getQuantileIntervals(
|
||||
flattendedData,
|
||||
[0.2, 0.4, 0.6, 0.8, 0.9, 0.99]
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getCountTooltip(value) {
|
||||
if (!value) {
|
||||
return this.$t(
|
||||
'OVERVIEW_REPORTS.CONVERSATION_HEATMAP.NO_CONVERSATIONS'
|
||||
);
|
||||
}
|
||||
|
||||
if (value === 1) {
|
||||
return this.$t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.CONVERSATION', {
|
||||
count: value,
|
||||
});
|
||||
}
|
||||
|
||||
return this.$t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.CONVERSATIONS', {
|
||||
count: value,
|
||||
});
|
||||
},
|
||||
formatDate(dateString) {
|
||||
return format(new Date(dateString), 'MMM d, yyyy');
|
||||
},
|
||||
getDayOfTheWeek(date) {
|
||||
const dayIndex = getDay(date);
|
||||
const days = [
|
||||
this.$t('DAYS_OF_WEEK.SUNDAY'),
|
||||
this.$t('DAYS_OF_WEEK.MONDAY'),
|
||||
this.$t('DAYS_OF_WEEK.TUESDAY'),
|
||||
this.$t('DAYS_OF_WEEK.WEDNESDAY'),
|
||||
this.$t('DAYS_OF_WEEK.THURSDAY'),
|
||||
this.$t('DAYS_OF_WEEK.FRIDAY'),
|
||||
this.$t('DAYS_OF_WEEK.SATURDAY'),
|
||||
];
|
||||
return days[dayIndex];
|
||||
},
|
||||
getHeatmapLevelClass(value) {
|
||||
if (!value) return '';
|
||||
|
||||
const level = [...this.quantileRange, Infinity].findIndex(
|
||||
range => value <= range && value > 0
|
||||
);
|
||||
|
||||
if (level > 6) {
|
||||
return 'l6';
|
||||
}
|
||||
|
||||
return `l${level}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$heatmap-colors: (
|
||||
level-1: var(--w-50),
|
||||
level-2: var(--w-100),
|
||||
level-3: var(--w-300),
|
||||
level-4: var(--w-500),
|
||||
level-5: var(--w-700),
|
||||
level-6: var(--w-900),
|
||||
);
|
||||
|
||||
$heatmap-hover-border-color: (
|
||||
level-1: var(--w-25),
|
||||
level-2: var(--w-50),
|
||||
level-3: var(--w-100),
|
||||
level-4: var(--w-300),
|
||||
level-5: var(--w-500),
|
||||
level-6: var(--w-700),
|
||||
);
|
||||
|
||||
$tile-height: 1.875rem;
|
||||
$tile-gap: var(--space-smaller);
|
||||
$container-gap-row: var(--space-one);
|
||||
$container-gap-column: var(--space-two);
|
||||
$marker-height: var(--space-two);
|
||||
|
||||
@mixin heatmap-level($level) {
|
||||
$color: map-get($heatmap-colors, 'level-#{$level}');
|
||||
background-color: $color;
|
||||
&:hover {
|
||||
border: 1px solid map-get($heatmap-hover-border-color, 'level-#{$level}');
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.heatmap-container {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-cell {
|
||||
background-color: var(--color-background-light);
|
||||
border: 0px;
|
||||
|
||||
animation: loading-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes loading-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.heatmap-container {
|
||||
display: grid;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
gap: $container-gap-row $container-gap-column;
|
||||
grid-template-columns: 80px 1fr;
|
||||
min-height: calc(
|
||||
7 * #{$tile-height} + 6 * #{$tile-gap} + #{$container-gap-row} + #{$marker-height}
|
||||
);
|
||||
}
|
||||
|
||||
.heatmap-labels {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
gap: $tile-gap;
|
||||
flex-shrink: 0;
|
||||
|
||||
.heatmap-axis-label {
|
||||
height: $tile-height;
|
||||
min-width: 70px;
|
||||
font-size: var(--font-size-micro);
|
||||
font-weight: var(--font-weight-bold);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
@apply text-slate-800 dark:text-slate-200;
|
||||
|
||||
time {
|
||||
font-size: var(--font-size-micro);
|
||||
font-weight: var(--font-weight-normal);
|
||||
@apply text-slate-700 dark:text-slate-200;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.heatmap-grid {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
gap: $tile-gap;
|
||||
min-width: 700px;
|
||||
width: 100%;
|
||||
|
||||
.heatmap-grid-row {
|
||||
display: grid;
|
||||
gap: $tile-gap;
|
||||
grid-template-columns: repeat(24, 1fr);
|
||||
}
|
||||
|
||||
.heatmap-tile {
|
||||
width: auto;
|
||||
height: $tile-height;
|
||||
border-radius: var(--border-radius-normal);
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow-large);
|
||||
|
||||
transform: translateY(-2px);
|
||||
transition:
|
||||
transform 0.2s ease-in-out,
|
||||
box-shadow 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
&:not(.l1):not(.l2):not(.l3):not(.l4):not(.l5):not(.l6) {
|
||||
background-color: var(--color-background-light);
|
||||
border: 1px solid var(--color-border-light);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(0);
|
||||
box-shadow: none;
|
||||
border: 1px solid var(--color-border-light);
|
||||
}
|
||||
}
|
||||
|
||||
&.l1 {
|
||||
@include heatmap-level(1);
|
||||
}
|
||||
&.l2 {
|
||||
@include heatmap-level(2);
|
||||
}
|
||||
&.l3 {
|
||||
@include heatmap-level(3);
|
||||
}
|
||||
&.l4 {
|
||||
@include heatmap-level(4);
|
||||
}
|
||||
&.l5 {
|
||||
@include heatmap-level(5);
|
||||
}
|
||||
&.l6 {
|
||||
@include heatmap-level(6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.heatmap-markers {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(24, 1fr);
|
||||
gap: $tile-gap;
|
||||
width: 100%;
|
||||
font-size: var(--font-size-nano);
|
||||
font-weight: var(--font-weight-bold);
|
||||
height: $marker-height;
|
||||
color: var(--color-body);
|
||||
@apply text-slate-800 dark:text-slate-200;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+16
-39
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
+5
-2
@@ -45,7 +45,7 @@ const i18nKey = props.isOnChatwootCloud ? 'PAYWALL' : 'ENTERPRISE_PAYWALL';
|
||||
{{ $t('SLA.ENTERPRISE_PAYWALL.ASK_ADMIN') }}
|
||||
</span>
|
||||
</p>
|
||||
<template v-if="isOnChatwootCloud">
|
||||
<template v-if="isOnChatwootCloud || true">
|
||||
<woot-button
|
||||
color-scheme="primary"
|
||||
class="w-full mt-2 text-center rounded-xl"
|
||||
@@ -55,8 +55,11 @@ const i18nKey = props.isOnChatwootCloud ? 'PAYWALL' : 'ENTERPRISE_PAYWALL';
|
||||
>
|
||||
{{ $t('SLA.PAYWALL.UPGRADE_NOW') }}
|
||||
</woot-button>
|
||||
<span class="mt-2 text-xs tracking-tight text-center">
|
||||
{{ $t('SLA.PAYWALL.CANCEL_ANYTIME') }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="isSuperAdmin">
|
||||
<template v-else-if="isSuperAdmin">
|
||||
<a href="/super_admin" class="block w-full">
|
||||
<woot-button
|
||||
color-scheme="primary"
|
||||
|
||||
@@ -46,26 +46,8 @@ import webhooks from './modules/webhooks';
|
||||
import draftMessages from './modules/draftMessages';
|
||||
import SLAReports from './modules/SLAReports';
|
||||
|
||||
import LogRocket from 'logrocket';
|
||||
import createPlugin from 'logrocket-vuex';
|
||||
|
||||
const plugins = [];
|
||||
|
||||
if (window.logRocketProjectId) {
|
||||
LogRocket.init(window.logRocketProjectId);
|
||||
// eslint-disable-next-line func-names
|
||||
const logRocketPlugin = createPlugin(LogRocket, function (mutation) {
|
||||
const eventsToIgnore = ['SET_CURRENT_USER', 'AUTHENTICATE', 'CLEAR_USER'];
|
||||
if (eventsToIgnore.includes(mutation.type)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mutation;
|
||||
});
|
||||
|
||||
plugins.push(logRocketPlugin);
|
||||
}
|
||||
|
||||
Vue.use(Vuex);
|
||||
export default new Vuex.Store({
|
||||
modules: {
|
||||
|
||||
Reference in New Issue
Block a user