Merge branch 'develop' into fix/CW-6859
This commit is contained in:
@@ -103,6 +103,7 @@ const showPagination = computed(() => {
|
||||
<ContactsActiveFiltersPreview
|
||||
v-if="showActiveFiltersPreview"
|
||||
:active-segment="activeSegment"
|
||||
class="mb-1"
|
||||
@clear-filters="emit('clearFilters')"
|
||||
@open-filter="openFilter"
|
||||
/>
|
||||
|
||||
@@ -57,7 +57,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
|
||||
<template>
|
||||
<ButtonGroup
|
||||
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2/90 backdrop-blur-lg border border-n-weak/50 rounded-full gap-1.5 p-1.5 shadow-sm transition-shadow duration-200 hover:shadow"
|
||||
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2/90 backdrop-blur-lg border border-n-weak/50 rounded-full gap-1.5 p-1.5 shadow-sm transition-shadow duration-200 hover:shadow !z-20"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, useSlots } from 'vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
@@ -30,23 +30,40 @@ const modelValue = defineModel({
|
||||
});
|
||||
|
||||
const selectedCount = computed(() => modelValue.value.size);
|
||||
const totalCount = computed(() => props.allItems.length);
|
||||
|
||||
const visibleItemIds = computed(() => props.allItems.map(item => item.id));
|
||||
const visibleItemCount = computed(() => visibleItemIds.value.length);
|
||||
const selectedVisibleCount = computed(
|
||||
() => visibleItemIds.value.filter(id => modelValue.value.has(id)).length
|
||||
);
|
||||
const hasSelected = computed(() => selectedCount.value > 0);
|
||||
const isIndeterminate = computed(
|
||||
() => hasSelected.value && selectedCount.value < totalCount.value
|
||||
() =>
|
||||
selectedVisibleCount.value > 0 &&
|
||||
selectedVisibleCount.value < visibleItemCount.value
|
||||
);
|
||||
const allSelected = computed(
|
||||
() => totalCount.value > 0 && selectedCount.value === totalCount.value
|
||||
() =>
|
||||
visibleItemCount.value > 0 &&
|
||||
selectedVisibleCount.value === visibleItemCount.value
|
||||
);
|
||||
|
||||
const slots = useSlots();
|
||||
const hasSecondaryActions = computed(() => Boolean(slots['secondary-actions']));
|
||||
|
||||
const bulkCheckboxState = computed({
|
||||
get: () => allSelected.value,
|
||||
set: shouldSelectAll => {
|
||||
const newSelectedIds = shouldSelectAll
|
||||
? new Set(props.allItems.map(item => item.id))
|
||||
: new Set();
|
||||
modelValue.value = newSelectedIds;
|
||||
if (!visibleItemCount.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedSelection = new Set(modelValue.value);
|
||||
if (shouldSelectAll) {
|
||||
visibleItemIds.value.forEach(id => updatedSelection.add(id));
|
||||
} else {
|
||||
visibleItemIds.value.forEach(id => updatedSelection.delete(id));
|
||||
}
|
||||
modelValue.value = updatedSelection;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -63,7 +80,7 @@ const bulkCheckboxState = computed({
|
||||
v-if="hasSelected"
|
||||
class="flex items-center gap-3 py-1 ltr:pl-3 rtl:pr-3 ltr:pr-4 rtl:pl-4 rounded-lg bg-n-solid-2 outline outline-1 outline-n-container shadow"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<Checkbox
|
||||
v-model="bulkCheckboxState"
|
||||
@@ -78,21 +95,23 @@ const bulkCheckboxState = computed({
|
||||
<span class="text-sm text-n-slate-10 truncate tabular-nums">
|
||||
{{ selectedCountLabel }}
|
||||
</span>
|
||||
<div class="h-4 w-px bg-n-strong" />
|
||||
<slot name="secondary-actions" />
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<slot name="actions" :selected-count="selectedCount">
|
||||
<Button
|
||||
:label="deleteLabel"
|
||||
sm
|
||||
ruby
|
||||
ghost
|
||||
class="!px-1.5"
|
||||
icon="i-lucide-trash"
|
||||
@click="emit('bulkDelete')"
|
||||
/>
|
||||
</slot>
|
||||
<slot v-if="hasSecondaryActions" name="secondary-actions" />
|
||||
<div v-if="hasSecondaryActions" class="h-4 w-px bg-n-strong" />
|
||||
<div class="flex items-center gap-3">
|
||||
<slot name="actions" :selected-count="selectedCount">
|
||||
<Button
|
||||
:label="deleteLabel"
|
||||
sm
|
||||
ruby
|
||||
ghost
|
||||
class="!px-1.5"
|
||||
icon="i-lucide-trash"
|
||||
@click="emit('bulkDelete')"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-3">
|
||||
|
||||
@@ -27,10 +27,6 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
isPopout: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -208,10 +204,7 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="space-y-2 mb-4">
|
||||
<div
|
||||
class="overflow-y-auto"
|
||||
:class="{ 'max-h-96': isPopout, 'max-h-56': !isPopout }"
|
||||
>
|
||||
<div class="overflow-y-auto max-h-56">
|
||||
<p
|
||||
v-dompurify-html="formatMessage(generatedContent, false)"
|
||||
class="text-n-iris-12 text-sm prose-sm font-normal !mb-4"
|
||||
|
||||
@@ -984,7 +984,32 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
}
|
||||
|
||||
.ProseMirror-woot-style {
|
||||
@apply overflow-auto min-h-[5rem] max-h-[7.5rem];
|
||||
@apply overflow-auto;
|
||||
}
|
||||
|
||||
.ProseMirror-woot-style:not(
|
||||
:where(.resizable-editor-wrapper .ProseMirror-woot-style)
|
||||
) {
|
||||
@apply min-h-[5rem] max-h-[7.5rem];
|
||||
}
|
||||
|
||||
// Resizable editor wrapper styles
|
||||
.resizable-editor-wrapper {
|
||||
.ProseMirror-woot-style {
|
||||
min-height: clamp(
|
||||
var(--editor-min-allowed, var(--editor-min-height, 5rem)),
|
||||
var(--editor-height, var(--editor-min-height, 5rem)),
|
||||
var(--editor-max-allowed, var(--editor-max-height, 7.5rem))
|
||||
);
|
||||
max-height: clamp(
|
||||
var(--editor-min-allowed, var(--editor-min-height, 5rem)),
|
||||
var(--editor-height, var(--editor-min-height, 5rem)),
|
||||
var(--editor-max-allowed, var(--editor-max-height, 7.5rem))
|
||||
);
|
||||
transition:
|
||||
min-height var(--editor-height-transition, 180ms ease),
|
||||
max-height var(--editor-height-transition, 180ms ease);
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-prompt-backdrop::backdrop {
|
||||
|
||||
@@ -54,7 +54,7 @@ export default {
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
emits: ['setReplyMode', 'togglePopout', 'executeCopilotAction'],
|
||||
emits: ['setReplyMode', 'toggleEditorSize', 'executeCopilotAction'],
|
||||
setup(props, { emit }) {
|
||||
const setReplyMode = mode => {
|
||||
emit('setReplyMode', mode);
|
||||
@@ -189,7 +189,7 @@ export default {
|
||||
class="text-n-slate-11"
|
||||
sm
|
||||
icon="i-lucide-maximize-2"
|
||||
@click="$emit('togglePopout')"
|
||||
@click="$emit('toggleEditorSize')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,10 +16,6 @@ defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isPopout: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -69,7 +65,6 @@ const onSend = () => {
|
||||
:generated-content="generatedContent"
|
||||
:min-height="4"
|
||||
:enabled-menu-options="[]"
|
||||
:is-popout="isPopout"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@clear-selection="clearEditorSelection"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { ref, provide } from 'vue';
|
||||
import { ref, provide, useTemplateRef } from 'vue';
|
||||
import { useElementSize } from '@vueuse/core';
|
||||
// composable
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useLabelSuggestions } from 'dashboard/composables/useLabelSuggestions';
|
||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||
|
||||
@@ -11,6 +11,7 @@ import MessageList from 'next/message/MessageList.vue';
|
||||
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
|
||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import ResizableEditorWrapper from './ResizableEditorWrapper.vue';
|
||||
|
||||
// stores and apis
|
||||
import { mapGetters } from 'vuex';
|
||||
@@ -43,21 +44,16 @@ export default {
|
||||
Banner,
|
||||
ConversationLabelSuggestion,
|
||||
Spinner,
|
||||
ResizableEditorWrapper,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
setup() {
|
||||
const isPopOutReplyBox = ref(false);
|
||||
const conversationPanelRef = ref(null);
|
||||
|
||||
const keyboardEvents = {
|
||||
Escape: {
|
||||
action: () => {
|
||||
isPopOutReplyBox.value = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
const resizableEditorWrapperRef = ref(null);
|
||||
const messagesViewRef = useTemplateRef('messagesViewRef');
|
||||
const topBannerRef = useTemplateRef('topBannerRef');
|
||||
const { height: containerHeight } = useElementSize(messagesViewRef);
|
||||
const { height: topBannerHeight } = useElementSize(topBannerRef);
|
||||
|
||||
const {
|
||||
captainTasksEnabled,
|
||||
@@ -68,11 +64,15 @@ export default {
|
||||
provide('contextMenuElementTarget', conversationPanelRef);
|
||||
|
||||
return {
|
||||
isPopOutReplyBox,
|
||||
captainTasksEnabled,
|
||||
getLabelSuggestions,
|
||||
isLabelSuggestionFeatureEnabled,
|
||||
conversationPanelRef,
|
||||
resizableEditorWrapperRef,
|
||||
messagesViewRef,
|
||||
topBannerRef,
|
||||
containerHeight,
|
||||
topBannerHeight,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
@@ -254,6 +254,7 @@ export default {
|
||||
this.fetchAllAttachmentsFromCurrentChat();
|
||||
this.fetchSuggestions();
|
||||
this.messageSentSinceOpened = false;
|
||||
this.resetReplyEditorHeight();
|
||||
},
|
||||
},
|
||||
|
||||
@@ -437,26 +438,37 @@ export default {
|
||||
const payload = useSnakeCase(message);
|
||||
await this.$store.dispatch('sendMessageWithData', payload);
|
||||
},
|
||||
toggleReplyEditorSize() {
|
||||
this.resizableEditorWrapperRef?.toggleEditorExpand?.();
|
||||
},
|
||||
resetReplyEditorHeight() {
|
||||
this.resizableEditorWrapperRef?.resetEditorHeight?.();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col justify-between flex-grow h-full min-w-0 m-0">
|
||||
<Banner
|
||||
v-if="!currentChat.can_reply"
|
||||
color-scheme="alert"
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="replyWindowBannerMessage"
|
||||
:href-link="replyWindowLink"
|
||||
:href-link-text="replyWindowLinkText"
|
||||
/>
|
||||
<Banner
|
||||
v-else-if="hasDuplicateInstagramInbox"
|
||||
color-scheme="alert"
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
|
||||
/>
|
||||
<div
|
||||
ref="messagesViewRef"
|
||||
class="flex flex-col justify-between flex-grow h-full min-w-0 m-0"
|
||||
>
|
||||
<div ref="topBannerRef">
|
||||
<Banner
|
||||
v-if="!currentChat.can_reply"
|
||||
color-scheme="alert"
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="replyWindowBannerMessage"
|
||||
:href-link="replyWindowLink"
|
||||
:href-link-text="replyWindowLinkText"
|
||||
/>
|
||||
<Banner
|
||||
v-else-if="hasDuplicateInstagramInbox"
|
||||
color-scheme="alert"
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
|
||||
/>
|
||||
</div>
|
||||
<MessageList
|
||||
ref="conversationPanelRef"
|
||||
class="conversation-panel flex-shrink flex-grow basis-px flex flex-col overflow-y-auto relative h-full m-0 pb-4"
|
||||
@@ -498,13 +510,7 @@ export default {
|
||||
/>
|
||||
</template>
|
||||
</MessageList>
|
||||
<div
|
||||
class="flex relative flex-col"
|
||||
:class="{
|
||||
'modal-mask': isPopOutReplyBox,
|
||||
'bg-n-surface-1': !isPopOutReplyBox,
|
||||
}"
|
||||
>
|
||||
<div class="flex relative flex-col bg-n-surface-1">
|
||||
<div
|
||||
v-if="isAnyoneTyping"
|
||||
class="absolute flex items-center w-full h-0 -top-7"
|
||||
@@ -520,42 +526,12 @@ export default {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ReplyBox
|
||||
:pop-out-reply-box="isPopOutReplyBox"
|
||||
@update:pop-out-reply-box="isPopOutReplyBox = $event"
|
||||
/>
|
||||
<ResizableEditorWrapper
|
||||
ref="resizableEditorWrapperRef"
|
||||
:container-height="Math.max(0, containerHeight - topBannerHeight)"
|
||||
>
|
||||
<ReplyBox @toggle-editor-size="toggleReplyEditorSize" />
|
||||
</ResizableEditorWrapper>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.modal-mask {
|
||||
@apply fixed;
|
||||
|
||||
&::v-deep {
|
||||
.ProseMirror-woot-style {
|
||||
@apply max-h-[25rem];
|
||||
}
|
||||
|
||||
.reply-box {
|
||||
@apply border border-n-weak max-w-[75rem] w-[70%];
|
||||
|
||||
&.is-private {
|
||||
@apply dark:border-n-amber-3/30 border-n-amber-12/5;
|
||||
}
|
||||
}
|
||||
|
||||
.reply-box .reply-box__top {
|
||||
@apply relative min-h-[27.5rem];
|
||||
}
|
||||
|
||||
.reply-box__top .input {
|
||||
@apply min-h-[27.5rem];
|
||||
}
|
||||
|
||||
.emoji-dialog {
|
||||
@apply absolute ltr:left-auto rtl:right-auto bottom-1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -81,13 +81,7 @@ export default {
|
||||
CopilotReplyBottomPanel,
|
||||
},
|
||||
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
|
||||
props: {
|
||||
popOutReplyBox: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['update:popOutReplyBox'],
|
||||
emits: ['toggleEditorSize'],
|
||||
setup() {
|
||||
const {
|
||||
uiSettings,
|
||||
@@ -797,7 +791,6 @@ export default {
|
||||
|
||||
this.clearMessage();
|
||||
this.hideEmojiPicker();
|
||||
this.$emit('update:popOutReplyBox', false);
|
||||
}
|
||||
},
|
||||
sendMessageAsMultipleMessages(message, copilotAcceptedMessage = '') {
|
||||
@@ -1217,8 +1210,9 @@ export default {
|
||||
file => !file?.isRecordedAudio
|
||||
);
|
||||
},
|
||||
togglePopout() {
|
||||
this.$emit('update:popOutReplyBox', !this.popOutReplyBox);
|
||||
toggleEditorSize() {
|
||||
this.$emit('toggleEditorSize');
|
||||
this.$nextTick(() => this.messageEditor?.focusEditorInputField());
|
||||
},
|
||||
onSubmitCopilotReply() {
|
||||
const acceptedMessage = this.copilot.accept();
|
||||
@@ -1244,9 +1238,8 @@ export default {
|
||||
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
|
||||
:characters-remaining="charactersRemaining"
|
||||
:editor-content="message"
|
||||
:popout-reply-box="popOutReplyBox"
|
||||
@set-reply-mode="setReplyMode"
|
||||
@toggle-popout="togglePopout"
|
||||
@toggle-editor-size="toggleEditorSize"
|
||||
@toggle-copilot="copilot.toggleEditor"
|
||||
@execute-copilot-action="executeCopilotAction"
|
||||
/>
|
||||
@@ -1275,7 +1268,7 @@ export default {
|
||||
v-if="showEmojiPicker"
|
||||
v-on-clickaway="hideEmojiPicker"
|
||||
:class="{
|
||||
'emoji-dialog--expanded': isOnExpandedLayout || popOutReplyBox,
|
||||
'emoji-dialog--expanded': isOnExpandedLayout,
|
||||
}"
|
||||
:on-click="addIntoEditor"
|
||||
/>
|
||||
@@ -1299,7 +1292,6 @@ export default {
|
||||
:show-copilot-editor="copilot.showEditor.value"
|
||||
:is-generating-content="copilot.isGenerating.value"
|
||||
:generated-content="copilot.generatedContent.value"
|
||||
:is-popout="popOutReplyBox"
|
||||
:placeholder="$t('CONVERSATION.FOOTER.COPILOT_MSG_INPUT')"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount, useTemplateRef } from 'vue';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
|
||||
const props = defineProps({
|
||||
containerHeight: { type: Number, default: 0 },
|
||||
});
|
||||
|
||||
const DEFAULT_HEIGHT = 120;
|
||||
const MIN_HEIGHT = 80;
|
||||
const MIN_MESSAGES_HEIGHT = 200;
|
||||
const EXPAND_RATIO = 0.5;
|
||||
const RESET_DELAY_MS = 120;
|
||||
|
||||
const wrapperRef = useTemplateRef('wrapperRef');
|
||||
const surroundingHeight = ref(0);
|
||||
const editorHeight = ref(DEFAULT_HEIGHT);
|
||||
const isResizing = ref(false);
|
||||
const startY = ref(0);
|
||||
const startHeight = ref(0);
|
||||
let resetTimeoutId = null;
|
||||
|
||||
const clamp = (val, min, max) => Math.min(Math.max(val, min), max);
|
||||
|
||||
// Measure height of elements surrounding the editor (top panel, email fields, bottom panel)
|
||||
const measureSurroundingHeight = () => {
|
||||
if (wrapperRef.value) {
|
||||
surroundingHeight.value = Math.max(
|
||||
0,
|
||||
wrapperRef.value.offsetHeight - editorHeight.value
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const isContainerReady = computed(() => props.containerHeight > 0);
|
||||
|
||||
const sizeBounds = computed(() => {
|
||||
const h = props.containerHeight;
|
||||
const s = surroundingHeight.value;
|
||||
const max = Math.max(MIN_HEIGHT, h - MIN_MESSAGES_HEIGHT - s);
|
||||
const expanded = clamp(Math.floor(h * EXPAND_RATIO - s / 2), MIN_HEIGHT, max);
|
||||
return {
|
||||
min: MIN_HEIGHT,
|
||||
max: isContainerReady.value ? max : DEFAULT_HEIGHT,
|
||||
expanded,
|
||||
default: clamp(DEFAULT_HEIGHT, MIN_HEIGHT, max),
|
||||
};
|
||||
});
|
||||
|
||||
const clampToBounds = val =>
|
||||
clamp(val, sizeBounds.value.min, sizeBounds.value.max);
|
||||
|
||||
const clearDragStyles = () => {
|
||||
Object.assign(document.body.style, { cursor: '', userSelect: '' });
|
||||
};
|
||||
|
||||
const getClientY = e => (e.touches ? e.touches[0].clientY : e.clientY);
|
||||
|
||||
const onResizeStart = event => {
|
||||
editorHeight.value = clampToBounds(editorHeight.value);
|
||||
measureSurroundingHeight();
|
||||
isResizing.value = true;
|
||||
startY.value = getClientY(event);
|
||||
startHeight.value = clampToBounds(editorHeight.value);
|
||||
editorHeight.value = startHeight.value;
|
||||
Object.assign(document.body.style, {
|
||||
cursor: 'row-resize',
|
||||
userSelect: 'none',
|
||||
});
|
||||
};
|
||||
|
||||
const onResizeMove = event => {
|
||||
if (!isResizing.value) return;
|
||||
if (event.touches) event.preventDefault();
|
||||
editorHeight.value = clampToBounds(
|
||||
startHeight.value + startY.value - getClientY(event)
|
||||
);
|
||||
};
|
||||
|
||||
const onResizeEnd = () => {
|
||||
if (!isResizing.value) return;
|
||||
isResizing.value = false;
|
||||
clearDragStyles();
|
||||
};
|
||||
|
||||
const resetEditorHeight = () => {
|
||||
editorHeight.value = sizeBounds.value.default;
|
||||
};
|
||||
|
||||
const toggleEditorExpand = () => {
|
||||
editorHeight.value = clampToBounds(editorHeight.value);
|
||||
measureSurroundingHeight();
|
||||
const { expanded, max, default: defaultHeight } = sizeBounds.value;
|
||||
const isExpanded = editorHeight.value > defaultHeight;
|
||||
// If expanded is too close to default, use max so the toggle is always noticeable
|
||||
const target = expanded - defaultHeight < 100 ? max : expanded;
|
||||
editorHeight.value = isExpanded ? defaultHeight : target;
|
||||
};
|
||||
|
||||
const handleMessageSent = () => {
|
||||
clearTimeout(resetTimeoutId);
|
||||
resetTimeoutId = setTimeout(resetEditorHeight, RESET_DELAY_MS);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
emitter.on(BUS_EVENTS.MESSAGE_SENT, handleMessageSent);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
emitter.off(BUS_EVENTS.MESSAGE_SENT, handleMessageSent);
|
||||
clearTimeout(resetTimeoutId);
|
||||
if (isResizing.value) {
|
||||
isResizing.value = false;
|
||||
clearDragStyles();
|
||||
}
|
||||
});
|
||||
|
||||
useEventListener(document, 'mousemove', onResizeMove);
|
||||
useEventListener(document, 'mouseup', onResizeEnd);
|
||||
useEventListener(document, 'touchmove', onResizeMove, { passive: false });
|
||||
useEventListener(document, 'touchend', onResizeEnd);
|
||||
useEventListener(document, 'touchcancel', onResizeEnd);
|
||||
useEventListener(window, 'blur', onResizeEnd);
|
||||
|
||||
defineExpose({ toggleEditorExpand, resetEditorHeight });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="wrapperRef"
|
||||
class="relative resizable-editor-wrapper"
|
||||
:style="{
|
||||
'--editor-height': editorHeight + 'px',
|
||||
'--editor-min-allowed': sizeBounds.min + 'px',
|
||||
'--editor-max-allowed': sizeBounds.max + 'px',
|
||||
'--editor-height-transition': isResizing ? 'none' : '180ms ease',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="group absolute inset-x-0 -top-4 z-10 flex h-4 cursor-row-resize select-none items-center justify-center bg-gradient-to-b from-transparent from-10% dark:to-n-surface-1/80 to-n-surface-1/90 backdrop-blur-[0.01875rem]"
|
||||
@mousedown="onResizeStart"
|
||||
@touchstart.prevent="onResizeStart"
|
||||
@dblclick="resetEditorHeight"
|
||||
>
|
||||
<div
|
||||
class="w-8 h-0.5 mt-1 rounded-full bg-n-slate-6 group-hover:bg-n-slate-8 transition-all duration-200 motion-safe:group-hover:animate-bounce"
|
||||
:class="{ 'bg-n-slate-8 animate-bounce': isResizing }"
|
||||
/>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
+1
-2
@@ -66,8 +66,7 @@ const selectionModel = computed({
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldSelectAll =
|
||||
newSet.size === props.visibleContactIds.length && newSet.size > 0;
|
||||
const shouldSelectAll = props.visibleContactIds.every(id => newSet.has(id));
|
||||
emit('toggleAll', shouldSelectAll);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -151,7 +151,13 @@ const openBulkDeleteDialog = () => {
|
||||
};
|
||||
|
||||
const toggleSelectAll = shouldSelect => {
|
||||
selectedContactIds.value = shouldSelect ? [...visibleContactIds.value] : [];
|
||||
const currentSelection = new Set(selectedContactIds.value);
|
||||
if (shouldSelect) {
|
||||
visibleContactIds.value.forEach(id => currentSelection.add(id));
|
||||
} else {
|
||||
visibleContactIds.value.forEach(id => currentSelection.delete(id));
|
||||
}
|
||||
selectedContactIds.value = Array.from(currentSelection);
|
||||
};
|
||||
|
||||
const toggleContactSelection = ({ id, value }) => {
|
||||
@@ -190,16 +196,28 @@ const getCommonFetchParams = (page = 1) => ({
|
||||
label: activeLabel.value,
|
||||
});
|
||||
|
||||
const fetchContacts = async (page = 1) => {
|
||||
clearSelection();
|
||||
const fetchContacts = async (page = 1, options = {}) => {
|
||||
const { clearSelection: shouldClearSelection = true } = options;
|
||||
if (shouldClearSelection) {
|
||||
clearSelection();
|
||||
}
|
||||
await store.dispatch('contacts/clearContactFilters');
|
||||
await store.dispatch('contacts/get', getCommonFetchParams(page));
|
||||
updatePageParam(page);
|
||||
};
|
||||
|
||||
const fetchSavedOrAppliedFilteredContact = async (payload, page = 1) => {
|
||||
const fetchSavedOrAppliedFilteredContact = async (
|
||||
payload,
|
||||
page = 1,
|
||||
options = {}
|
||||
) => {
|
||||
if (!activeSegmentId.value && !hasAppliedFilters.value) return;
|
||||
clearSelection();
|
||||
|
||||
const { clearSelection: shouldClearSelection = true } = options;
|
||||
if (shouldClearSelection) {
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
await store.dispatch('contacts/filter', {
|
||||
...getCommonFetchParams(page),
|
||||
queryPayload: payload,
|
||||
@@ -207,8 +225,12 @@ const fetchSavedOrAppliedFilteredContact = async (payload, page = 1) => {
|
||||
updatePageParam(page);
|
||||
};
|
||||
|
||||
const fetchActiveContacts = async (page = 1) => {
|
||||
clearSelection();
|
||||
const fetchActiveContacts = async (page = 1, options = {}) => {
|
||||
const { clearSelection: shouldClearSelection = true } = options;
|
||||
if (shouldClearSelection) {
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
await store.dispatch('contacts/clearContactFilters');
|
||||
await store.dispatch('contacts/active', {
|
||||
page,
|
||||
@@ -217,28 +239,36 @@ const fetchActiveContacts = async (page = 1) => {
|
||||
updatePageParam(page);
|
||||
};
|
||||
|
||||
const searchContacts = debounce(async (value, page = 1, append = false) => {
|
||||
if (!append) {
|
||||
clearSelection();
|
||||
searchPageNumber.value = 1;
|
||||
}
|
||||
await store.dispatch('contacts/clearContactFilters');
|
||||
searchValue.value = value;
|
||||
const searchContacts = debounce(
|
||||
async (value, page = 1, append = false, options = {}) => {
|
||||
const { clearSelection: shouldClearSelection = true } = options;
|
||||
|
||||
if (!value) {
|
||||
updatePageParam(page);
|
||||
await fetchContacts(page);
|
||||
return;
|
||||
}
|
||||
if (!append) {
|
||||
searchPageNumber.value = 1;
|
||||
|
||||
updatePageParam(page, value);
|
||||
await store.dispatch('contacts/search', {
|
||||
...getCommonFetchParams(page),
|
||||
search: encodeURIComponent(value),
|
||||
append,
|
||||
});
|
||||
searchPageNumber.value = page;
|
||||
}, DEBOUNCE_DELAY);
|
||||
if (shouldClearSelection) {
|
||||
clearSelection();
|
||||
}
|
||||
}
|
||||
await store.dispatch('contacts/clearContactFilters');
|
||||
searchValue.value = value;
|
||||
|
||||
if (!value) {
|
||||
updatePageParam(page);
|
||||
await fetchContacts(page, { clearSelection: false });
|
||||
return;
|
||||
}
|
||||
|
||||
updatePageParam(page, value);
|
||||
await store.dispatch('contacts/search', {
|
||||
...getCommonFetchParams(page),
|
||||
search: encodeURIComponent(value),
|
||||
append,
|
||||
});
|
||||
searchPageNumber.value = page;
|
||||
},
|
||||
DEBOUNCE_DELAY
|
||||
);
|
||||
|
||||
const loadMoreSearchResults = async () => {
|
||||
if (!hasMore.value || isLoadingMore.value) return;
|
||||
@@ -256,19 +286,26 @@ const loadMoreSearchResults = async () => {
|
||||
isLoadingMore.value = false;
|
||||
};
|
||||
|
||||
const fetchContactsBasedOnContext = async page => {
|
||||
clearSelection();
|
||||
const fetchContactsBasedOnContext = async (page, options = {}) => {
|
||||
const { clearSelection: shouldClearSelection = true } = options;
|
||||
if (shouldClearSelection) {
|
||||
clearSelection();
|
||||
}
|
||||
updatePageParam(page, searchValue.value);
|
||||
if (isFetchingList.value) return;
|
||||
if (searchQuery.value) {
|
||||
await searchContacts(searchQuery.value, page);
|
||||
await searchContacts(searchQuery.value, page, false, {
|
||||
clearSelection: shouldClearSelection,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Reset the search value when we change the view
|
||||
searchValue.value = '';
|
||||
// If we're on the active route, fetch active contacts
|
||||
if (isActiveView.value) {
|
||||
await fetchActiveContacts(page);
|
||||
await fetchActiveContacts(page, {
|
||||
clearSelection: shouldClearSelection,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// If there are applied filters or active segment with query
|
||||
@@ -278,13 +315,20 @@ const fetchContactsBasedOnContext = async page => {
|
||||
) {
|
||||
const queryPayload =
|
||||
activeSegment.value?.query || filterQueryGenerator(appliedFilters.value);
|
||||
await fetchSavedOrAppliedFilteredContact(queryPayload, page);
|
||||
await fetchSavedOrAppliedFilteredContact(queryPayload, page, {
|
||||
clearSelection: shouldClearSelection,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Default case: fetch regular contacts + label
|
||||
await fetchContacts(page);
|
||||
await fetchContacts(page, {
|
||||
clearSelection: shouldClearSelection,
|
||||
});
|
||||
};
|
||||
|
||||
const onPageChange = page =>
|
||||
fetchContactsBasedOnContext(page, { clearSelection: false });
|
||||
|
||||
const assignLabels = async labels => {
|
||||
if (!labels.length || !selectedContactIds.value.length) {
|
||||
return;
|
||||
@@ -338,7 +382,9 @@ const handleSort = async ({ sort, order }) => {
|
||||
});
|
||||
|
||||
if (searchQuery.value) {
|
||||
await searchContacts(searchValue.value);
|
||||
await searchContacts(searchValue.value, pageNumber.value, false, {
|
||||
clearSelection: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -360,17 +406,6 @@ const createContact = async contact => {
|
||||
await store.dispatch('contacts/create', contact);
|
||||
};
|
||||
|
||||
watch(
|
||||
contacts,
|
||||
newContacts => {
|
||||
const idsOnPage = newContacts.map(contact => contact.id);
|
||||
selectedContactIds.value = selectedContactIds.value.filter(id =>
|
||||
idsOnPage.includes(id)
|
||||
);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
watch(hasSelection, value => {
|
||||
if (!value) {
|
||||
bulkDeleteDialogRef.value?.close?.();
|
||||
@@ -416,7 +451,9 @@ watch(searchQuery, value => {
|
||||
onMounted(async () => {
|
||||
if (!activeSegmentId.value) {
|
||||
if (searchQuery.value) {
|
||||
await searchContacts(searchQuery.value, pageNumber.value);
|
||||
await searchContacts(searchQuery.value, pageNumber.value, false, {
|
||||
clearSelection: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isActiveView.value) {
|
||||
@@ -452,8 +489,10 @@ onMounted(async () => {
|
||||
:use-infinite-scroll="isSearchView"
|
||||
:has-more="hasMore"
|
||||
:is-loading-more="isLoadingMore"
|
||||
@update:current-page="fetchContactsBasedOnContext"
|
||||
@search="searchContacts"
|
||||
@update:current-page="onPageChange"
|
||||
@search="
|
||||
value => searchContacts(value, 1, false, { clearSelection: false })
|
||||
"
|
||||
@update:sort="handleSort"
|
||||
@apply-filter="fetchSavedOrAppliedFilteredContact"
|
||||
@clear-filters="fetchContacts"
|
||||
@@ -485,6 +524,7 @@ onMounted(async () => {
|
||||
:button-label="t('CONTACTS_LAYOUT.EMPTY_STATE.BUTTON_LABEL')"
|
||||
@create="createContact"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else-if="showEmptyText"
|
||||
class="flex items-center justify-center py-10"
|
||||
@@ -493,6 +533,7 @@ onMounted(async () => {
|
||||
{{ emptyStateMessage }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-4 pt-4 pb-6">
|
||||
<ContactsList
|
||||
:contacts="contacts"
|
||||
|
||||
Reference in New Issue
Block a user