mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-17 05:41:25 +02:00
Added possibility to Drag&Drop message selection between folders.
fix #3775
This commit is contained in:
@@ -65,23 +65,31 @@ export default {
|
||||
responseType: 'blob',
|
||||
})
|
||||
},
|
||||
deleteSelection(mailbox, selection) {
|
||||
moveSelection(source, destination, selection) {
|
||||
const body = {
|
||||
mailbox,
|
||||
source,
|
||||
destination,
|
||||
selection,
|
||||
}
|
||||
return repository.post('/webmail/emails/move/', body)
|
||||
},
|
||||
deleteSelection(source, selection) {
|
||||
const body = {
|
||||
source,
|
||||
selection,
|
||||
}
|
||||
return repository.post('/webmail/emails/delete/', body)
|
||||
},
|
||||
markSelectionAsJunk(mailbox, selection) {
|
||||
markSelectionAsJunk(source, selection) {
|
||||
const body = {
|
||||
mailbox,
|
||||
source,
|
||||
selection,
|
||||
}
|
||||
return repository.post('/webmail/emails/mark_as_junk/', body)
|
||||
},
|
||||
markSelectionAsNotJunk(mailbox, selection) {
|
||||
markSelectionAsNotJunk(source, selection) {
|
||||
const body = {
|
||||
mailbox,
|
||||
source,
|
||||
selection,
|
||||
}
|
||||
return repository.post('/webmail/emails/mark_as_not_junk/', body)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
v-model="selectAll"
|
||||
class="mr-4"
|
||||
hide-details
|
||||
color="primary"
|
||||
@update:model-value="toggleAllSelection"
|
||||
/>
|
||||
|
||||
@@ -88,7 +89,7 @@
|
||||
</v-menu>
|
||||
</v-btn>
|
||||
<v-spacer />
|
||||
<div v-if="emails.results" class="d-flex align-center">
|
||||
<div v-if="emails" class="d-flex align-center">
|
||||
<div class="text-caption mr-2">
|
||||
{{ emails.first_index }}-{{ emails.last_index }} {{ $gettext('on') }}
|
||||
{{ emails.count }}
|
||||
@@ -119,14 +120,17 @@
|
||||
:key="email.imapid"
|
||||
density="compact"
|
||||
class="mb-2 mx-1"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart(email)"
|
||||
>
|
||||
<v-card-text
|
||||
class="d-flex align-center"
|
||||
:class="{ 'font-weight-bold': email.style === 'unseen' }"
|
||||
>
|
||||
<v-checkbox
|
||||
v-model="selection"
|
||||
v-model="webmailStore.selection"
|
||||
:value="email.imapid"
|
||||
color="primary"
|
||||
hide-details
|
||||
/>
|
||||
<v-btn
|
||||
@@ -170,10 +174,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useBusStore } from '@/stores'
|
||||
import { useBusStore, useWebmailStore } from '@/stores'
|
||||
import api from '@/api/webmail'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -183,18 +187,20 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const { $gettext, $ngettext } = useGettext()
|
||||
const { displayNotification, reloadMailboxCounters } = useBusStore()
|
||||
const webmailStore = useWebmailStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const loading = ref(false)
|
||||
const emails = ref({})
|
||||
const emails = ref([])
|
||||
const emailsPage = ref({})
|
||||
const page = ref(1)
|
||||
const search = ref('')
|
||||
const selectAll = ref(false)
|
||||
const selection = ref([])
|
||||
const working = ref(false)
|
||||
const emailList = ref(null)
|
||||
|
||||
let intervalId = null
|
||||
|
||||
@@ -233,18 +239,18 @@ const submitSearch = () => {
|
||||
|
||||
const toggleAllSelection = (value) => {
|
||||
if (!value) {
|
||||
selection.value = []
|
||||
webmailStore.selection = []
|
||||
} else {
|
||||
selection.value = emails.value.results.map((email) => email.imapid)
|
||||
webmailStore.selection = emails.value.results.map((email) => email.imapid)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteSelection = () => {
|
||||
if (!selection.value.length) {
|
||||
if (!webmailStore.selection.length) {
|
||||
return
|
||||
}
|
||||
working.value = true
|
||||
api.deleteSelection(currentMailbox.value, selection.value).then(() => {
|
||||
api.deleteSelection(currentMailbox.value, webmailStore.selection).then(() => {
|
||||
working.value = false
|
||||
displayNotification({ msg: $gettext('Message(s) deleted') })
|
||||
fetchEmails()
|
||||
@@ -253,41 +259,47 @@ const deleteSelection = () => {
|
||||
}
|
||||
|
||||
const markSelectionAsJunk = () => {
|
||||
if (!selection.value.length) {
|
||||
if (!webmailStore.selection.length) {
|
||||
return
|
||||
}
|
||||
working.value = true
|
||||
api.markSelectionAsJunk(currentMailbox.value, selection.value).then(() => {
|
||||
working.value = false
|
||||
displayNotification({ msg: $gettext('Message(s) marked as junk') })
|
||||
autoRefreshContent()
|
||||
})
|
||||
api
|
||||
.markSelectionAsJunk(currentMailbox.value, webmailStore.selection)
|
||||
.then(() => {
|
||||
working.value = false
|
||||
displayNotification({ msg: $gettext('Message(s) marked as junk') })
|
||||
autoRefreshContent()
|
||||
})
|
||||
}
|
||||
|
||||
const markSelectionAsNotJunk = () => {
|
||||
if (!selection.value.length) {
|
||||
if (!webmailStore.selection.length) {
|
||||
return
|
||||
}
|
||||
working.value = true
|
||||
api.markSelectionAsNotJunk(currentMailbox.value, selection.value).then(() => {
|
||||
working.value = false
|
||||
displayNotification({ msg: $gettext('Message(s) marked as not junk') })
|
||||
autoRefreshContent()
|
||||
})
|
||||
api
|
||||
.markSelectionAsNotJunk(currentMailbox.value, webmailStore.selection)
|
||||
.then(() => {
|
||||
working.value = false
|
||||
displayNotification({ msg: $gettext('Message(s) marked as not junk') })
|
||||
autoRefreshContent()
|
||||
})
|
||||
}
|
||||
|
||||
const flagSelection = (status) => {
|
||||
if (!selection.value.length) {
|
||||
if (!webmailStore.selection.length) {
|
||||
return
|
||||
}
|
||||
working.value = true
|
||||
api.flagSelection(currentMailbox.value, selection.value, status).then(() => {
|
||||
working.value = false
|
||||
selection.value = []
|
||||
displayNotification({ msg: $gettext('Message(s) flagged') })
|
||||
fetchEmails()
|
||||
reloadMailboxCounters()
|
||||
})
|
||||
api
|
||||
.flagSelection(currentMailbox.value, webmailStore.selection, status)
|
||||
.then(() => {
|
||||
working.value = false
|
||||
webmailStore.selection = []
|
||||
displayNotification({ msg: $gettext('Message(s) flagged') })
|
||||
fetchEmails()
|
||||
reloadMailboxCounters()
|
||||
})
|
||||
}
|
||||
|
||||
const emptyMailbox = () => {
|
||||
@@ -305,6 +317,25 @@ const toggleFollowState = async (email) => {
|
||||
email.flagged = flag === 'flagged'
|
||||
}
|
||||
|
||||
const onDragStart = (email) => {
|
||||
if (!webmailStore.selection.includes(email.imapid)) {
|
||||
webmailStore.selection = [email.imapid]
|
||||
}
|
||||
const ghost = document.createElement('div')
|
||||
const count = webmailStore.selection.length || 0
|
||||
const msg = $ngettext('%{count} message', '%{count} messages', count, {
|
||||
count,
|
||||
})
|
||||
ghost.innerHTML = `
|
||||
<div class="pa-4 rounded-lg" style="background: #a9a9a9; opacity: 0.8; width: 300px">
|
||||
<div style="font-weight: 600">${msg}</div>
|
||||
</div>
|
||||
`
|
||||
document.body.appendChild(ghost)
|
||||
event.dataTransfer.setDragImage(ghost, -10, -10)
|
||||
setTimeout(() => ghost.remove(), 0)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
intervalId = setInterval(autoRefreshContent, 300 * 1000)
|
||||
})
|
||||
@@ -320,13 +351,22 @@ watch(
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
watch(selection, () => {
|
||||
if (!selection.value.length) {
|
||||
selectAll.value = false
|
||||
} else {
|
||||
selectAll.value = true
|
||||
watch(
|
||||
() => webmailStore.selection,
|
||||
() => {
|
||||
if (!webmailStore.selection.length) {
|
||||
selectAll.value = false
|
||||
} else {
|
||||
selectAll.value = true
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
watch(
|
||||
() => webmailStore.listingKey,
|
||||
() => {
|
||||
fetchEmails()
|
||||
}
|
||||
)
|
||||
watch(page, () => {
|
||||
fetchEmails()
|
||||
})
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
@click="updateSelection(mailbox.name)"
|
||||
@mouseover="setHover(mailbox, true)"
|
||||
@mouseleave="setHover(mailbox, false)"
|
||||
@dragover.prevent
|
||||
@dragenter="setHover(mailbox, true)"
|
||||
@dragleave="setHover(mailbox, false)"
|
||||
@drop="onDrop(mailbox)"
|
||||
>
|
||||
<v-icon :icon="iconByMailboxType[mailbox.type]" class="mr-4" />
|
||||
<template v-if="mailbox.name === route.query.mailbox">
|
||||
@@ -57,7 +61,8 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useBusStore } from '@/stores'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { useBusStore, useWebmailStore } from '@/stores'
|
||||
import api from '@/api/webmail'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -86,7 +91,9 @@ const model = defineModel()
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const route = useRoute()
|
||||
const { $gettext } = useGettext()
|
||||
const busStore = useBusStore()
|
||||
const webmailStore = useWebmailStore()
|
||||
|
||||
const iconByMailboxType = {
|
||||
inbox: 'mdi-inbox',
|
||||
@@ -152,6 +159,25 @@ function updateSelection(value) {
|
||||
}
|
||||
}
|
||||
|
||||
async function onDrop(mailbox) {
|
||||
try {
|
||||
busStore.displayNotification({
|
||||
msg: $gettext('Moving selection...'),
|
||||
type: 'info',
|
||||
timeout: 0,
|
||||
})
|
||||
const resp = await api.moveSelection(
|
||||
route.query.mailbox,
|
||||
mailbox.name,
|
||||
webmailStore.selection
|
||||
)
|
||||
webmailStore.listingKey++
|
||||
webmailStore.selection = []
|
||||
} finally {
|
||||
busStore.hideNotification()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => busStore.mbCounterKey,
|
||||
() => {
|
||||
@@ -169,4 +195,8 @@ watch(
|
||||
border-radius: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.mailbox * {
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -54,17 +54,21 @@ const props = defineProps({
|
||||
|
||||
const authUser = computed(() => authStore.authUser)
|
||||
const notificationColor = computed(() => busStore.notificationColor)
|
||||
const notificationTimeout = computed(() => busStore.notificationTimeout)
|
||||
const notification = computed(() => busStore.notification)
|
||||
const snackbar = ref(false)
|
||||
|
||||
const notificationTimeout = 2000
|
||||
|
||||
busStore.$onAction(({ name, after }) => {
|
||||
if (name === 'displayNotification') {
|
||||
after(() => {
|
||||
snackbar.value = true
|
||||
})
|
||||
}
|
||||
if (name === 'hideNotification') {
|
||||
after(() => {
|
||||
snackbar.value = false
|
||||
})
|
||||
}
|
||||
}, true)
|
||||
|
||||
if (props.loadTheme) {
|
||||
|
||||
@@ -3,14 +3,16 @@ import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useBusStore = defineStore('bus', () => {
|
||||
const notification = ref({})
|
||||
const notification = ref('')
|
||||
const notificationColor = ref('')
|
||||
const notificationTimeout = ref(2000)
|
||||
const dataKey = ref(1)
|
||||
const mbCounterKey = ref(1)
|
||||
|
||||
async function $reset() {
|
||||
notification.value = {}
|
||||
notification.value = ''
|
||||
notificationColor.value = ''
|
||||
notificationTimeout.value = 2000
|
||||
dataKey.value = 1
|
||||
mbCounterKey.value = 1
|
||||
}
|
||||
@@ -18,6 +20,13 @@ export const useBusStore = defineStore('bus', () => {
|
||||
function displayNotification(options) {
|
||||
notification.value = options.msg
|
||||
notificationColor.value = options.type ? options.type : 'success'
|
||||
if (options.timeout !== undefined) {
|
||||
notificationTimeout.value = options.timeout
|
||||
}
|
||||
}
|
||||
|
||||
function hideNotification() {
|
||||
notification.value = ''
|
||||
}
|
||||
|
||||
function reloadData() {
|
||||
@@ -32,6 +41,7 @@ export const useBusStore = defineStore('bus', () => {
|
||||
notification,
|
||||
notificationColor,
|
||||
displayNotification,
|
||||
hideNotification,
|
||||
reloadMailboxCounters,
|
||||
dataKey,
|
||||
mbCounterKey,
|
||||
|
||||
@@ -9,5 +9,6 @@ export * from './domains.store'
|
||||
export * from './parameters.store'
|
||||
export * from './providers.store'
|
||||
export * from './contacts.store'
|
||||
export * from './webmail.store'
|
||||
|
||||
export default createPinia()
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useWebmailStore = defineStore('webmail', () => {
|
||||
const selection = ref([])
|
||||
const listingKey = ref(0)
|
||||
|
||||
return {
|
||||
selection,
|
||||
listingKey,
|
||||
}
|
||||
})
|
||||
@@ -245,6 +245,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.10.tgz#a2a1e3812d14525f725d011a73eceb41fef5bc1c"
|
||||
integrity sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==
|
||||
|
||||
"@formkit/drag-and-drop@^0.5.3":
|
||||
version "0.5.3"
|
||||
resolved "https://registry.yarnpkg.com/@formkit/drag-and-drop/-/drag-and-drop-0.5.3.tgz#24408c0cf8749b26d3fba3b043503efba44b4c90"
|
||||
integrity sha512-VGQsKYc350OOMbz7wYt69YqDbn49x7RJ32eqiJieNke54nGZYmx18XEqTw0lBiJmH3RaiH6V45gB1u+tvI/TNw==
|
||||
|
||||
"@fullcalendar/core@^6.1.15":
|
||||
version "6.1.19"
|
||||
resolved "https://registry.yarnpkg.com/@fullcalendar/core/-/core-6.1.19.tgz#60379111ba0cfb9fd9bc6594fee47cf855fc77b2"
|
||||
|
||||
@@ -173,7 +173,8 @@ class EmailSerializer(serializers.Serializer):
|
||||
|
||||
class MoveSelectionSerializer(serializers.Serializer):
|
||||
|
||||
mailbox = serializers.CharField()
|
||||
source = serializers.CharField()
|
||||
destination = serializers.CharField(required=False)
|
||||
selection = serializers.ListField(child=serializers.CharField())
|
||||
|
||||
def validate_selection(self, value):
|
||||
|
||||
@@ -173,15 +173,27 @@ class UserEmailViewSetTestCase(WebmailTestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("Message-ID", response.json()["source"])
|
||||
|
||||
def test_delete(self):
|
||||
def test_move(self):
|
||||
self.authenticate()
|
||||
url = reverse("v2:webmail-email-delete")
|
||||
body = {"mailbox": "INBOX", "selection": [1]}
|
||||
url = reverse("v2:webmail-email-move")
|
||||
body = {"source": "INBOX", "destination": "Junk", "selection": [1]}
|
||||
response = self.client.post(url, body, format="json")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["count"], 1)
|
||||
|
||||
body = {"mailbox": "INBOX", "selection": ["truc"]}
|
||||
body = {"source": "INBOX", "selection": [1]}
|
||||
response = self.client.post(url, body, format="json")
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_delete(self):
|
||||
self.authenticate()
|
||||
url = reverse("v2:webmail-email-delete")
|
||||
body = {"source": "INBOX", "selection": [1]}
|
||||
response = self.client.post(url, body, format="json")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["count"], 1)
|
||||
|
||||
body = {"source": "INBOX", "selection": ["truc"]}
|
||||
response = self.client.post(url, body, format="json")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["count"], 0)
|
||||
@@ -189,7 +201,7 @@ class UserEmailViewSetTestCase(WebmailTestCase):
|
||||
def test_mark_as_junk(self):
|
||||
self.authenticate()
|
||||
url = reverse("v2:webmail-email-mark-as-junk")
|
||||
body = {"mailbox": "INBOX", "selection": [1]}
|
||||
body = {"source": "INBOX", "selection": [1]}
|
||||
response = self.client.post(url, body, format="json")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["count"], 1)
|
||||
@@ -197,7 +209,7 @@ class UserEmailViewSetTestCase(WebmailTestCase):
|
||||
def test_mark_as_not_junk(self):
|
||||
self.authenticate()
|
||||
url = reverse("v2:webmail-email-mark-as-not-junk")
|
||||
body = {"mailbox": "INBOX", "selection": [1]}
|
||||
body = {"source": "INBOX", "selection": [1]}
|
||||
response = self.client.post(url, body, format="json")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["count"], 1)
|
||||
|
||||
@@ -9,6 +9,7 @@ from rest_framework import parsers, response, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
|
||||
from modoboa.lib import exceptions
|
||||
from modoboa.lib.paginator import Paginator
|
||||
from modoboa.lib.viewsets import HasMailbox
|
||||
from modoboa.webmail import lib, serializers
|
||||
@@ -123,7 +124,7 @@ class UserEmailViewSet(viewsets.GenericViewSet):
|
||||
def get_serializer_class(self):
|
||||
if self.action == "list":
|
||||
return serializers.PaginatedEmailListSerializer
|
||||
if self.action in ["delete", "mark_as_junk", "mark_as_not_junk"]:
|
||||
if self.action in ["move", "delete", "mark_as_junk", "mark_as_not_junk"]:
|
||||
return serializers.MoveSelectionSerializer
|
||||
if self.action == "flag":
|
||||
return serializers.FlagSelectionSerializer
|
||||
@@ -167,18 +168,31 @@ class UserEmailViewSet(viewsets.GenericViewSet):
|
||||
)
|
||||
return response.Response(serializer.data)
|
||||
|
||||
def move_selection(self, request, destination: str) -> int:
|
||||
def move_selection(self, request, destination: str | None = None) -> int:
|
||||
"""Move selected messages to the given mailbox."""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
if destination is None:
|
||||
if "destination" not in serializer.validated_data:
|
||||
raise exceptions.BadRequest("No destination provided")
|
||||
destination = serializer.validated_data["destination"]
|
||||
with lib.get_imapconnector(request) as mbc:
|
||||
mbc.move(
|
||||
",".join(serializer.validated_data["selection"]),
|
||||
serializer.validated_data["mailbox"],
|
||||
serializer.validated_data["source"],
|
||||
destination,
|
||||
)
|
||||
return len(serializer.validated_data["selection"])
|
||||
|
||||
@action(
|
||||
methods=["post"],
|
||||
detail=False,
|
||||
serializer_class=serializers.MoveSelectionSerializer,
|
||||
)
|
||||
def move(self, request):
|
||||
count = self.move_selection(request)
|
||||
return response.Response({"count": count})
|
||||
|
||||
@action(
|
||||
methods=["post"],
|
||||
detail=False,
|
||||
|
||||
Reference in New Issue
Block a user