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: {
|
||||
|
||||
@@ -109,9 +109,10 @@ export default {
|
||||
generateArticleUrl(article) {
|
||||
return `/hc/${article.portal.slug}/articles/${article.slug}`;
|
||||
},
|
||||
handleKeyboardEvent(e) {
|
||||
this.processKeyDownEvent(e);
|
||||
this.$el.scrollTop = 102 * this.selectedIndex;
|
||||
adjustScroll() {
|
||||
this.$nextTick(() => {
|
||||
this.$el.scrollTop = 102 * this.selectedIndex;
|
||||
});
|
||||
},
|
||||
prepareContent(content) {
|
||||
return this.highlightContent(
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"M21 6.25A3.25 3.25 0 0 0 17.75 3H6.25A3.25 3.25 0 0 0 3 6.25v11.5A3.25 3.25 0 0 0 6.25 21h5.772a6.471 6.471 0 0 1-.709-1.5H6.25a1.75 1.75 0 0 1-1.75-1.75V8.5h15v2.813a6.471 6.471 0 0 1 1.5.709V6.25ZM6.25 4.5h11.5c.966 0 1.75.784 1.75 1.75V7h-15v-.75c0-.966.784-1.75 1.75-1.75Z",
|
||||
"M23 17.5a5.5 5.5 0 1 0-11 0 5.5 5.5 0 0 0 11 0Zm-5.5 0h2a.5.5 0 0 1 0 1H17a.5.5 0 0 1-.5-.491v-3.01a.5.5 0 0 1 1 0V17.5Z"
|
||||
],
|
||||
"calendar-outline": "M17.75 3A3.25 3.25 0 0 1 21 6.25v11.5A3.25 3.25 0 0 1 17.75 21H6.25A3.25 3.25 0 0 1 3 17.75V6.25A3.25 3.25 0 0 1 6.25 3h11.5Zm1.75 5.5h-15v9.25c0 .966.784 1.75 1.75 1.75h11.5a1.75 1.75 0 0 0 1.75-1.75V8.5Zm-11.75 6a1.25 1.25 0 1 1 0 2.5a1.25 1.25 0 0 1 0-2.5Zm4.25 0a1.25 1.25 0 1 1 0 2.5a1.25 1.25 0 0 1 0-2.5Zm-4.25-4a1.25 1.25 0 1 1 0 2.5a1.25 1.25 0 0 1 0-2.5Zm4.25 0a1.25 1.25 0 1 1 0 2.5a1.25 1.25 0 0 1 0-2.5Zm4.25 0a1.25 1.25 0 1 1 0 2.5a1.25 1.25 0 0 1 0-2.5Zm1.5-6H6.25A1.75 1.75 0 0 0 4.5 6.25V7h15v-.75a1.75 1.75 0 0 0-1.75-1.75Z",
|
||||
"call-outline": "m7.056 2.418 1.167-.351a2.75 2.75 0 0 1 3.302 1.505l.902 2.006a2.75 2.75 0 0 1-.633 3.139L10.3 10.11a.25.25 0 0 0-.078.155c-.044.397.225 1.17.845 2.245.451.781.86 1.33 1.207 1.637.242.215.375.261.432.245l2.01-.615a2.75 2.75 0 0 1 3.034 1.02l1.281 1.776a2.75 2.75 0 0 1-.339 3.605l-.886.84a3.75 3.75 0 0 1-3.587.889c-2.754-.769-5.223-3.093-7.435-6.924-2.215-3.836-2.992-7.14-2.276-9.913a3.75 3.75 0 0 1 2.548-2.652Zm.433 1.437a2.25 2.25 0 0 0-1.529 1.59c-.602 2.332.087 5.261 2.123 8.788 2.033 3.522 4.222 5.582 6.54 6.23a2.25 2.25 0 0 0 2.151-.534l.887-.84a1.25 1.25 0 0 0 .154-1.639l-1.28-1.775a1.25 1.25 0 0 0-1.38-.464l-2.015.617c-1.17.348-2.232-.593-3.372-2.568C9 11.93 8.642 10.9 8.731 10.099c.047-.416.24-.8.546-1.086l1.494-1.393a1.25 1.25 0 0 0 .288-1.427l-.902-2.006a1.25 1.25 0 0 0-1.5-.684l-1.168.352Z",
|
||||
"chat-help-outline": "M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10a9.96 9.96 0 0 1-4.587-1.112l-3.826 1.067a1.25 1.25 0 0 1-1.54-1.54l1.068-3.823A9.96 9.96 0 0 1 2 12C2 6.477 6.477 2 12 2Zm0 1.5A8.5 8.5 0 0 0 3.5 12c0 1.47.373 2.883 1.073 4.137l.15.27-1.112 3.984 3.987-1.112.27.15A8.5 8.5 0 1 0 12 3.5Zm0 12a1 1 0 1 1 0 2 1 1 0 0 1 0-2Zm0-8.75a2.75 2.75 0 0 1 2.75 2.75c0 1.01-.297 1.574-1.051 2.359l-.169.171c-.622.622-.78.886-.78 1.47a.75.75 0 0 1-1.5 0c0-1.01.297-1.574 1.051-2.359l.169-.171c.622-.622.78-.886.78-1.47a1.25 1.25 0 0 0-2.493-.128l-.007.128a.75.75 0 0 1-1.5 0A2.75 2.75 0 0 1 12 6.75Z",
|
||||
"chat-multiple-outline": "M9.562 3a7.5 7.5 0 0 0-6.798 10.673l-.724 2.842a1.25 1.25 0 0 0 1.504 1.524c.75-.18 1.903-.457 2.93-.702A7.5 7.5 0 1 0 9.561 3Zm-6 7.5a6 6 0 1 1 3.33 5.375l-.244-.121-.264.063c-.923.22-1.99.475-2.788.667l.69-2.708.07-.276-.13-.253a5.971 5.971 0 0 1-.664-2.747Zm11 10.5c-1.97 0-3.762-.759-5.1-2h.1c.718 0 1.415-.089 2.08-.257.865.482 1.86.757 2.92.757.96 0 1.866-.225 2.67-.625l.243-.121.264.063c.922.22 1.966.445 2.74.61-.175-.751-.414-1.756-.642-2.651l-.07-.276.13-.253a5.971 5.971 0 0 0 .665-2.747 5.995 5.995 0 0 0-2.747-5.042 8.44 8.44 0 0 0-.8-2.047 7.503 7.503 0 0 1 4.344 10.263c.253 1.008.509 2.1.671 2.803a1.244 1.244 0 0 1-1.467 1.5 132.62 132.62 0 0 1-2.913-.64 7.476 7.476 0 0 1-3.088.663Z",
|
||||
@@ -277,5 +278,6 @@
|
||||
"chevrons-left-outline": ["m11 17-5-5 5-5", "m18 17-5-5 5-5"],
|
||||
"chevron-left-single-outline": "m15 18-6-6 6-6",
|
||||
"chevrons-right-outline": ["m6 17 5-5-5-5", "m13 17 5-5-5-5"],
|
||||
"chevron-right-single-outline": "m9 18 6-6-6-6"
|
||||
"chevron-right-single-outline": "m9 18 6-6-6-6",
|
||||
"avatar-upload-outline": "M19.754 11a.75.75 0 0 1 .743.648l.007.102v7a3.25 3.25 0 0 1-3.065 3.246l-.185.005h-11a3.25 3.25 0 0 1-3.244-3.066l-.006-.184V11.75a.75.75 0 0 1 1.494-.102l.006.102v7a1.75 1.75 0 0 0 1.607 1.745l.143.006h11A1.75 1.75 0 0 0 19 18.894l.005-.143V11.75a.75.75 0 0 1 .75-.75ZM6.22 7.216l4.996-4.996a.75.75 0 0 1 .976-.073l.084.072l5.005 4.997a.75.75 0 0 1-.976 1.134l-.084-.073l-3.723-3.716l.001 11.694a.75.75 0 0 1-.648.743l-.102.007a.75.75 0 0 1-.743-.648L11 16.255V4.558L7.28 8.277a.75.75 0 0 1-.976.073l-.084-.073a.75.75 0 0 1-.073-.977l.073-.084l4.996-4.996L6.22 7.216Z"
|
||||
}
|
||||
|
||||
@@ -8,16 +8,12 @@
|
||||
</ul>
|
||||
</template>
|
||||
<script>
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import {
|
||||
hasPressedArrowUpKey,
|
||||
hasPressedArrowDownKey,
|
||||
} from 'shared/helpers/KeyboardHelpers';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
export default {
|
||||
name: 'WootDropdownMenu',
|
||||
componentName: 'WootDropdownMenu',
|
||||
|
||||
mixins: [eventListenerMixins],
|
||||
mixins: [keyboardEventListenerMixins],
|
||||
|
||||
props: {
|
||||
placement: {
|
||||
@@ -31,38 +27,46 @@ export default {
|
||||
'ul.dropdown li.dropdown-menu__item .button'
|
||||
);
|
||||
},
|
||||
activeElementIndex() {
|
||||
const menuButtons = this.dropdownMenuButtons();
|
||||
getActiveButtonIndex(menuButtons) {
|
||||
const focusedButton = this.$refs.dropdownMenu.querySelector(
|
||||
'ul.dropdown li.dropdown-menu__item .button:focus'
|
||||
);
|
||||
const activeIndex = [...menuButtons].indexOf(focusedButton);
|
||||
return activeIndex;
|
||||
return Array.from(menuButtons).indexOf(focusedButton);
|
||||
},
|
||||
handleKeyEvents(e) {
|
||||
getKeyboardEvents() {
|
||||
const menuButtons = this.dropdownMenuButtons();
|
||||
const lastElementIndex = menuButtons.length - 1;
|
||||
|
||||
return {
|
||||
ArrowUp: {
|
||||
action: e => {
|
||||
e.preventDefault();
|
||||
this.focusPreviousButton(menuButtons);
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
ArrowDown: {
|
||||
action: e => {
|
||||
e.preventDefault();
|
||||
this.focusNextButton(menuButtons);
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
focusPreviousButton(menuButtons) {
|
||||
const activeIndex = this.getActiveButtonIndex(menuButtons);
|
||||
const newIndex =
|
||||
activeIndex >= 1 ? activeIndex - 1 : menuButtons.length - 1;
|
||||
this.focusButton(menuButtons, newIndex);
|
||||
},
|
||||
focusNextButton(menuButtons) {
|
||||
const activeIndex = this.getActiveButtonIndex(menuButtons);
|
||||
const newIndex =
|
||||
activeIndex === menuButtons.length - 1 ? 0 : activeIndex + 1;
|
||||
this.focusButton(menuButtons, newIndex);
|
||||
},
|
||||
focusButton(menuButtons, newIndex) {
|
||||
if (menuButtons.length === 0) return;
|
||||
|
||||
if (hasPressedArrowUpKey(e)) {
|
||||
const activeIndex = this.activeElementIndex();
|
||||
|
||||
if (activeIndex >= 1) {
|
||||
menuButtons[activeIndex - 1].focus();
|
||||
} else {
|
||||
menuButtons[lastElementIndex].focus();
|
||||
}
|
||||
}
|
||||
if (hasPressedArrowDownKey(e)) {
|
||||
const activeIndex = this.activeElementIndex();
|
||||
|
||||
if (activeIndex === lastElementIndex) {
|
||||
menuButtons[0].focus();
|
||||
} else {
|
||||
menuButtons[activeIndex + 1].focus();
|
||||
}
|
||||
}
|
||||
menuButtons[newIndex].focus();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export const isEnter = e => {
|
||||
return e.keyCode === 13;
|
||||
return e.key === 'Enter';
|
||||
};
|
||||
|
||||
export const isEscape = e => {
|
||||
return e.keyCode === 27;
|
||||
return e.key === 'Escape';
|
||||
};
|
||||
|
||||
export const hasPressedShift = e => {
|
||||
@@ -19,118 +19,7 @@ export const hasPressedEnterAndNotCmdOrShift = e => {
|
||||
};
|
||||
|
||||
export const hasPressedCommandAndEnter = e => {
|
||||
return e.metaKey && e.keyCode === 13;
|
||||
};
|
||||
|
||||
export const hasPressedCommandAndForwardSlash = e => {
|
||||
return e.metaKey && e.keyCode === 191;
|
||||
};
|
||||
|
||||
export const hasPressedAltAndCKey = e => {
|
||||
return e.altKey && e.keyCode === 67;
|
||||
};
|
||||
|
||||
export const hasPressedAltAndVKey = e => {
|
||||
return e.altKey && e.keyCode === 86;
|
||||
};
|
||||
|
||||
export const hasPressedAltAndRKey = e => {
|
||||
return e.altKey && e.keyCode === 82;
|
||||
};
|
||||
|
||||
export const hasPressedAltAndSKey = e => {
|
||||
return e.altKey && e.keyCode === 83;
|
||||
};
|
||||
|
||||
export const hasPressedAltAndBKey = e => {
|
||||
return e.altKey && e.keyCode === 66;
|
||||
};
|
||||
|
||||
export const hasPressedAltAndNKey = e => {
|
||||
return e.altKey && e.keyCode === 78;
|
||||
};
|
||||
|
||||
export const hasPressedAltAndAKey = e => {
|
||||
return e.altKey && e.keyCode === 65;
|
||||
};
|
||||
|
||||
export const hasPressedAltAndPKey = e => {
|
||||
return e.altKey && e.keyCode === 80;
|
||||
};
|
||||
|
||||
export const hasPressedAltAndLKey = e => {
|
||||
return e.altKey && e.keyCode === 76;
|
||||
};
|
||||
|
||||
export const hasPressedAltAndEKey = e => {
|
||||
return e.altKey && e.keyCode === 69;
|
||||
};
|
||||
|
||||
export const hasPressedCommandPlusAltAndEKey = e => {
|
||||
return e.metaKey && e.altKey && e.keyCode === 69;
|
||||
};
|
||||
|
||||
export const hasPressedAltAndOKey = e => {
|
||||
return e.altKey && e.keyCode === 79;
|
||||
};
|
||||
|
||||
export const hasPressedAltAndJKey = e => {
|
||||
return e.altKey && e.keyCode === 74;
|
||||
};
|
||||
|
||||
export const hasPressedAltAndKKey = e => {
|
||||
return e.altKey && e.keyCode === 75;
|
||||
};
|
||||
|
||||
export const hasPressedAltAndMKey = e => {
|
||||
return e.altKey && e.keyCode === 77;
|
||||
};
|
||||
|
||||
export const hasPressedArrowUpKey = e => {
|
||||
return e.keyCode === 38;
|
||||
};
|
||||
|
||||
export const hasPressedArrowDownKey = e => {
|
||||
return e.keyCode === 40;
|
||||
};
|
||||
|
||||
export const hasPressedArrowLeftKey = e => {
|
||||
return e.keyCode === 37;
|
||||
};
|
||||
|
||||
export const hasPressedArrowRightKey = e => {
|
||||
return e.keyCode === 39;
|
||||
};
|
||||
|
||||
export const hasPressedCommandPlusKKey = e => {
|
||||
return e.metaKey && e.keyCode === 75;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a string representation of the hotkey pattern based on the provided event object.
|
||||
* @param {KeyboardEvent} e - The keyboard event object.
|
||||
* @returns {string} - The hotkey pattern string.
|
||||
*/
|
||||
export const buildHotKeys = e => {
|
||||
const key = e.key.toLowerCase();
|
||||
if (['shift', 'meta', 'alt', 'control'].includes(key)) {
|
||||
return key;
|
||||
}
|
||||
let hotKeyPattern = '';
|
||||
if (e.altKey) {
|
||||
hotKeyPattern += 'alt+';
|
||||
}
|
||||
if (e.ctrlKey) {
|
||||
hotKeyPattern += 'ctrl+';
|
||||
}
|
||||
if (e.metaKey && !e.ctrlKey) {
|
||||
hotKeyPattern += 'meta+';
|
||||
}
|
||||
if (e.shiftKey) {
|
||||
hotKeyPattern += 'shift+';
|
||||
}
|
||||
hotKeyPattern += key;
|
||||
return hotKeyPattern;
|
||||
return hasPressedCommand(e) && isEnter(e);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,20 +3,19 @@ import {
|
||||
isEscape,
|
||||
hasPressedShift,
|
||||
hasPressedCommand,
|
||||
buildHotKeys,
|
||||
isActiveElementTypeable,
|
||||
} from '../KeyboardHelpers';
|
||||
|
||||
describe('#KeyboardHelpers', () => {
|
||||
describe('#isEnter', () => {
|
||||
it('return correct values', () => {
|
||||
expect(isEnter({ keyCode: 13 })).toEqual(true);
|
||||
expect(isEnter({ key: 'Enter' })).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#isEscape', () => {
|
||||
it('return correct values', () => {
|
||||
expect(isEscape({ keyCode: 27 })).toEqual(true);
|
||||
expect(isEscape({ key: 'Escape' })).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,14 +30,6 @@ describe('#KeyboardHelpers', () => {
|
||||
expect(hasPressedCommand({ metaKey: true })).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#buildHotKeys', () => {
|
||||
it('returns hot keys', () => {
|
||||
expect(buildHotKeys({ altKey: true, key: 'alt' })).toEqual('alt');
|
||||
expect(buildHotKeys({ ctrlKey: true, key: 'a' })).toEqual('ctrl+a');
|
||||
expect(buildHotKeys({ metaKey: true, key: 'b' })).toEqual('meta+b');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isActiveElementTypeable', () => {
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { isActiveElementTypeable, isEscape } from '../helpers/KeyboardHelpers';
|
||||
|
||||
export default {
|
||||
mounted() {
|
||||
document.addEventListener('keydown', this.onKeyDownHandler);
|
||||
},
|
||||
beforeDestroy() {
|
||||
document.removeEventListener('keydown', this.onKeyDownHandler);
|
||||
},
|
||||
methods: {
|
||||
onKeyDownHandler(e) {
|
||||
const isTypeable = isActiveElementTypeable(e);
|
||||
|
||||
if (isTypeable) {
|
||||
if (isEscape(e)) {
|
||||
e.target.blur();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleKeyEvents(e);
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { isActiveElementTypeable, isEscape } from '../helpers/KeyboardHelpers';
|
||||
|
||||
import { createKeybindingsHandler } from 'tinykeys';
|
||||
|
||||
// this is a store that stores the handler globally, and only gets reset on reload
|
||||
const taggedHandlers = [];
|
||||
|
||||
export default {
|
||||
mounted() {
|
||||
const events = this.getKeyboardEvents();
|
||||
if (events) {
|
||||
const wrappedEvents = this.wrapEventsInKeybindingsHandler(events);
|
||||
const keydownHandler = createKeybindingsHandler(wrappedEvents);
|
||||
this.appendToHandler(keydownHandler);
|
||||
document.addEventListener('keydown', keydownHandler);
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.$el && this.$el.dataset.keydownHandlerIndex) {
|
||||
const handlerToRemove =
|
||||
taggedHandlers[this.$el.dataset.keydownHandlerIndex];
|
||||
document.removeEventListener('keydown', handlerToRemove);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
appendToHandler(keydownHandler) {
|
||||
const indexToAppend = taggedHandlers.push(keydownHandler) - 1;
|
||||
const root = this.$el;
|
||||
root.dataset.keydownHandlerIndex = indexToAppend;
|
||||
},
|
||||
getKeyboardEvents() {
|
||||
return null;
|
||||
},
|
||||
wrapEventsInKeybindingsHandler(events) {
|
||||
const wrappedEvents = {};
|
||||
Object.keys(events).forEach(eventName => {
|
||||
wrappedEvents[eventName] = this.keydownWrapper(events[eventName]);
|
||||
});
|
||||
|
||||
return wrappedEvents;
|
||||
},
|
||||
keydownWrapper(handler) {
|
||||
return e => {
|
||||
const actionToPerform =
|
||||
typeof handler === 'function' ? handler : handler.action;
|
||||
const allowOnFocusedInput =
|
||||
typeof handler === 'function' ? false : handler.allowOnFocusedInput;
|
||||
|
||||
const isTypeable = isActiveElementTypeable(e);
|
||||
|
||||
if (isTypeable) {
|
||||
if (isEscape(e)) {
|
||||
e.target.blur();
|
||||
}
|
||||
|
||||
if (!allowOnFocusedInput) return;
|
||||
}
|
||||
|
||||
actionToPerform(e);
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -33,8 +33,8 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
|
||||
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import Header from '../../components/playground/Header.vue';
|
||||
import UserMessage from '../../components/playground/UserMessage.vue';
|
||||
import BotMessage from '../../components/playground/BotMessage.vue';
|
||||
@@ -47,7 +47,7 @@ export default {
|
||||
BotMessage,
|
||||
TypingIndicator,
|
||||
},
|
||||
mixins: [messageFormatterMixin],
|
||||
mixins: [messageFormatterMixin, keyboardEventListenerMixins],
|
||||
props: {
|
||||
componentData: {
|
||||
type: Object,
|
||||
@@ -67,17 +67,17 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.focusInput();
|
||||
document.addEventListener('keydown', this.handleKeyEvents);
|
||||
},
|
||||
beforeDestroy() {
|
||||
document.removeEventListener('keydown', this.handleKeyEvents);
|
||||
},
|
||||
methods: {
|
||||
handleKeyEvents(e) {
|
||||
const keyCode = buildHotKeys(e);
|
||||
if (['meta+enter', 'ctrl+enter'].includes(keyCode)) {
|
||||
this.onMessageSend();
|
||||
}
|
||||
getKeyboardEvents() {
|
||||
return {
|
||||
'$mod+Enter': {
|
||||
action: () => {
|
||||
this.onMessageSend();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
focusInput() {
|
||||
this.$refs.messageInput.focus();
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<button
|
||||
class="inline-flex items-center gap-1 text-sm font-medium reset-base rounded-xl w-fit"
|
||||
:class="buttonClasses"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<fluent-icon
|
||||
v-if="icon && !trailingIcon"
|
||||
size="1.16em"
|
||||
:icon="icon"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<span
|
||||
v-if="$slots.default"
|
||||
class="text-sm font-medium truncate ltr:text-left rtl:text-right"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
<fluent-icon
|
||||
v-if="icon && trailingIcon"
|
||||
size="1.16em"
|
||||
:icon="icon"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, defineProps, useAttrs } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'solid',
|
||||
validator: value => ['outline', 'ghost', 'solid'].includes(value),
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'large',
|
||||
validator: value => ['medium', 'large'].includes(value),
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
colorScheme: {
|
||||
type: String,
|
||||
default: 'primary',
|
||||
validator: value => ['primary', 'secondary', 'danger'].includes(value),
|
||||
},
|
||||
trailingIcon: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const attrs = useAttrs();
|
||||
|
||||
const baseClasses = {
|
||||
outline: 'outline outline-1 -outline-offset-1 focus:ring focus:ring-offset-1',
|
||||
ghost: 'hover:text-600 active:text-600 focus:ring focus:ring-offset-1',
|
||||
solid: 'hover:bg-700 active:bg-700 focus:ring focus:ring-offset-1',
|
||||
};
|
||||
|
||||
const colorClass = computed(() => {
|
||||
if (attrs.disabled) {
|
||||
return 'bg-ash-200 text-ash-600 cursor-not-allowed';
|
||||
}
|
||||
|
||||
const styleMap = {
|
||||
primary: {
|
||||
outline: `${baseClasses.outline} outline-primary-400 hover:text-primary-600 active:text-primary-600 focus:ring-primary-400`,
|
||||
ghost: `${baseClasses.ghost} focus:ring-primary-400`,
|
||||
solid: `${baseClasses.solid} bg-primary-600 text-white focus:ring-primary-400`,
|
||||
},
|
||||
secondary: {
|
||||
outline: `${baseClasses.outline} outline-ash-400 hover:text-ash-600 active:text-ash-600 focus:ring-ash-400`,
|
||||
ghost: `${baseClasses.ghost} focus:ring-ash-400`,
|
||||
solid: `${baseClasses.solid} bg-ash-100 text-ash-900 focus:ring-ash-400`,
|
||||
},
|
||||
danger: {
|
||||
outline: `${baseClasses.outline} outline-ruby-400 hover:text-ruby-600 active:text-ruby-600 focus:ring-ruby-400`,
|
||||
ghost: `${baseClasses.ghost} focus:ring-ruby-400`,
|
||||
solid: `${baseClasses.solid} bg-ruby-600 text-white focus:ring-ruby-400`,
|
||||
},
|
||||
};
|
||||
|
||||
const schemeStyles = styleMap[props.colorScheme];
|
||||
return schemeStyles[props.variant] || schemeStyles.solid;
|
||||
});
|
||||
|
||||
const sizeClass = computed(() => {
|
||||
if (props.size === 'medium') {
|
||||
return 'h-8 px-3 py-1.5 text-sm';
|
||||
}
|
||||
return 'h-10 px-4 py-2.5 text';
|
||||
});
|
||||
|
||||
const buttonClasses = computed(() => [colorClass.value, sizeClass.value]);
|
||||
</script>
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div
|
||||
class="rounded-xl flex leading-[100%] font-medium items-center justify-center text-center cursor-default"
|
||||
:class="`h-[${size}px] w-[${size}px] ${colorClass}`"
|
||||
:style="style"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<slot>{{ initial }}</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { userInitial } from 'v3/helpers/CommonHelper';
|
||||
const colors = {
|
||||
1: 'bg-ash-200 text-ash-900',
|
||||
2: 'bg-amber-200 text-amber-900',
|
||||
3: 'bg-pink-100 text-pink-800',
|
||||
4: 'bg-purple-100 text-purple-800',
|
||||
5: 'bg-indigo-100 text-indigo-800',
|
||||
6: 'bg-grass-100 text-grass-800',
|
||||
7: 'bg-mint-100 text-mint-800',
|
||||
8: 'bg-orange-100 text-orange-800',
|
||||
};
|
||||
const props = defineProps({
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
default: 72,
|
||||
},
|
||||
});
|
||||
const style = computed(() => ({
|
||||
fontSize: `${Math.floor(props.size / 2.5)}px`,
|
||||
}));
|
||||
const colorClass = computed(() => {
|
||||
return colors[(props.name.length % 8) + 1];
|
||||
});
|
||||
|
||||
const initial = computed(() => userInitial(props.name));
|
||||
</script>
|
||||
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div class="relative rounded-xl h-[72px] w-[72px] cursor-pointe group">
|
||||
<img
|
||||
v-if="shouldShowImage"
|
||||
class="rounded-xl h-[72px] w-[72px]"
|
||||
:alt="name"
|
||||
:src="src"
|
||||
draggable="false"
|
||||
@load="onImageLoad"
|
||||
@error="onImageLoadError"
|
||||
/>
|
||||
<initials-avatar v-else-if="!shouldShowImage" :name="name" :size="72" />
|
||||
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
|
||||
hidden
|
||||
@change="onImageUpload"
|
||||
/>
|
||||
<div class="hidden group-hover:block">
|
||||
<button
|
||||
v-if="src"
|
||||
class="absolute z-10 flex items-center justify-center w-6 h-6 p-1 border border-white rounded-full select-none dark:border-ash-75 reset-base -top-2 -right-2 bg-ash-300"
|
||||
@click="onAvatarDelete"
|
||||
>
|
||||
<fluent-icon icon="dismiss" size="16" class="text-ash-900" />
|
||||
</button>
|
||||
<button
|
||||
class="reset-base absolute h-[72px] w-[72px] top-0 left-0 rounded-xl select-none flex items-center justify-center bg-modal-backdrop-dark dark:bg-modal-backdrop-dark"
|
||||
@click="openFileInput"
|
||||
>
|
||||
<fluent-icon icon="avatar-upload" size="32" class="text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import InitialsAvatar from './InitialsAvatar.vue';
|
||||
const props = defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['change', 'delete']);
|
||||
|
||||
const hasImageLoaded = ref(false);
|
||||
const imageLoadedError = ref(false);
|
||||
const fileInputRef = ref(null);
|
||||
|
||||
const shouldShowImage = computed(() => props.src && !imageLoadedError.value);
|
||||
|
||||
const onImageLoadError = () => {
|
||||
imageLoadedError.value = true;
|
||||
};
|
||||
|
||||
const onImageLoad = () => {
|
||||
hasImageLoaded.value = true;
|
||||
imageLoadedError.value = false;
|
||||
};
|
||||
|
||||
const openFileInput = () => {
|
||||
fileInputRef.value.click();
|
||||
};
|
||||
|
||||
const onImageUpload = event => {
|
||||
const [file] = event.target.files;
|
||||
emits('change', {
|
||||
file,
|
||||
url: file ? URL.createObjectURL(file) : null,
|
||||
});
|
||||
};
|
||||
|
||||
const onAvatarDelete = () => {
|
||||
emits('delete');
|
||||
};
|
||||
</script>
|
||||
@@ -1,3 +1,9 @@
|
||||
export const replaceRouteWithReload = url => {
|
||||
window.location = url;
|
||||
};
|
||||
|
||||
export const userInitial = name => {
|
||||
const parts = name.split(/[ -]/).filter(Boolean);
|
||||
let initials = parts.map(part => part[0].toUpperCase()).join('');
|
||||
return initials.slice(0, 2);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { userInitial } from '../CommonHelper';
|
||||
|
||||
describe('#userInitial', () => {
|
||||
it('returns the initials of the user', () => {
|
||||
expect(userInitial('John Doe')).toEqual('JD');
|
||||
expect(userInitial('John')).toEqual('J');
|
||||
expect(userInitial('John-Doe')).toEqual('JD');
|
||||
expect(userInitial('John Doe Smith')).toEqual('JD');
|
||||
});
|
||||
});
|
||||
@@ -220,25 +220,26 @@ export default {
|
||||
},
|
||||
dropdownItem() {
|
||||
// This function is used to get all the items in the dropdown.
|
||||
if (!this.showDropdown) return [];
|
||||
return Array.from(
|
||||
this.$refs.dropdown.querySelectorAll(
|
||||
this.$refs.dropdown?.querySelectorAll(
|
||||
'div.country-dropdown div.country-dropdown--item'
|
||||
)
|
||||
);
|
||||
},
|
||||
focusedOrActiveItem(className) {
|
||||
// This function is used to get the focused or active item in the dropdown.
|
||||
if (!this.showDropdown) return [];
|
||||
return Array.from(
|
||||
this.$refs.dropdown.querySelectorAll(
|
||||
this.$refs.dropdown?.querySelectorAll(
|
||||
`div.country-dropdown div.country-dropdown--item.${className}`
|
||||
)
|
||||
);
|
||||
},
|
||||
handleKeyboardEvent(e) {
|
||||
if (this.showDropdown) {
|
||||
this.processKeyDownEvent(e);
|
||||
adjustScroll() {
|
||||
this.$nextTick(() => {
|
||||
this.scrollToFocusedOrActiveItem(this.focusedOrActiveItem('focus'));
|
||||
}
|
||||
});
|
||||
},
|
||||
onSelect() {
|
||||
this.onSelectCountry(this.items[this.selectedIndex]);
|
||||
|
||||
Reference in New Issue
Block a user