Merge branch 'develop' into feat/captain-audience-schedule
This commit is contained in:
+7
-1
@@ -16,6 +16,10 @@ const props = defineProps({
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
contact: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -49,7 +53,9 @@ const unreadMessagesCount = computed(() => {
|
||||
|
||||
const hasSlaThreshold = computed(() => {
|
||||
return (
|
||||
slaCardLabelRef.value?.hasSlaThreshold && props.conversation?.slaPolicyId
|
||||
!props.contact?.blocked &&
|
||||
slaCardLabelRef.value?.hasSlaThreshold &&
|
||||
props.conversation?.appliedSla?.id
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+1
@@ -126,6 +126,7 @@ const onCardClick = e => {
|
||||
v-show="!showMessagePreviewWithoutMeta"
|
||||
ref="cardMessagePreviewWithMetaRef"
|
||||
:conversation="conversation"
|
||||
:contact="contact"
|
||||
:account-labels="accountLabels"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+3
-1
@@ -51,7 +51,9 @@ const unreadCount = computed(() => props.chat.unread_count);
|
||||
const slaCardLabel = useTemplateRef('slaCardLabel');
|
||||
|
||||
const hasSlaPolicyId = computed(
|
||||
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
|
||||
() =>
|
||||
!props.currentContact?.blocked &&
|
||||
(props.chat?.applied_sla?.id || slaCardLabel.value?.hasSlaThreshold)
|
||||
);
|
||||
|
||||
const selectedModel = computed({
|
||||
|
||||
+4
-11
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { evaluateSLAStatus } from '@chatwoot/utils';
|
||||
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
|
||||
|
||||
const props = defineProps({
|
||||
conversation: {
|
||||
@@ -19,16 +19,8 @@ const slaStatus = ref({
|
||||
icon: null,
|
||||
});
|
||||
|
||||
// TODO: Remove this once we update the helper from utils
|
||||
// https://github.com/chatwoot/utils/blob/main/src/sla.ts#L73
|
||||
const convertObjectCamelCaseToSnakeCase = object => {
|
||||
return Object.keys(object).reduce((acc, key) => {
|
||||
acc[key.replace(/([A-Z])/g, '_$1').toLowerCase()] = object[key];
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const appliedSLA = computed(() => props.conversation?.appliedSla);
|
||||
const slaEvents = computed(() => props.conversation?.slaEvents);
|
||||
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
|
||||
|
||||
const hasSlaThreshold = computed(() => {
|
||||
@@ -41,8 +33,9 @@ const slaStatusText = computed(() => {
|
||||
|
||||
const updateSlaStatus = () => {
|
||||
slaStatus.value = evaluateSLAStatus({
|
||||
appliedSla: convertObjectCamelCaseToSnakeCase(appliedSLA.value || {}),
|
||||
appliedSla: appliedSLA.value || {},
|
||||
chat: props.conversation,
|
||||
slaEvents: slaEvents.value || [],
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { evaluateSLAStatus } from '@chatwoot/utils';
|
||||
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Label from 'dashboard/components-next/label/Label.vue';
|
||||
@@ -27,6 +27,7 @@ defineOptions({
|
||||
});
|
||||
|
||||
const appliedSLA = computed(() => props.chat?.applied_sla);
|
||||
const slaEvents = computed(() => props.chat?.sla_events);
|
||||
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
|
||||
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
|
||||
|
||||
@@ -34,6 +35,7 @@ const updateSlaStatus = () => {
|
||||
slaStatus.value = evaluateSLAStatus({
|
||||
appliedSla: appliedSLA.value || {},
|
||||
chat: props.chat,
|
||||
slaEvents: slaEvents.value || [],
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+378
@@ -0,0 +1,378 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch, nextTick, onBeforeUnmount } from 'vue';
|
||||
import { useEventListener, onKeyStroke, useRafFn } from '@vueuse/core';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
// Reorderable list with cross-page drag. It must stay mounted during a page
|
||||
// fetch — hide the list spinner while `dragging`, or the held item is lost.
|
||||
const props = defineProps({
|
||||
items: { type: Array, required: true },
|
||||
itemKey: { type: String, default: 'id' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
currentPage: { type: Number, default: 1 },
|
||||
totalPages: { type: Number, default: 1 },
|
||||
});
|
||||
const emit = defineEmits(['reorder', 'navigatePage', 'dragging']);
|
||||
|
||||
const DRAG_THRESHOLD = 5;
|
||||
const EDGE_BAND = 72; // px from a side that arms a page turn
|
||||
const AUTO_PAGE_DELAY = 600; // ms hovering an edge before it flips
|
||||
const SCROLL_BAND = 60; // px from top/bottom that autoscrolls the list
|
||||
const SCROLL_STEP = 10; // px scrolled per frame while parked at an edge
|
||||
const PILL = 36; // px, the page-turn arrow button on each edge
|
||||
|
||||
const root = ref(null);
|
||||
const isDragging = ref(false);
|
||||
const dragged = ref(null);
|
||||
const pointer = ref({ x: 0, y: 0 });
|
||||
const insertBefore = ref(null); // key the gap sits before, null = end of list
|
||||
const activeEdge = ref(null);
|
||||
const bounds = ref({ left: 0, right: 0, top: 0, bottom: 0 }); // visible list rect
|
||||
const grab = ref({ dx: 0, dy: 0 }); // cursor offset inside the grabbed card
|
||||
const dragHeight = ref(0);
|
||||
|
||||
let press = null; // pending press, before it becomes a drag
|
||||
let dwell = null; // timer that flips the page after hovering an edge
|
||||
let paging = false; // waiting for a flipped page to load
|
||||
let scroller = null; // scrollable ancestor, found when a drag begins
|
||||
let scrollDir = 0; // -1 up, +1 down, 0 idle
|
||||
let sourcePage = 1; // page the drag started on, to resolve cross-page end-drops
|
||||
|
||||
const keyOf = item => String(item?.[props.itemKey]);
|
||||
const others = item => props.items.filter(o => keyOf(o) !== keyOf(item));
|
||||
const isRTL = () =>
|
||||
document.querySelector('#app[dir]')?.getAttribute('dir') === 'rtl';
|
||||
const canPage = dir =>
|
||||
dir === 'next' ? props.currentPage < props.totalPages : props.currentPage > 1;
|
||||
|
||||
// Key of the item after `key` in `list`, or null when it is the last one.
|
||||
const keyAfter = (list, key) => {
|
||||
const next = list[list.findIndex(o => keyOf(o) === key) + 1];
|
||||
return next ? keyOf(next) : null;
|
||||
};
|
||||
|
||||
const edges = computed(() =>
|
||||
['prev', 'next'].filter(canPage).map(dir => {
|
||||
const onLeft = isRTL() ? dir === 'next' : dir === 'prev';
|
||||
return {
|
||||
dir,
|
||||
onLeft,
|
||||
icon: onLeft ? 'i-lucide-chevrons-left' : 'i-lucide-chevrons-right',
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Cursor overlay: an edge glow band, a page-turn arrow per edge, and a ghost of
|
||||
// the dragged row that follows the cursor and shrinks when aimed at an edge.
|
||||
const EDGE_BAND_W = 80; // px width of the glow band on each pageable edge
|
||||
const bandStyle = onLeft => {
|
||||
const x = onLeft ? bounds.value.left : bounds.value.right - EDGE_BAND_W;
|
||||
return {
|
||||
width: `${EDGE_BAND_W}px`,
|
||||
height: `${bounds.value.bottom - bounds.value.top}px`,
|
||||
transform: `translate(${x}px, ${bounds.value.top}px)`,
|
||||
};
|
||||
};
|
||||
const pillStyle = onLeft => {
|
||||
const x = onLeft ? bounds.value.left + 12 : bounds.value.right - PILL - 12;
|
||||
return { transform: `translate(${x}px, ${pointer.value.y - PILL / 2}px)` };
|
||||
};
|
||||
const ghostStyle = computed(() => ({
|
||||
width: `${bounds.value.right - bounds.value.left}px`,
|
||||
transform: `translate(${pointer.value.x - grab.value.dx}px, ${pointer.value.y - grab.value.dy}px)`,
|
||||
}));
|
||||
const scaleStyle = computed(() => ({
|
||||
transformOrigin: `${grab.value.dx}px ${grab.value.dy}px`,
|
||||
}));
|
||||
|
||||
// The list as shown mid-drag: the dragged row slotted into the gap at `insertBefore`.
|
||||
const displayItems = computed(() => {
|
||||
if (!isDragging.value || !dragged.value) return props.items;
|
||||
const rest = others(dragged.value);
|
||||
const at = rest.findIndex(o => keyOf(o) === insertBefore.value);
|
||||
rest.splice(at === -1 ? rest.length : at, 0, dragged.value);
|
||||
return rest;
|
||||
});
|
||||
|
||||
// Move the gap when the cursor crosses the midpoint of the card it is over.
|
||||
const aim = () => {
|
||||
const card = document
|
||||
.elementFromPoint(pointer.value.x, pointer.value.y)
|
||||
?.closest('[data-drag-id]');
|
||||
const key = card?.dataset.dragId;
|
||||
if (!key || key === keyOf(dragged.value)) return;
|
||||
|
||||
const { top, height } = card.getBoundingClientRect();
|
||||
const above = pointer.value.y < top + height / 2;
|
||||
insertBefore.value = above ? key : keyAfter(others(dragged.value), key);
|
||||
};
|
||||
|
||||
const flip = dir => {
|
||||
if (paging || !isDragging.value) return;
|
||||
paging = true;
|
||||
emit('navigatePage', props.currentPage + (dir === 'next' ? 1 : -1));
|
||||
};
|
||||
|
||||
const aimEdge = x => {
|
||||
const rect = root.value?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
// Clip the list rect to the scroll viewport so the glow band and its top/bottom
|
||||
// fade always sit on the visible edges, not the far ends of the full content.
|
||||
const view = scroller?.getBoundingClientRect();
|
||||
const viewTop = Math.max(view?.top ?? 0, 0);
|
||||
const viewBottom = Math.min(
|
||||
view?.bottom ?? window.innerHeight,
|
||||
window.innerHeight
|
||||
);
|
||||
bounds.value = {
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
top: Math.max(rect.top, viewTop),
|
||||
bottom: Math.min(rect.bottom, viewBottom),
|
||||
};
|
||||
|
||||
let dir = null;
|
||||
if (x <= rect.left + EDGE_BAND) dir = isRTL() ? 'next' : 'prev';
|
||||
else if (x >= rect.right - EDGE_BAND) dir = isRTL() ? 'prev' : 'next';
|
||||
if (dir && !canPage(dir)) dir = null;
|
||||
if (dir === activeEdge.value) return;
|
||||
|
||||
activeEdge.value = dir;
|
||||
clearTimeout(dwell);
|
||||
if (dir) dwell = setTimeout(() => flip(dir), AUTO_PAGE_DELAY);
|
||||
};
|
||||
|
||||
// Nearest scrollable ancestor, so a drag can reach rows that are off-screen.
|
||||
const scrollParent = () => {
|
||||
let el = root.value?.parentElement;
|
||||
while (el) {
|
||||
const { overflowY } = getComputedStyle(el);
|
||||
if (overflowY === 'auto' || overflowY === 'scroll') return el;
|
||||
el = el.parentElement;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// While parked at the top/bottom edge, keep scrolling and re-aim as rows slide by.
|
||||
const { pause: pauseScroll, resume: resumeScroll } = useRafFn(
|
||||
() => {
|
||||
if (!scroller || !scrollDir) return;
|
||||
scroller.scrollTop += scrollDir * SCROLL_STEP;
|
||||
aim();
|
||||
aimEdge(pointer.value.x);
|
||||
},
|
||||
{ immediate: false }
|
||||
);
|
||||
|
||||
const updateAutoScroll = y => {
|
||||
if (!scroller) return;
|
||||
const rect = scroller.getBoundingClientRect();
|
||||
const atTop = scroller.scrollTop <= 0;
|
||||
const atBottom =
|
||||
scroller.scrollTop >= scroller.scrollHeight - scroller.clientHeight;
|
||||
if (y < rect.top + SCROLL_BAND && !atTop) scrollDir = -1;
|
||||
else if (y > rect.bottom - SCROLL_BAND && !atBottom) scrollDir = 1;
|
||||
else scrollDir = 0;
|
||||
if (scrollDir) resumeScroll();
|
||||
else pauseScroll();
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
isDragging.value = false;
|
||||
dragged.value = null;
|
||||
insertBefore.value = null;
|
||||
activeEdge.value = null;
|
||||
clearTimeout(dwell);
|
||||
paging = false;
|
||||
scrollDir = 0;
|
||||
scroller = null;
|
||||
pauseScroll();
|
||||
document.body.classList.remove('select-none');
|
||||
emit('dragging', false);
|
||||
};
|
||||
|
||||
// Positions sit on a gap-of-10 grid; a midpoint (±5 at the ends) slots between two rows.
|
||||
// Cross-page end-drops are the tricky case: moving an item off its source page pulls the
|
||||
// target page's boundary row into the vacated slot. So at the leading edge after moving
|
||||
// DOWN (or the trailing edge after moving UP) we land between the two boundary rows, or
|
||||
// the item sorts onto the adjacent page and vanishes from view.
|
||||
const drop = (item, rawBefore) => {
|
||||
const pos = o => o?.position || 0;
|
||||
const mid = (a, b) => Math.floor((pos(a) + pos(b)) / 2);
|
||||
const rest = others(item);
|
||||
// No other rows to position against — e.g. the lone article on a page dropped
|
||||
// without crossing to another page. Leave the order untouched.
|
||||
if (!rest.length) return;
|
||||
// A page flip can leave the aimed key pointing at a row that is no longer on
|
||||
// this page. Like displayItems, resolve an unknown key to null (end of list),
|
||||
// so what the user sees and what we save agree.
|
||||
const before = rest.some(o => keyOf(o) === rawBefore) ? rawBefore : null;
|
||||
const movedDown = props.currentPage > sourcePage;
|
||||
const movedUp = props.currentPage < sourcePage;
|
||||
|
||||
let position;
|
||||
if (before === null) {
|
||||
position =
|
||||
movedUp && rest.length >= 2
|
||||
? mid(rest.at(-2), rest.at(-1))
|
||||
: pos(rest.at(-1)) + 5;
|
||||
} else {
|
||||
const i = rest.findIndex(o => keyOf(o) === before);
|
||||
if (i > 0) {
|
||||
position = mid(rest[i - 1], rest[i]);
|
||||
} else if (movedDown) {
|
||||
// Top-of-page after moving down: the old first row slid up into the source
|
||||
// page's gap, so land just after it to stay this page's first.
|
||||
position = rest.length >= 2 ? mid(rest[0], rest[1]) : pos(rest[0]) + 5;
|
||||
} else {
|
||||
position = pos(rest[0]) - 5;
|
||||
}
|
||||
}
|
||||
emit('reorder', { [item[props.itemKey]]: position });
|
||||
};
|
||||
|
||||
const startDrag = () => {
|
||||
isDragging.value = true;
|
||||
dragged.value = press.item;
|
||||
grab.value = { dx: press.dx, dy: press.dy };
|
||||
dragHeight.value = press.h;
|
||||
insertBefore.value = keyAfter(props.items, keyOf(press.item));
|
||||
sourcePage = props.currentPage;
|
||||
scroller = scrollParent();
|
||||
document.body.classList.add('select-none');
|
||||
emit('dragging', true);
|
||||
};
|
||||
|
||||
const onPointerDown = (item, e) => {
|
||||
if (e.button !== 0 || props.disabled) return;
|
||||
if (e.target.closest('button, a, input, [role="button"]')) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
press = {
|
||||
item,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
dx: e.clientX - rect.left,
|
||||
dy: e.clientY - rect.top,
|
||||
h: rect.height,
|
||||
};
|
||||
};
|
||||
|
||||
useEventListener(window, 'pointermove', e => {
|
||||
if (!press) return;
|
||||
if (!isDragging.value) {
|
||||
const moved = Math.hypot(e.clientX - press.x, e.clientY - press.y);
|
||||
if (moved < DRAG_THRESHOLD) return;
|
||||
startDrag();
|
||||
}
|
||||
e.preventDefault();
|
||||
pointer.value = { x: e.clientX, y: e.clientY };
|
||||
aim();
|
||||
aimEdge(e.clientX);
|
||||
updateAutoScroll(e.clientY);
|
||||
});
|
||||
|
||||
useEventListener(window, 'pointerup', () => {
|
||||
if (!press) return;
|
||||
press = null;
|
||||
if (!isDragging.value) return; // a press without movement is a click
|
||||
const item = dragged.value;
|
||||
const before = insertBefore.value;
|
||||
const flipping = paging;
|
||||
reset();
|
||||
if (!flipping) drop(item, before);
|
||||
});
|
||||
|
||||
// When the flipped page loads, re-aim under the held cursor so a parked edge keeps flipping.
|
||||
watch(
|
||||
() => props.items,
|
||||
() => {
|
||||
if (!isDragging.value || !paging) return;
|
||||
paging = false;
|
||||
nextTick(() => {
|
||||
if (!isDragging.value) return;
|
||||
aim();
|
||||
activeEdge.value = null;
|
||||
aimEdge(pointer.value.x);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
onKeyStroke('Escape', () => {
|
||||
if (!isDragging.value) return;
|
||||
press = null;
|
||||
reset();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimeout(dwell);
|
||||
document.body.classList.remove('select-none');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="relative w-full h-full">
|
||||
<ul class="w-full h-full space-y-4">
|
||||
<li
|
||||
v-for="(item, index) in displayItems"
|
||||
:key="keyOf(item)"
|
||||
:data-drag-id="keyOf(item)"
|
||||
class="relative list-none"
|
||||
:class="{ 'cursor-grab': !disabled && !isDragging }"
|
||||
@pointerdown="onPointerDown(item, $event)"
|
||||
@dragstart.prevent
|
||||
>
|
||||
<div
|
||||
v-if="isDragging && keyOf(item) === keyOf(dragged)"
|
||||
:style="{ height: `${dragHeight}px` }"
|
||||
class="border-2 border-dashed rounded-2xl border-n-brand/50 bg-n-brand/5"
|
||||
/>
|
||||
<slot v-else name="item" :item="item" :index="index" />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Teleport v-if="isDragging" to="body">
|
||||
<div
|
||||
v-for="edge in edges"
|
||||
:key="`band-${edge.dir}`"
|
||||
:style="bandStyle(edge.onLeft)"
|
||||
class="fixed top-0 left-0 z-40 pointer-events-none from-n-brand/15 to-transparent transition-opacity duration-200 [mask-image:linear-gradient(to_bottom,transparent,#000_56px,#000_calc(100%_-_56px),transparent)] [-webkit-mask-image:linear-gradient(to_bottom,transparent,#000_56px,#000_calc(100%_-_56px),transparent)]"
|
||||
:class="[
|
||||
edge.onLeft ? 'bg-gradient-to-r' : 'bg-gradient-to-l',
|
||||
activeEdge === edge.dir ? 'opacity-100' : 'opacity-0',
|
||||
]"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-for="edge in edges"
|
||||
:key="edge.dir"
|
||||
:style="pillStyle(edge.onLeft)"
|
||||
class="fixed top-0 left-0 z-50 flex items-center justify-center transition-all duration-150 border rounded-full pointer-events-none size-9 backdrop-blur-sm"
|
||||
:class="
|
||||
activeEdge === edge.dir
|
||||
? 'scale-110 border-n-brand bg-n-brand/20 text-n-brand shadow-md'
|
||||
: 'opacity-70 border-n-weak/60 bg-n-solid-1/70 text-n-slate-10'
|
||||
"
|
||||
>
|
||||
<Icon
|
||||
:icon="edge.icon"
|
||||
class="size-4"
|
||||
:class="activeEdge === edge.dir && 'animate-pulse'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="dragged"
|
||||
:style="ghostStyle"
|
||||
class="fixed top-0 left-0 z-50 pointer-events-none select-none"
|
||||
>
|
||||
<div
|
||||
:style="scaleStyle"
|
||||
class="transition-transform duration-150 shadow-2xl rounded-2xl"
|
||||
:class="{ 'scale-50': activeEdge }"
|
||||
>
|
||||
<slot name="ghost" :item="dragged" />
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { h, nextTick } from 'vue';
|
||||
import DraggableReorderList from '../DraggableReorderList.vue';
|
||||
|
||||
// The component is pointer-driven, so we drive it through real pointer events on
|
||||
// window while mocking the layout APIs jsdom does not implement: elementFromPoint
|
||||
// (which card is under the cursor) and getBoundingClientRect (its geometry).
|
||||
const elementAtPoint = { current: null };
|
||||
|
||||
const move = (clientX, clientY) =>
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX, clientY }));
|
||||
const release = () => window.dispatchEvent(new MouseEvent('pointerup'));
|
||||
|
||||
// Stack the rows 50px apart, each 40px tall, inside a 500px-wide list.
|
||||
const stubGeometry = wrapper => {
|
||||
wrapper.element.getBoundingClientRect = () => ({
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
bottom: 600,
|
||||
});
|
||||
wrapper.findAll('[data-drag-id]').forEach((li, index) => {
|
||||
const top = index * 50;
|
||||
li.element.getBoundingClientRect = () => ({
|
||||
top,
|
||||
height: 40,
|
||||
bottom: top + 40,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const mountList = (props = {}) =>
|
||||
mount(DraggableReorderList, {
|
||||
props: { items: [], ...props },
|
||||
slots: {
|
||||
item: scope => h('div', { class: 'card' }, scope.item.title),
|
||||
ghost: scope => h('div', { class: 'ghost' }, scope.item.title),
|
||||
},
|
||||
global: { stubs: { Icon: true, teleport: true } },
|
||||
});
|
||||
|
||||
describe('DraggableReorderList', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
elementAtPoint.current = null;
|
||||
document.elementFromPoint = vi.fn(() => elementAtPoint.current);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const startDragging = async id => {
|
||||
stubGeometry(wrapper);
|
||||
wrapper.find(`[data-drag-id="${id}"]`).element.dispatchEvent(
|
||||
new MouseEvent('pointerdown', {
|
||||
button: 0,
|
||||
clientX: 250,
|
||||
clientY: 20,
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
await nextTick();
|
||||
};
|
||||
|
||||
it('renders each item through the item slot', () => {
|
||||
wrapper = mountList({
|
||||
items: [
|
||||
{ id: 1, title: 'Alpha' },
|
||||
{ id: 2, title: 'Beta' },
|
||||
],
|
||||
});
|
||||
|
||||
const cards = wrapper.findAll('.card');
|
||||
expect(cards).toHaveLength(2);
|
||||
expect(cards[0].text()).toBe('Alpha');
|
||||
expect(wrapper.find('[data-drag-id="1"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-drag-id="2"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('shows a grab affordance only when enabled', () => {
|
||||
wrapper = mountList({ items: [{ id: 1, title: 'Alpha' }] });
|
||||
expect(wrapper.find('[data-drag-id="1"]').classes()).toContain(
|
||||
'cursor-grab'
|
||||
);
|
||||
|
||||
wrapper.unmount();
|
||||
wrapper = mountList({ items: [{ id: 1, title: 'Alpha' }], disabled: true });
|
||||
expect(wrapper.find('[data-drag-id="1"]').classes()).not.toContain(
|
||||
'cursor-grab'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not start a drag when disabled', async () => {
|
||||
wrapper = mountList({
|
||||
items: [
|
||||
{ id: 1, title: 'Alpha' },
|
||||
{ id: 2, title: 'Beta' },
|
||||
],
|
||||
disabled: true,
|
||||
});
|
||||
await startDragging(1);
|
||||
move(250, 200);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.emitted('dragging')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('emits dragging true then false across a drag', async () => {
|
||||
wrapper = mountList({
|
||||
items: [
|
||||
{ id: 1, title: 'Alpha' },
|
||||
{ id: 2, title: 'Beta' },
|
||||
],
|
||||
});
|
||||
await startDragging(1);
|
||||
elementAtPoint.current = wrapper.find('[data-drag-id="2"]').element;
|
||||
move(250, 60);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.emitted('dragging')[0]).toEqual([true]);
|
||||
|
||||
release();
|
||||
await nextTick();
|
||||
expect(wrapper.emitted('dragging')[1]).toEqual([false]);
|
||||
});
|
||||
|
||||
it('emits the midpoint position when dropped between two rows', async () => {
|
||||
wrapper = mountList({
|
||||
items: [
|
||||
{ id: 1, title: 'Alpha', position: 10 },
|
||||
{ id: 2, title: 'Beta', position: 20 },
|
||||
{ id: 3, title: 'Gamma', position: 30 },
|
||||
],
|
||||
});
|
||||
await startDragging(1);
|
||||
|
||||
// Hover the lower half of Beta (top 50, height 40 → midpoint 70) so the gap
|
||||
// sits before Gamma; dropping there lands halfway between Beta and Gamma.
|
||||
elementAtPoint.current = wrapper.find('[data-drag-id="2"]').element;
|
||||
move(250, 85);
|
||||
await nextTick();
|
||||
release();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.emitted('reorder')[0][0]).toEqual({ 1: 25 });
|
||||
});
|
||||
|
||||
it('does not reorder when the only row on a page is dropped in place', async () => {
|
||||
// P1: dragging the lone article on a later page and releasing without
|
||||
// crossing to another page must be a no-op, not move it to the top.
|
||||
wrapper = mountList({
|
||||
items: [{ id: 5, title: 'Solo', position: 260 }],
|
||||
currentPage: 2,
|
||||
totalPages: 2,
|
||||
});
|
||||
await startDragging(5);
|
||||
move(250, 300);
|
||||
await nextTick();
|
||||
release();
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.emitted('dragging')).toEqual([[true], [false]]);
|
||||
expect(wrapper.emitted('reorder')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('turns the page after dwelling on a pageable edge', async () => {
|
||||
vi.useFakeTimers();
|
||||
wrapper = mountList({
|
||||
items: [
|
||||
{ id: 1, title: 'Alpha', position: 10 },
|
||||
{ id: 2, title: 'Beta', position: 20 },
|
||||
],
|
||||
currentPage: 1,
|
||||
totalPages: 2,
|
||||
});
|
||||
await startDragging(1);
|
||||
|
||||
// Drag to the right edge over blank space (no card) and hold.
|
||||
elementAtPoint.current = null;
|
||||
move(490, 20);
|
||||
await nextTick();
|
||||
vi.advanceTimersByTime(600);
|
||||
|
||||
expect(wrapper.emitted('navigatePage')[0]).toEqual([2]);
|
||||
});
|
||||
|
||||
it('can still turn pages after releasing during a pending flip', async () => {
|
||||
// Releasing while a flip fetch is in flight must clear paging state, or every
|
||||
// later drag would be stuck unable to navigate.
|
||||
vi.useFakeTimers();
|
||||
wrapper = mountList({
|
||||
items: [
|
||||
{ id: 1, title: 'Alpha', position: 10 },
|
||||
{ id: 2, title: 'Beta', position: 20 },
|
||||
],
|
||||
currentPage: 1,
|
||||
totalPages: 2,
|
||||
});
|
||||
|
||||
// First drag: park at the edge to start a flip, then release before the new
|
||||
// page arrives (items never change here).
|
||||
await startDragging(1);
|
||||
elementAtPoint.current = null;
|
||||
move(490, 20);
|
||||
await nextTick();
|
||||
vi.advanceTimersByTime(600);
|
||||
release();
|
||||
await nextTick();
|
||||
|
||||
// Second drag must be able to flip again.
|
||||
await startDragging(1);
|
||||
elementAtPoint.current = null;
|
||||
move(490, 20);
|
||||
await nextTick();
|
||||
vi.advanceTimersByTime(600);
|
||||
|
||||
expect(wrapper.emitted('navigatePage')).toEqual([[2], [2]]);
|
||||
});
|
||||
});
|
||||
+68
-72
@@ -1,6 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import Draggable from 'vuedraggable';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
@@ -10,6 +9,7 @@ import { getArticleStatus } from 'dashboard/helper/portalHelper.js';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue';
|
||||
import DraggableReorderList from 'dashboard/components-next/DraggableReorderList/DraggableReorderList.vue';
|
||||
|
||||
const props = defineProps({
|
||||
articles: {
|
||||
@@ -28,9 +28,22 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
currentPage: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
totalPages: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['translateArticle', 'toggleSelect']);
|
||||
const emit = defineEmits([
|
||||
'translateArticle',
|
||||
'toggleSelect',
|
||||
'navigatePage',
|
||||
'dragging',
|
||||
]);
|
||||
|
||||
const { ARTICLE_STATUS_TYPES } = wootConstants;
|
||||
|
||||
@@ -39,14 +52,14 @@ const route = useRoute();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const localArticles = ref(props.articles);
|
||||
const hoveredArticleId = ref(null);
|
||||
|
||||
const dragEnabled = computed(() => {
|
||||
const canReorder = props.articles?.length > 1 || props.totalPages > 1;
|
||||
return (
|
||||
props.isCategoryArticles &&
|
||||
!props.isSearching &&
|
||||
localArticles.value?.length > 1 &&
|
||||
canReorder &&
|
||||
props.selectedArticleIds.size === 0
|
||||
);
|
||||
});
|
||||
@@ -63,6 +76,10 @@ const handleCardHover = (isHovered, id) => {
|
||||
|
||||
const getCategoryById = useMapGetter('categories/categoryById');
|
||||
|
||||
const getCategory = categoryId => {
|
||||
return getCategoryById.value(categoryId) || { name: '', icon: '' };
|
||||
};
|
||||
|
||||
const openArticle = id => {
|
||||
const { tab, categorySlug, locale } = route.params;
|
||||
if (props.isCategoryArticles) {
|
||||
@@ -83,38 +100,25 @@ const openArticle = id => {
|
||||
}
|
||||
};
|
||||
|
||||
const onReorder = async reorderedGroup => {
|
||||
const onReorder = async positionsHash => {
|
||||
const [movedId] = Object.keys(positionsHash);
|
||||
// A same-page reorder updates optimistically in the store, so it needs no
|
||||
// refetch. Only a cross-page drop must refresh, to pull the moved article
|
||||
// onto this page in its new spot.
|
||||
const isCrossPage = !props.articles.some(
|
||||
article => String(article.id) === movedId
|
||||
);
|
||||
try {
|
||||
await store.dispatch('articles/reorder', {
|
||||
reorderedGroup,
|
||||
reorderedGroup: positionsHash,
|
||||
portalSlug: route.params.portalSlug,
|
||||
});
|
||||
if (isCrossPage) emit('navigatePage', props.currentPage);
|
||||
} catch {
|
||||
useAlert(t('HELP_CENTER.REORDER_ARTICLE.API.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
|
||||
const onDragEnd = () => {
|
||||
// Collect and sort existing positions, falling back to index+1 for null/0 values
|
||||
const sortedArticlePositions = localArticles.value
|
||||
.map((article, index) => article.position || index + 1)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const orderedArticles = localArticles.value.map(article => article.id);
|
||||
|
||||
// Create a map of article IDs to their new positions
|
||||
const reorderedGroup = orderedArticles.reduce((obj, key, index) => {
|
||||
obj[key] = sortedArticlePositions[index];
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
onReorder(reorderedGroup);
|
||||
};
|
||||
|
||||
const getCategory = categoryId => {
|
||||
return getCategoryById.value(categoryId) || { name: '', icon: '' };
|
||||
};
|
||||
|
||||
const getStatusMessage = (status, isSuccess) => {
|
||||
const messageType = isSuccess ? 'SUCCESS' : 'ERROR';
|
||||
const statusMap = {
|
||||
@@ -184,54 +188,46 @@ const updateArticle = ({ action, value, id }) => {
|
||||
const status = action !== 'delete' ? getArticleStatus(value) : null;
|
||||
handleArticleAction(action, { status, id });
|
||||
};
|
||||
|
||||
// Watch for changes in the articles prop and update the localArticles ref
|
||||
watch(
|
||||
() => props.articles,
|
||||
newArticles => {
|
||||
localArticles.value = newArticles;
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Draggable
|
||||
v-model="localArticles"
|
||||
<DraggableReorderList
|
||||
:items="articles"
|
||||
:disabled="!dragEnabled"
|
||||
item-key="id"
|
||||
tag="ul"
|
||||
ghost-class="article-ghost-class"
|
||||
class="w-full h-full space-y-4"
|
||||
@end="onDragEnd"
|
||||
:current-page="currentPage"
|
||||
:total-pages="totalPages"
|
||||
@reorder="onReorder"
|
||||
@navigate-page="page => emit('navigatePage', page)"
|
||||
@dragging="value => emit('dragging', value)"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<li class="list-none rounded-2xl">
|
||||
<ArticleCard
|
||||
:id="element.id"
|
||||
:key="element.id"
|
||||
:title="element.title"
|
||||
:status="element.status"
|
||||
:author="element.author"
|
||||
:category="getCategory(element.category.id)"
|
||||
:views="element.views || 0"
|
||||
:updated-at="element.updatedAt"
|
||||
:is-selected="selectedArticleIds.has(element.id)"
|
||||
selectable
|
||||
:show-selection-control="shouldShowSelectionControl(element.id)"
|
||||
:class="{ 'cursor-grab': dragEnabled }"
|
||||
@open-article="openArticle"
|
||||
@article-action="updateArticle"
|
||||
@toggle-select="emit('toggleSelect', $event)"
|
||||
@hover="isHovered => handleCardHover(isHovered, element.id)"
|
||||
/>
|
||||
</li>
|
||||
<template #item="{ item }">
|
||||
<ArticleCard
|
||||
:id="item.id"
|
||||
:title="item.title"
|
||||
:status="item.status"
|
||||
:author="item.author"
|
||||
:category="getCategory(item.category.id)"
|
||||
:views="item.views || 0"
|
||||
:updated-at="item.updatedAt"
|
||||
:is-selected="selectedArticleIds.has(item.id)"
|
||||
selectable
|
||||
:show-selection-control="shouldShowSelectionControl(item.id)"
|
||||
@open-article="openArticle"
|
||||
@article-action="updateArticle"
|
||||
@toggle-select="emit('toggleSelect', $event)"
|
||||
@hover="isHovered => handleCardHover(isHovered, item.id)"
|
||||
/>
|
||||
</template>
|
||||
</Draggable>
|
||||
<template #ghost="{ item }">
|
||||
<ArticleCard
|
||||
:id="item.id"
|
||||
:title="item.title"
|
||||
:status="item.status"
|
||||
:author="item.author"
|
||||
:category="getCategory(item.category.id)"
|
||||
:views="item.views || 0"
|
||||
:updated-at="item.updatedAt"
|
||||
/>
|
||||
</template>
|
||||
</DraggableReorderList>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.article-ghost-class {
|
||||
@apply opacity-50 bg-n-solid-1;
|
||||
}
|
||||
</style>
|
||||
|
||||
+8
-1
@@ -70,6 +70,7 @@ const isFeatureEnabledonAccount = useMapGetter(
|
||||
);
|
||||
|
||||
const selectedArticleIds = ref(new Set());
|
||||
const isArticleDragging = ref(false);
|
||||
const deleteConfirmDialogRef = ref(null);
|
||||
const isCategoryMenuOpen = ref(false);
|
||||
const searchQuery = ref(route.query.search || '');
|
||||
@@ -148,6 +149,8 @@ const articlesCount = computed(() => {
|
||||
return Number(countMap[tab] || countMap['']);
|
||||
});
|
||||
|
||||
const totalPages = computed(() => Math.ceil(articlesCount.value / 25) || 1);
|
||||
|
||||
const showArticleHeaderControls = computed(
|
||||
() => !props.isCategoryArticles && !isSwitchingPortal.value
|
||||
);
|
||||
@@ -343,7 +346,7 @@ watch(
|
||||
</template>
|
||||
<template #content>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
v-if="isLoading && !isArticleDragging"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
@@ -453,9 +456,13 @@ watch(
|
||||
:is-category-articles="isCategoryArticles"
|
||||
:is-searching="isSearching"
|
||||
:selected-article-ids="selectedArticleIds"
|
||||
:current-page="Number(meta.currentPage)"
|
||||
:total-pages="totalPages"
|
||||
class="relative z-0"
|
||||
@translate-article="handleTranslateArticle"
|
||||
@toggle-select="handleToggleSelect"
|
||||
@navigate-page="handlePageChange"
|
||||
@dragging="isArticleDragging = $event"
|
||||
/>
|
||||
</template>
|
||||
<ArticleEmptyState
|
||||
|
||||
Reference in New Issue
Block a user