mirror of
https://github.com/suitenumerique/messages.git
synced 2026-04-25 17:15:21 +02:00
✨(frontend) add label assignment with archive and bulk label widget
Dragging threads onto a label now assigns the label and archive by default. Holding Shift while dropping only assign the label to the threads, mimicking Gmail's "move" behavior. A custom drag preview follows the cursor and updates in real-time to reflect the current action. A new BulkLabelsWidget in the thread selection toolbar lets users assign labels to multiple threads at once. The existing LabelsPopup was refactored to support multi-thread selection with indeterminate checkbox states. Resolve #396
This commit is contained in:
@@ -291,22 +291,19 @@ class LabelViewSet(
|
||||
{"detail": "No thread IDs provided"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# Require full edit rights on each thread **scoped to the label's
|
||||
# own mailbox**. The mailbox here is intrinsic to the label (a
|
||||
# property of the resource), not ambient from the caller's view,
|
||||
# so we always pass `label.mailbox_id`. This enforces two things
|
||||
# at once:
|
||||
# - User must have full edit rights (EDITOR ThreadAccess role +
|
||||
# CAN_EDIT MailboxAccess role) on the thread — blocks viewer
|
||||
# access to the thread.
|
||||
# - The thread must actually exist under label.mailbox — blocks
|
||||
# attaching a label to a thread that is only visible from a
|
||||
# different mailbox, even when the user has edit rights there.
|
||||
# Labels are a mailbox-local organizational tool: the caller's
|
||||
# EDITOR/SENDER/ADMIN role on the label's mailbox (verified by
|
||||
# `check_mailbox_permissions` above) is sufficient to attach the
|
||||
# label. We don't require per-thread EDITOR rights, so a viewer
|
||||
# of a shared thread can still organize it within their own
|
||||
# mailbox. We still require the thread to belong to the label's
|
||||
# mailbox — otherwise a caller could attach a label to any
|
||||
# thread ID they happen to know.
|
||||
accessible_threads = models.Thread.objects.filter(
|
||||
Exists(
|
||||
models.ThreadAccess.objects.editable_by(
|
||||
request.user, mailbox_id=label.mailbox_id
|
||||
).filter(thread=OuterRef("pk"))
|
||||
models.ThreadAccess.objects.filter(
|
||||
mailbox_id=label.mailbox_id, thread=OuterRef("pk")
|
||||
)
|
||||
),
|
||||
id__in=thread_ids,
|
||||
)
|
||||
@@ -360,16 +357,14 @@ class LabelViewSet(
|
||||
{"detail": "No thread IDs provided"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# Removing a label from a thread is a mutation on shared state,
|
||||
# so it requires full edit rights (EDITOR ThreadAccess role +
|
||||
# CAN_EDIT MailboxAccess role) scoped to the label's own mailbox
|
||||
# — same check as add_threads. See the comment there for the
|
||||
# rationale on using `label.mailbox_id` (intrinsic to the label).
|
||||
# Mirrors `add_threads`: mailbox-level permission is sufficient
|
||||
# for mailbox-local label organization; we only require the
|
||||
# thread to belong to the label's mailbox.
|
||||
accessible_threads = models.Thread.objects.filter(
|
||||
Exists(
|
||||
models.ThreadAccess.objects.editable_by(
|
||||
request.user, mailbox_id=label.mailbox_id
|
||||
).filter(thread=OuterRef("pk"))
|
||||
models.ThreadAccess.objects.filter(
|
||||
mailbox_id=label.mailbox_id, thread=OuterRef("pk")
|
||||
)
|
||||
),
|
||||
id__in=thread_ids,
|
||||
)
|
||||
|
||||
@@ -662,49 +662,44 @@ class TestLabelViewSet:
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert label.threads.count() == 1 # Thread not removed
|
||||
|
||||
def test_add_threads_cross_mailbox_viewer_thread_access_forbidden(
|
||||
self, api_client, label, user
|
||||
def test_add_threads_viewer_thread_access_allowed(
|
||||
self, api_client, label, mailbox, user
|
||||
):
|
||||
"""User is EDITOR/ADMIN on label.mailbox (can manage labels) but
|
||||
only has VIEWER ThreadAccess to the thread (via a different
|
||||
mailbox). Labelling the thread is a shared-state mutation on the
|
||||
thread, so it must be blocked.
|
||||
"""A label is a mailbox-local organizational tool: mailbox-level
|
||||
permission on label.mailbox is enough to attach it. The caller's
|
||||
per-thread role is irrelevant — a VIEWER on a shared thread can
|
||||
still label it from within their own mailbox.
|
||||
"""
|
||||
other_mailbox = MailboxFactory()
|
||||
other_mailbox.accesses.create(user=user, role=models.MailboxRoleChoices.VIEWER)
|
||||
thread = ThreadFactory()
|
||||
thread.accesses.create(
|
||||
mailbox=other_mailbox,
|
||||
role=enums.ThreadAccessRoleChoices.EDITOR, # Mailbox has edit access
|
||||
mailbox=mailbox,
|
||||
role=enums.ThreadAccessRoleChoices.VIEWER,
|
||||
)
|
||||
|
||||
url = reverse("labels-add-threads", args=[label.pk])
|
||||
response = api_client.post(url, {"thread_ids": [str(thread.id)]}, format="json")
|
||||
|
||||
# The endpoint returns 200 but silently skips the thread.
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert label.threads.count() == 0
|
||||
assert label.threads.count() == 1
|
||||
|
||||
def test_remove_threads_cross_mailbox_viewer_thread_access_forbidden(
|
||||
self, api_client, label, user
|
||||
def test_remove_threads_viewer_thread_access_allowed(
|
||||
self, api_client, label, mailbox, user
|
||||
):
|
||||
"""Mirror of add_threads above — removing a label from a thread
|
||||
also requires full edit rights on the thread.
|
||||
"""Mirror of add_threads above — removing a label only requires
|
||||
mailbox-level permission on label.mailbox.
|
||||
"""
|
||||
other_mailbox = MailboxFactory()
|
||||
other_mailbox.accesses.create(user=user, role=models.MailboxRoleChoices.VIEWER)
|
||||
thread = ThreadFactory()
|
||||
thread.accesses.create(
|
||||
mailbox=other_mailbox,
|
||||
role=enums.ThreadAccessRoleChoices.EDITOR,
|
||||
mailbox=mailbox,
|
||||
role=enums.ThreadAccessRoleChoices.VIEWER,
|
||||
)
|
||||
label.threads.add(thread) # Pre-existing association
|
||||
label.threads.add(thread)
|
||||
|
||||
url = reverse("labels-remove-threads", args=[label.pk])
|
||||
response = api_client.post(url, {"thread_ids": [str(thread.id)]}, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert label.threads.count() == 1 # Not removed
|
||||
assert label.threads.count() == 0
|
||||
|
||||
def test_add_threads_thread_not_in_label_mailbox_forbidden(
|
||||
self, api_client, label, user
|
||||
|
||||
@@ -75,8 +75,6 @@
|
||||
"{{count}} threads have been unarchived._other": "{{count}} threads have been unarchived.",
|
||||
"{{count}} threads have been updated._one": "The thread has been updated.",
|
||||
"{{count}} threads have been updated._other": "{{count}} threads have been updated.",
|
||||
"{{count}} threads selected_one": "{{count}} thread selected",
|
||||
"{{count}} threads selected_other": "{{count}} threads selected",
|
||||
"{{count}} unread messages_one": "{{count}} unread message",
|
||||
"{{count}} unread messages_other": "{{count}} unread messages",
|
||||
"{{count}} unread messages mentioning you_one": "{{count}} unread message mentioning you",
|
||||
@@ -148,6 +146,8 @@
|
||||
"Are you sure you want to delete this signature? This action is irreversible!": "Are you sure you want to delete this signature? This action is irreversible!",
|
||||
"Are you sure you want to delete this template? This action is irreversible!": "Are you sure you want to delete this template? This action is irreversible!",
|
||||
"Are you sure you want to reset the password?": "Are you sure you want to reset the password?",
|
||||
"Assign this label": "Assign this label",
|
||||
"Assign this label and archive": "Assign this label and archive",
|
||||
"At least one recipient is required.": "At least one recipient is required.",
|
||||
"Attachment failed to be saved into your {{driveAppName}}'s workspace.": "Attachment failed to be saved into your {{driveAppName}}'s workspace.",
|
||||
"Attachment saved into your {{driveAppName}}'s workspace.": "Attachment saved into your {{driveAppName}}'s workspace.",
|
||||
@@ -366,8 +366,13 @@
|
||||
"Integration updated!": "Integration updated!",
|
||||
"Integrations": "Integrations",
|
||||
"just now": "just now",
|
||||
"Label \"{{label}}\" assigned and {{count}} threads archived._one": "Label \"{{label}}\" assigned and thread archived.",
|
||||
"Label \"{{label}}\" assigned and {{count}} threads archived._other": "Label \"{{label}}\" assigned and {{count}} threads archived.",
|
||||
"Label \"{{label}}\" assigned to {{count}} threads._one": "Label \"{{label}}\" assigned to this thread.",
|
||||
"Label \"{{label}}\" assigned to {{count}} threads._other": "Label \"{{label}}\" assigned to {{count}} threads.",
|
||||
"Label \"{{label}}\" assigned, but no threads could be archived.": "Label \"{{label}}\" assigned, but no threads could be archived.",
|
||||
"Label \"{{label}}\" assigned. {{count}} of {{total}} threads archived._one": "Label \"{{label}}\" assigned. {{count}} of {{total}} thread archived.",
|
||||
"Label \"{{label}}\" assigned. {{count}} of {{total}} threads archived._other": "Label \"{{label}}\" assigned. {{count}} of {{total}} threads archived.",
|
||||
"Label \"{{label}}\" removed from this conversation.": "Label \"{{label}}\" removed from this conversation.",
|
||||
"Label name": "Label name",
|
||||
"Labels": "Labels",
|
||||
@@ -418,6 +423,8 @@
|
||||
"Monthly": "Monthly",
|
||||
"More": "More",
|
||||
"More options": "More options",
|
||||
"Move {{count}} threads_one": "Move {{count}} thread",
|
||||
"Move {{count}} threads_other": "Move {{count}} threads",
|
||||
"My auto-replies": "My auto-replies",
|
||||
"My message templates": "My message templates",
|
||||
"My signatures": "My signatures",
|
||||
|
||||
@@ -113,9 +113,6 @@
|
||||
"{{count}} threads have been updated._one": "La conversation a été mise à jour.",
|
||||
"{{count}} threads have been updated._many": "{{count}} conversations ont été mises à jour.",
|
||||
"{{count}} threads have been updated._other": "{{count}} conversations ont été mises à jour.",
|
||||
"{{count}} threads selected_one": "{{count}} conversation sélectionnée",
|
||||
"{{count}} threads selected_many": "{{count}} conversations sélectionnées",
|
||||
"{{count}} threads selected_other": "{{count}} conversations sélectionnées",
|
||||
"{{count}} unread messages_one": "{{count}} message non lu",
|
||||
"{{count}} unread messages_many": "{{count}} messages non lus",
|
||||
"{{count}} unread messages_other": "{{count}} messages non lus",
|
||||
@@ -198,6 +195,8 @@
|
||||
"Are you sure you want to delete this signature? This action is irreversible!": "Êtes-vous sûr de vouloir supprimer cette signature ? Cette action est irréversible !",
|
||||
"Are you sure you want to delete this template? This action is irreversible!": "Êtes-vous sûr de vouloir supprimer ce modèle ? Cette action est irréversible !",
|
||||
"Are you sure you want to reset the password?": "Êtes-vous sûr de vouloir réinitialiser le mot de passe ?",
|
||||
"Assign this label": "Assigner ce libellé",
|
||||
"Assign this label and archive": "Assigner ce libellé et archiver",
|
||||
"At least one recipient is required.": "Il faut au moins un destinataire.",
|
||||
"Attachment failed to be saved into your {{driveAppName}}'s workspace.": "Impossible de sauvegarder la pièce jointe dans votre espace de travail {{driveAppName}}.",
|
||||
"Attachment saved into your {{driveAppName}}'s workspace.": "Pièce jointe sauvegardée dans votre espace de travail {{driveAppName}}.",
|
||||
@@ -350,7 +349,7 @@
|
||||
"Every {{count}} years_many": "Tous les {{count}} ans",
|
||||
"Every {{count}} years_other": "Tous les {{count}} ans",
|
||||
"Expand": "Développer",
|
||||
"Expand {{name}}": "",
|
||||
"Expand {{name}}": "Développer {{name}}",
|
||||
"Expand all": "Tout développer",
|
||||
"Failed to delete auto-reply.": "Erreur lors de la suppression de la réponse automatique.",
|
||||
"Failed to delete integration.": "Erreur lors de la suppression de l'intégration.",
|
||||
@@ -420,9 +419,16 @@
|
||||
"Integration updated!": "Intégration mise à jour !",
|
||||
"Integrations": "Intégrations",
|
||||
"just now": "à l'instant",
|
||||
"Label \"{{label}}\" assigned and {{count}} threads archived._one": "Libellé \"{{label}}\" assigné et conversation archivée.",
|
||||
"Label \"{{label}}\" assigned and {{count}} threads archived._many": "Libellé \"{{label}}\" assigné et {{count}} conversations archivées.",
|
||||
"Label \"{{label}}\" assigned and {{count}} threads archived._other": "Libellé \"{{label}}\" assigné et {{count}} conversations archivées.",
|
||||
"Label \"{{label}}\" assigned to {{count}} threads._one": "Libellé \"{{label}}\" assigné à la conversation.",
|
||||
"Label \"{{label}}\" assigned to {{count}} threads._many": "Libellé \"{{label}}\" assigné à {{count}} conversations.",
|
||||
"Label \"{{label}}\" assigned to {{count}} threads._other": "Libellé \"{{label}}\" assigné à {{count}} conversations.",
|
||||
"Label \"{{label}}\" assigned, but no threads could be archived.": "Libellé \"{{label}}\" assigné, mais aucune conversation n'a pu être archivée.",
|
||||
"Label \"{{label}}\" assigned. {{count}} of {{total}} threads archived._one": "Libellé \"{{label}}\" assigné. {{count}} conversation sur {{total}} archivée.",
|
||||
"Label \"{{label}}\" assigned. {{count}} of {{total}} threads archived._many": "Libellé \"{{label}}\" assigné. {{count}} conversations sur {{total}} archivées.",
|
||||
"Label \"{{label}}\" assigned. {{count}} of {{total}} threads archived._other": "Libellé \"{{label}}\" assigné. {{count}} conversations sur {{total}} archivées.",
|
||||
"Label \"{{label}}\" removed from this conversation.": "Libellé \"{{label}}\" retiré de cette conversation.",
|
||||
"Label name": "Nom du libellé",
|
||||
"Labels": "Libellés",
|
||||
@@ -473,6 +479,9 @@
|
||||
"Monthly": "Mensuel",
|
||||
"More": "Plus",
|
||||
"More options": "Plus d'options",
|
||||
"Move {{count}} threads_one": "Déplacer {{count}} conversation",
|
||||
"Move {{count}} threads_many": "Déplacer {{count}} conversations",
|
||||
"Move {{count}} threads_other": "Déplacer {{count}} conversations",
|
||||
"My auto-replies": "Mes réponses automatiques",
|
||||
"My message templates": "Mes modèles de message",
|
||||
"My signatures": "Mes signatures",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { Button } from "@gouvfr-lasuite/cunningham-react";
|
||||
import { SearchFiltersForm } from "../search-filters-form";
|
||||
import { useLayoutContext } from "@/features/layouts/components/main";
|
||||
import { useLayoutContext } from "@/features/layouts/components/layout-context";
|
||||
import { MAILBOX_FOLDERS } from "@/features/layouts/components/mailbox-panel/components/mailbox-list";
|
||||
import { Icon } from "@gouvfr-lasuite/ui-kit";
|
||||
|
||||
|
||||
@@ -9,8 +9,7 @@ import ErrorPage from "next/error";
|
||||
import { Toaster } from "@/features/ui/components/toaster";
|
||||
import { Icon, IconSize, IconType } from "@gouvfr-lasuite/ui-kit";
|
||||
import { useTheme } from "@/features/providers/theme";
|
||||
import { useState } from "react";
|
||||
import { LayoutContext } from "../main";
|
||||
import { LayoutProvider } from "@/features/layouts/components/layout-context";
|
||||
|
||||
type AdminLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -121,18 +120,10 @@ function AdminLayoutContent({
|
||||
}
|
||||
|
||||
export function AdminLayout(props: AdminLayoutProps) {
|
||||
const [leftPanelOpen, setLeftPanelOpen] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const { theme, variant } = useTheme();
|
||||
|
||||
return (
|
||||
<LayoutContext.Provider value={{
|
||||
toggleLeftPanel: () => setLeftPanelOpen(!leftPanelOpen),
|
||||
closeLeftPanel: () => setLeftPanelOpen(false),
|
||||
openLeftPanel: () => setLeftPanelOpen(true),
|
||||
isDragging,
|
||||
setIsDragging,
|
||||
}}>
|
||||
<LayoutProvider>
|
||||
<AppLayout
|
||||
isLeftPanelOpen={false}
|
||||
setIsLeftPanelOpen={() => { }}
|
||||
@@ -146,6 +137,6 @@ export function AdminLayout(props: AdminLayoutProps) {
|
||||
<Toaster />
|
||||
</AdminMailDomainProvider>
|
||||
</AppLayout>
|
||||
</LayoutContext.Provider>
|
||||
</LayoutProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
.thread-labels-widget {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.thread-labels-widget[aria-busy="true"] {
|
||||
.labels-widget[aria-busy="true"] {
|
||||
cursor: wait;
|
||||
|
||||
& > .c__button {
|
||||
@@ -10,7 +6,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.thread-labels-widget__loading-labels-tooltip-content {
|
||||
.labels-widget__loading-labels-tooltip-content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--c--globals--spacings--xs);
|
||||
@@ -20,21 +16,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
.thread-labels-widget__popup {
|
||||
position: absolute;
|
||||
right: -25%;
|
||||
top: 100%;
|
||||
.labels-widget__popup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--c--contextuals--background--surface--secondary);
|
||||
border: 1px solid var(--c--contextuals--border--surface--primary);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 5px 0 var(--c--contextuals--background--semantic--contextual--primary-hover);
|
||||
z-index: 10000;
|
||||
width: min(400px, 60vw);
|
||||
max-height: max(600px, 80vh);
|
||||
margin-top: var(--c--globals--spacings--2xs);
|
||||
}
|
||||
|
||||
.thread-labels-widget__popup__header {
|
||||
.labels-widget__popup__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--c--globals--spacings--xs);
|
||||
@@ -54,18 +47,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
.thread-labels-widget__popup__content {
|
||||
.labels-widget__popup__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--c--globals--spacings--xs);
|
||||
list-style: none;
|
||||
padding: var(--c--globals--spacings--sm) var(--c--globals--spacings--base);
|
||||
margin: 0;
|
||||
max-height: 300px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.thread-labels-widget__popup__overlay {
|
||||
.labels-widget__popup__overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -74,14 +68,14 @@
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.thread-labels-widget__popup__content__empty .c__button {
|
||||
.labels-widget__popup__content__empty .c__button {
|
||||
text-wrap: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
|
||||
& > .thread-labels-widget__popup__content__empty__button-label {
|
||||
& > .labels-widget__popup__content__empty__button-label {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
@@ -0,0 +1,250 @@
|
||||
import { ThreadLabel, TreeLabel, useLabelsAddThreadsCreate, useLabelsList, useLabelsRemoveThreadsCreate } from "@/features/api/gen";
|
||||
import { Thread } from "@/features/api/gen/models";
|
||||
import { Icon, IconType, Spinner } from "@gouvfr-lasuite/ui-kit";
|
||||
import { Button, Checkbox, Input, Tooltip, useModal } from "@gouvfr-lasuite/cunningham-react";
|
||||
import { RefObject, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMailboxContext } from "@/features/providers/mailbox";
|
||||
import StringHelper from "@/features/utils/string-helper";
|
||||
import useAbility, { Abilities } from "@/hooks/use-ability";
|
||||
import { usePopupPosition } from "@/hooks/use-popup-position";
|
||||
import { LabelModal } from "@/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-form-modal";
|
||||
|
||||
type LabelsWidgetProps = {
|
||||
threadIds: string[];
|
||||
// Fallback for a deep-linked single thread that is not in `threads.results`
|
||||
// yet (e.g. filter active). Lets the popup display the right checked state.
|
||||
initialLabels?: readonly ThreadLabel[];
|
||||
}
|
||||
|
||||
export const LabelsWidget = ({ threadIds, initialLabels }: LabelsWidgetProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { selectedMailbox, threads } = useMailboxContext();
|
||||
const canManageLabels = useAbility(Abilities.CAN_MANAGE_MAILBOX_LABELS, selectedMailbox);
|
||||
const { data: labelsList, isLoading: isLoadingLabelsList } = useLabelsList(
|
||||
{ mailbox_id: selectedMailbox!.id },
|
||||
{ query: { enabled: canManageLabels } }
|
||||
);
|
||||
const [isPopupOpen, setIsPopupOpen] = useState(false);
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const labelCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
const fromThreads = threads?.results.filter((thread: Thread) => threadIds.includes(thread.id)) ?? [];
|
||||
if (fromThreads.length > 0) {
|
||||
for (const thread of fromThreads) {
|
||||
for (const label of thread.labels) {
|
||||
counts.set(label.id, (counts.get(label.id) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
} else if (initialLabels && threadIds.length === 1) {
|
||||
for (const label of initialLabels) {
|
||||
counts.set(label.id, 1);
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}, [threads?.results, threadIds, initialLabels]);
|
||||
|
||||
if (!canManageLabels) return null;
|
||||
|
||||
if (isLoadingLabelsList) {
|
||||
return (
|
||||
<div className="labels-widget" aria-busy={true}>
|
||||
<Tooltip
|
||||
content={
|
||||
<span className="labels-widget__loading-labels-tooltip-content">
|
||||
<Spinner size="sm" />
|
||||
{t('Loading labels...')}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="nano"
|
||||
variant="tertiary"
|
||||
aria-label={t('Add label')}
|
||||
icon={<Icon type={IconType.OUTLINED} name="new_label" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="labels-widget" ref={anchorRef}>
|
||||
<Tooltip content={t('Add label')}>
|
||||
<Button
|
||||
onClick={() => setIsPopupOpen(true)}
|
||||
size="nano"
|
||||
variant="tertiary"
|
||||
aria-label={t('Add label')}
|
||||
disabled={threadIds.length === 0}
|
||||
icon={<Icon type={IconType.OUTLINED} name="new_label" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
{isPopupOpen && (
|
||||
<LabelsPopup
|
||||
anchorRef={anchorRef}
|
||||
onClose={() => setIsPopupOpen(false)}
|
||||
labels={labelsList!.data || []}
|
||||
threadIds={threadIds}
|
||||
labelCounts={labelCounts}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type LabelsPopupProps = {
|
||||
labels: TreeLabel[];
|
||||
threadIds: string[];
|
||||
labelCounts: Map<string, number>;
|
||||
anchorRef: RefObject<HTMLElement | null>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type LabelOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
checked: boolean;
|
||||
indeterminate: boolean;
|
||||
}
|
||||
|
||||
export const LabelsPopup = ({ labels = [], threadIds, labelCounts, anchorRef, onClose }: LabelsPopupProps) => {
|
||||
const { t } = useTranslation();
|
||||
const {open, close, isOpen} = useModal();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const { invalidateThreadMessages } = useMailboxContext();
|
||||
const totalThreads = threadIds.length;
|
||||
const position = usePopupPosition(anchorRef, true, (rect) => {
|
||||
const top = rect.bottom + 4;
|
||||
return {
|
||||
top,
|
||||
right: Math.max(8, window.innerWidth - rect.right - 100),
|
||||
maxHeight: Math.min(300, Math.max(0, window.innerHeight - top - 8)),
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
e.stopImmediatePropagation();
|
||||
onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
return () => window.removeEventListener('keydown', onKey, true);
|
||||
}, [onClose]);
|
||||
|
||||
const getFlattenLabelOptions = (label: TreeLabel): LabelOption[] => {
|
||||
const children: LabelOption[] = label.children.length > 0
|
||||
? label.children.flatMap((child) => getFlattenLabelOptions(child))
|
||||
: [];
|
||||
|
||||
const count = labelCounts.get(label.id) ?? 0;
|
||||
const checked = totalThreads > 0 && count === totalThreads;
|
||||
const indeterminate = count > 0 && count < totalThreads;
|
||||
|
||||
return [{
|
||||
label: label.name,
|
||||
value: label.id,
|
||||
checked,
|
||||
indeterminate,
|
||||
}, ...children];
|
||||
}
|
||||
|
||||
const labelsOptions = labels
|
||||
.flatMap((label) => getFlattenLabelOptions(label))
|
||||
.filter((option) => {
|
||||
const normalizedLabel = StringHelper.normalizeForSearch(option.label);
|
||||
const normalizedSearchQuery = StringHelper.normalizeForSearch(searchQuery);
|
||||
return normalizedLabel.includes(normalizedSearchQuery);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.checked !== b.checked) return a.checked ? -1 : 1;
|
||||
if (a.indeterminate !== b.indeterminate) return a.indeterminate ? -1 : 1;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
|
||||
const addLabelMutation = useLabelsAddThreadsCreate({
|
||||
mutation: {
|
||||
onSuccess: () => invalidateThreadMessages()
|
||||
}
|
||||
});
|
||||
const deleteLabelMutation = useLabelsRemoveThreadsCreate({
|
||||
mutation: {
|
||||
onSuccess: () => invalidateThreadMessages()
|
||||
}
|
||||
});
|
||||
|
||||
const handleAddLabel = (labelId: string) => {
|
||||
addLabelMutation.mutate({
|
||||
id: labelId,
|
||||
data: { thread_ids: threadIds },
|
||||
});
|
||||
}
|
||||
const handleDeleteLabel = (labelId: string) => {
|
||||
deleteLabelMutation.mutate({
|
||||
id: labelId,
|
||||
data: { thread_ids: threadIds },
|
||||
});
|
||||
}
|
||||
|
||||
const handleToggle = (option: LabelOption) => {
|
||||
if (option.checked) {
|
||||
handleDeleteLabel(option.value);
|
||||
} else {
|
||||
handleAddLabel(option.value);
|
||||
}
|
||||
}
|
||||
|
||||
if (!position) return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div className="labels-widget__popup__overlay" onClick={onClose}></div>
|
||||
<div
|
||||
className="labels-widget__popup"
|
||||
style={{ position: 'fixed', top: position.top, right: position.right, maxHeight: position.maxHeight }}
|
||||
>
|
||||
<header className="labels-widget__popup__header">
|
||||
<h3><Icon type={IconType.OUTLINED} name="new_label" /> {t('Add labels')}</h3>
|
||||
<Input
|
||||
className="labels-widget__popup__search"
|
||||
type="search"
|
||||
icon={<Icon type={IconType.OUTLINED} name="search" />}
|
||||
label={t('Search a label')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
</header>
|
||||
<ul className="labels-widget__popup__content">
|
||||
{labelsOptions.map((option) => (
|
||||
<li key={option.value}>
|
||||
<Checkbox
|
||||
checked={option.checked}
|
||||
indeterminate={option.indeterminate}
|
||||
onChange={() => handleToggle(option)}
|
||||
label={option.label}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
<li className="labels-widget__popup__content__empty">
|
||||
<Button color="brand" variant="primary" onClick={open} fullWidth icon={<Icon type={IconType.OUTLINED} name="add" />}>
|
||||
<span className="labels-widget__popup__content__empty__button-label">
|
||||
{searchQuery && labelsOptions.length === 0 ? t('Create the label "{{label}}"', { label: searchQuery }) : t('Create a new label')}
|
||||
</span>
|
||||
</Button>
|
||||
<LabelModal
|
||||
isOpen={isOpen}
|
||||
onClose={close}
|
||||
label={{ display_name: searchQuery }}
|
||||
onSuccess={(label) => { handleAddLabel(label.id)}}
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { createContext, PropsWithChildren, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
type LayoutContextBase = {
|
||||
isLeftPanelOpen: boolean;
|
||||
setIsLeftPanelOpen: (open: boolean) => void;
|
||||
toggleLeftPanel: () => void;
|
||||
closeLeftPanel: () => void;
|
||||
openLeftPanel: () => void;
|
||||
};
|
||||
|
||||
type LayoutDragContext = {
|
||||
isDragging: boolean;
|
||||
setIsDragging: (prevState: boolean) => void;
|
||||
dragAction: string | null;
|
||||
setDragAction: (action: string | null) => void;
|
||||
// Safari fallback: `e.shiftKey` on drag events is always `false` in
|
||||
// WebKit, so callers OR it with this getter. The ref is kept fresh by
|
||||
// a document-level keydown/keyup listener. Caveat: Safari also stops
|
||||
// firing keyboard events entirely while a drag is in progress, so on
|
||||
// Safari the returned value is effectively the Shift state at
|
||||
// `dragstart` time — pressing/releasing Shift mid-drag has no effect.
|
||||
// Chrome/Firefox populate `e.shiftKey` natively, so the getter is only
|
||||
// the safety net. Exposed as a getter (not state) to avoid rerendering
|
||||
// every consumer on each keystroke.
|
||||
getIsShiftHeld: () => boolean;
|
||||
};
|
||||
|
||||
export type LayoutContextType = LayoutContextBase & Partial<LayoutDragContext>;
|
||||
|
||||
const LayoutContext = createContext<LayoutContextType | undefined>(undefined);
|
||||
|
||||
type LayoutProviderProps = PropsWithChildren<{
|
||||
draggable?: boolean;
|
||||
}>;
|
||||
|
||||
export const LayoutProvider = ({ children, draggable = false }: LayoutProviderProps) => {
|
||||
const [isLeftPanelOpen, setIsLeftPanelOpen] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragAction, setDragAction] = useState<string | null>(null);
|
||||
const isShiftHeldRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draggable) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
isShiftHeldRef.current = e.shiftKey;
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
window.addEventListener('keyup', handler);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handler);
|
||||
window.removeEventListener('keyup', handler);
|
||||
};
|
||||
}, [draggable]);
|
||||
|
||||
const value = useMemo<LayoutContextType>(() => {
|
||||
const base: LayoutContextBase = {
|
||||
isLeftPanelOpen,
|
||||
setIsLeftPanelOpen,
|
||||
toggleLeftPanel: () => setIsLeftPanelOpen(!isLeftPanelOpen),
|
||||
closeLeftPanel: () => setIsLeftPanelOpen(false),
|
||||
openLeftPanel: () => setIsLeftPanelOpen(true),
|
||||
};
|
||||
if (!draggable) return base;
|
||||
return {
|
||||
...base,
|
||||
isDragging,
|
||||
setIsDragging,
|
||||
dragAction,
|
||||
setDragAction,
|
||||
getIsShiftHeld: () => isShiftHeldRef.current,
|
||||
};
|
||||
}, [draggable, isLeftPanelOpen, isDragging, dragAction]);
|
||||
|
||||
return <LayoutContext.Provider value={value}>{children}</LayoutContext.Provider>;
|
||||
};
|
||||
|
||||
export const useLayoutContext = () => {
|
||||
const context = useContext(LayoutContext);
|
||||
if (!context) throw new Error("useLayoutContext must be used within a LayoutProvider");
|
||||
return context;
|
||||
};
|
||||
|
||||
export const useLayoutDragContext = (): LayoutContextBase & LayoutDragContext => {
|
||||
const context = useLayoutContext();
|
||||
if (context.setIsDragging === undefined) {
|
||||
throw new Error("useLayoutDragContext requires a drag-enabled LayoutProvider");
|
||||
}
|
||||
return context as LayoutContextBase & LayoutDragContext;
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import { useRouter } from "next/router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@gouvfr-lasuite/cunningham-react";
|
||||
import { useMailboxContext } from "@/features/providers/mailbox";
|
||||
import { useLayoutContext } from "../../../main";
|
||||
import { useLayoutContext } from "@/features/layouts/components/layout-context";
|
||||
import useAbility, { Abilities } from "@/hooks/use-ability";
|
||||
import { Icon, IconType, Spinner } from "@gouvfr-lasuite/ui-kit";
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TreeLabel, ThreadsStatsRetrieveStatsFields, useLabelsDestroy, useLabelsList, useThreadsStatsRetrieve, ThreadsStatsRetrieve200, useLabelsAddThreadsCreate, useLabelsRemoveThreadsCreate, useLabelsPartialUpdate } from "@/features/api/gen";
|
||||
import { TreeLabel, ThreadsStatsRetrieveStatsFields, useLabelsDestroy, useLabelsList, useThreadsStatsRetrieve, ThreadsStatsRetrieve200, useLabelsAddThreadsCreate, useLabelsRemoveThreadsCreate, useLabelsPartialUpdate, useFlagCreate } from "@/features/api/gen";
|
||||
import { FlagEnum } from "@/features/api/gen/models";
|
||||
import { getThreadsStatsQueryKey, useMailboxContext } from "@/features/providers/mailbox";
|
||||
import { DropdownMenu, Icon, IconSize, IconType } from "@gouvfr-lasuite/ui-kit";
|
||||
import { Button, useModals } from "@gouvfr-lasuite/cunningham-react";
|
||||
@@ -8,7 +9,7 @@ import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useLayoutContext } from "@/features/layouts/components/main";
|
||||
import { useLayoutDragContext } from "@/features/layouts/components/layout-context";
|
||||
import router from "next/router";
|
||||
import { MAILBOX_FOLDERS } from "../../../mailbox-list";
|
||||
import { addToast, ToasterItem } from "@/features/ui/components/toaster";
|
||||
@@ -16,6 +17,7 @@ import { toast } from "react-toastify";
|
||||
import { useFold } from "@/features/providers/fold";
|
||||
import { SubLabelCreation } from "../label-form-modal";
|
||||
import { handle } from "@/features/utils/errors";
|
||||
import ViewHelper from "@/features/utils/view-helper";
|
||||
|
||||
export type LabelTransferData = {
|
||||
type: 'label';
|
||||
@@ -48,7 +50,7 @@ export const LabelItem = ({ level = 0, onEdit, canManage, defaultFoldState, ...l
|
||||
}
|
||||
});
|
||||
const unreadCount = (stats?.data as ThreadsStatsRetrieve200)?.all_unread ?? 0;
|
||||
const { closeLeftPanel } = useLayoutContext();
|
||||
const { closeLeftPanel, setDragAction, getIsShiftHeld } = useLayoutDragContext();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useTranslation();
|
||||
@@ -58,6 +60,9 @@ export const LabelItem = ({ level = 0, onEdit, canManage, defaultFoldState, ...l
|
||||
const foldKey = useMemo(() => `label-item-${label.display_name}${label.children.length > 0 ? `-with-children` : ''}`, [label.display_name, label.children.length]);
|
||||
const { isFolded, toggle, setFoldState } = useFold(foldKey, isFoldedByDefault);
|
||||
const foldTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const shouldAutoArchive = !ViewHelper.isArchivedView() && !ViewHelper.isSpamView() && !ViewHelper.isTrashedView() && !ViewHelper.isDraftsView();
|
||||
|
||||
const { mutate: flagMutate } = useFlagCreate();
|
||||
|
||||
const unfoldIfNeeded = useEffectEvent(() => {
|
||||
if (isFolded) {
|
||||
@@ -106,32 +111,9 @@ export const LabelItem = ({ level = 0, onEdit, canManage, defaultFoldState, ...l
|
||||
|
||||
const addThreadMutation = useLabelsAddThreadsCreate({
|
||||
mutation: {
|
||||
onSuccess: (_, variables) => {
|
||||
// Invalidate relevant queries to refresh the UI
|
||||
onSuccess: () => {
|
||||
invalidateThreadMessages();
|
||||
invalidateThreadsStats();
|
||||
|
||||
// Show success toast
|
||||
const threadCount = variables.data.thread_ids!.length;
|
||||
|
||||
addToast(
|
||||
<ToasterItem
|
||||
type="info"
|
||||
actions={[{
|
||||
label: t('Undo'), onClick: () => deleteThreadMutation.mutate(variables)
|
||||
}]}
|
||||
>
|
||||
<Icon name="label" type={IconType.OUTLINED} />
|
||||
<span>{t('Label "{{label}}" assigned to {{count}} threads.', {
|
||||
count: threadCount,
|
||||
label: label.name,
|
||||
defaultValue_one: "Label \"{{label}}\" assigned to this thread.",
|
||||
defaultValue_other: "Label \"{{label}}\" assigned to {{count}} threads.",
|
||||
})}</span>
|
||||
</ToasterItem>, {
|
||||
toastId: JSON.stringify(variables),
|
||||
}
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -145,14 +127,29 @@ export const LabelItem = ({ level = 0, onEdit, canManage, defaultFoldState, ...l
|
||||
name: label.name
|
||||
}
|
||||
} as LabelTransferData));
|
||||
e.dataTransfer.effectAllowed = 'link'
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'link';
|
||||
setIsDragOver(true);
|
||||
|
||||
if (e.dataTransfer.types.includes('text/thread-drag')) {
|
||||
// Safari doesn't expose `e.shiftKey` on drag events, fall back to the
|
||||
// globally-tracked ref.
|
||||
const shiftHeld = e.shiftKey || getIsShiftHeld();
|
||||
// Auto-archive also requires at least one editable thread in the
|
||||
// dragged selection — archive is a shared-state mutation. The
|
||||
// source advertises this via `text/thread-editable`. Labelling
|
||||
// alone only needs mailbox-level `manage_labels` (enforced by
|
||||
// `canManage` on the drop handlers below).
|
||||
const hasEditable = e.dataTransfer.types.includes('text/thread-editable');
|
||||
const action = !shiftHeld && shouldAutoArchive && hasEditable
|
||||
? t('Assign this label and archive')
|
||||
: t('Assign this label');
|
||||
setDragAction(action);
|
||||
}
|
||||
|
||||
if (!foldTimeoutRef.current) {
|
||||
foldTimeoutRef.current = setTimeout(() => {
|
||||
if (isFolded === true) toggle();
|
||||
@@ -162,24 +159,125 @@ export const LabelItem = ({ level = 0, onEdit, canManage, defaultFoldState, ...l
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setIsDragOver(false);
|
||||
setDragAction(null);
|
||||
if (foldTimeoutRef.current) {
|
||||
clearTimeout(foldTimeoutRef.current);
|
||||
foldTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDropThread = (transferData: { threadIds?: string[], labels: string[] }) => {
|
||||
const handleDropThread = (transferData: { threadIds?: string[], labels: string[], hasEditable?: boolean }, shiftKeyHeld: boolean = false) => {
|
||||
const canBeAssigned = !transferData.labels.includes(label.id);
|
||||
if (!canBeAssigned) return;
|
||||
|
||||
if (transferData.threadIds && transferData.threadIds.length > 0) {
|
||||
addThreadMutation.mutate({
|
||||
id: label.id,
|
||||
data: {
|
||||
thread_ids: transferData.threadIds,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!transferData.threadIds || transferData.threadIds.length === 0) return;
|
||||
|
||||
const threadIds = transferData.threadIds;
|
||||
// Archive is gated on per-thread edit rights; without any editable
|
||||
// thread we fall back to assign-only.
|
||||
const doArchive = !shiftKeyHeld && shouldAutoArchive && transferData.hasEditable === true;
|
||||
const toastId = `label-assign-${label.id}-${Date.now()}`;
|
||||
|
||||
addThreadMutation.mutate({
|
||||
id: label.id,
|
||||
data: { thread_ids: threadIds },
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
if (doArchive) {
|
||||
flagMutate({
|
||||
data: { flag: FlagEnum.archived, value: true, thread_ids: threadIds },
|
||||
}, {
|
||||
onSuccess: (response) => {
|
||||
invalidateThreadMessages();
|
||||
invalidateThreadsStats();
|
||||
|
||||
// Mirror the `useFlag` toast pattern: label assignment is
|
||||
// fully successful under the current permission model (we
|
||||
// relaxed the per-thread edit check; all dragged threads
|
||||
// belong to the label's mailbox), so partial/none status
|
||||
// is driven by the archive mutation alone.
|
||||
const responseData = response.data as Record<string, unknown>;
|
||||
const archivedCount = typeof responseData.updated_threads === 'number'
|
||||
? responseData.updated_threads
|
||||
: threadIds.length;
|
||||
const submittedCount = threadIds.length;
|
||||
const isNone = archivedCount === 0;
|
||||
const isPartial = archivedCount > 0 && archivedCount < submittedCount;
|
||||
const toastType = isNone ? 'error' : isPartial ? 'warning' : 'info';
|
||||
|
||||
const undo = () => {
|
||||
deleteThreadMutation.mutate({
|
||||
id: label.id,
|
||||
data: { thread_ids: threadIds },
|
||||
});
|
||||
if (archivedCount > 0) {
|
||||
flagMutate({
|
||||
data: { flag: FlagEnum.archived, value: false, thread_ids: threadIds },
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
invalidateThreadMessages();
|
||||
invalidateThreadsStats();
|
||||
toast.dismiss(toastId);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
toast.dismiss(toastId);
|
||||
}
|
||||
};
|
||||
|
||||
const mainMessage = isNone
|
||||
? t('Label "{{label}}" assigned, but no threads could be archived.', { label: label.name })
|
||||
: isPartial
|
||||
? t('Label "{{label}}" assigned. {{count}} of {{total}} threads archived.', {
|
||||
count: archivedCount,
|
||||
total: submittedCount,
|
||||
label: label.name,
|
||||
})
|
||||
: t('Label "{{label}}" assigned and {{count}} threads archived.', {
|
||||
count: archivedCount,
|
||||
label: label.name,
|
||||
defaultValue_one: 'Label "{{label}}" assigned and thread archived.',
|
||||
defaultValue_other: 'Label "{{label}}" assigned and {{count}} threads archived.',
|
||||
});
|
||||
|
||||
addToast(
|
||||
<ToasterItem type={toastType} actions={[{ label: t('Undo'), onClick: undo }]}>
|
||||
<Icon name="label" type={IconType.OUTLINED} />
|
||||
<div>
|
||||
<p>{mainMessage}</p>
|
||||
{(isPartial || isNone) && (
|
||||
<p>{t('You may not have sufficient permissions for all selected threads.')}</p>
|
||||
)}
|
||||
</div>
|
||||
</ToasterItem>,
|
||||
{ toastId }
|
||||
);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const undo = () => {
|
||||
deleteThreadMutation.mutate({
|
||||
id: label.id,
|
||||
data: { thread_ids: threadIds },
|
||||
});
|
||||
toast.dismiss(toastId);
|
||||
};
|
||||
|
||||
addToast(
|
||||
<ToasterItem type="info" actions={[{ label: t('Undo'), onClick: undo }]}>
|
||||
<Icon name="label" type={IconType.OUTLINED} />
|
||||
<span>{t('Label "{{label}}" assigned to {{count}} threads.', {
|
||||
count: threadIds.length,
|
||||
label: label.name,
|
||||
defaultValue_one: 'Label "{{label}}" assigned to this thread.',
|
||||
defaultValue_other: 'Label "{{label}}" assigned to {{count}} threads.',
|
||||
})}</span>
|
||||
</ToasterItem>,
|
||||
{ toastId }
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const handleDropLabel = (transferData: LabelTransferData) => {
|
||||
@@ -206,13 +304,14 @@ export const LabelItem = ({ level = 0, onEdit, canManage, defaultFoldState, ...l
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
setDragAction(null);
|
||||
const rawData = e.dataTransfer.getData('application/json');
|
||||
if (!rawData) return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(rawData);
|
||||
|
||||
if (data.type === 'thread') handleDropThread(data);
|
||||
if (data.type === 'thread') handleDropThread(data, e.shiftKey || getIsShiftHeld());
|
||||
else if (data.type === 'label') handleDropLabel(data);
|
||||
} catch (error) {
|
||||
handle(new Error('Error parsing drag data.'), { extra: { error } });
|
||||
|
||||
@@ -50,7 +50,6 @@ export const MailboxLabelsBase = ({ mailbox }: MailboxLabelsProps) => {
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLElement>) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'link';
|
||||
setIsDragOver(true);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import clsx from "clsx"
|
||||
import Link from "next/link"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useLayoutContext } from "../../../main"
|
||||
import { useLayoutContext } from "@/features/layouts/components/layout-context"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Icon, IconSize, IconType } from "@gouvfr-lasuite/ui-kit"
|
||||
import i18n from "@/features/i18n/initI18n";
|
||||
@@ -340,9 +340,14 @@ const FolderItem = ({ folder, isChild, hasChildren, isExpanded, onToggleExpand,
|
||||
const hasDeliveryFailed = (folderStats?.[ThreadsStatsRetrieveStatsFields.has_delivery_failed] ?? 0) > 0;
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLAnchorElement>) => {
|
||||
// All folder actions (archive/spam/trash and restore-to-inbox)
|
||||
// mutate shared thread state, so the drop is refused when the
|
||||
// dragged selection has no editable thread. The source advertises
|
||||
// this via the `text/thread-editable` dataTransfer type (the JSON
|
||||
// payload is not readable on dragover).
|
||||
if (!e.dataTransfer.types.includes('text/thread-editable')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'link';
|
||||
setIsDragOver(true);
|
||||
};
|
||||
|
||||
@@ -361,6 +366,10 @@ const FolderItem = ({ folder, isChild, hasChildren, isExpanded, onToggleExpand,
|
||||
try {
|
||||
const data = JSON.parse(rawData);
|
||||
if (data.type !== 'thread' || !data.threadIds?.length) return;
|
||||
// Defence in depth: dragover already filtered this out, but
|
||||
// re-check on drop in case a source ever sets the JSON without
|
||||
// the dataTransfer type (e.g. programmatic DnD in tests).
|
||||
if (!data.hasEditable) return;
|
||||
|
||||
const threadIds = data.threadIds as string[];
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useMailboxContext } from "@/features/providers/mailbox";
|
||||
import { Button } from "@gouvfr-lasuite/cunningham-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useLayoutContext } from "../main";
|
||||
import { useLayoutContext } from "@/features/layouts/components/layout-context";
|
||||
import { MailboxLabels } from "./components/mailbox-labels";
|
||||
import { useState } from "react";
|
||||
|
||||
|
||||
@@ -211,7 +211,7 @@
|
||||
border-top: 1px solid var(--c--contextuals--border--semantic--neutral--default);
|
||||
}
|
||||
|
||||
// Tags Selector (reuses thread-labels-widget and cunningham styles)
|
||||
// Tags Selector (reuses labels-widget and cunningham styles)
|
||||
.tags-selector {
|
||||
position: relative;
|
||||
width: 100% !important;
|
||||
@@ -257,7 +257,3 @@
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.tags-selector__popup {
|
||||
left: 0;
|
||||
right: auto;
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ import { TreeLabel, ThreadLabel, useLabelsList } from "@/features/api/gen";
|
||||
import { Icon, IconType, IconSize, Spinner } from "@gouvfr-lasuite/ui-kit";
|
||||
import { Button, Checkbox, Field, Input, LabelledBox, useModal } from "@gouvfr-lasuite/cunningham-react";
|
||||
import { useState, useMemo, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMailboxContext } from "@/features/providers/mailbox";
|
||||
import StringHelper from "@/features/utils/string-helper";
|
||||
import { LabelModal } from "@/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-form-modal";
|
||||
import { Badge } from "@/features/ui/components/badge";
|
||||
import { ColorHelper } from "@/features/utils/color-helper";
|
||||
import { usePopupPosition } from "@/hooks/use-popup-position";
|
||||
|
||||
type TagsSelectorProps = {
|
||||
selectedTags: string[];
|
||||
@@ -43,6 +45,15 @@ export const TagsSelector = ({ selectedTags, onTagsChange }: TagsSelectorProps)
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isPopupOpen, setIsPopupOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const position = usePopupPosition(containerRef, isPopupOpen, (rect) => {
|
||||
const top = rect.bottom + 4;
|
||||
const popupWidth = Math.min(400, window.innerWidth * 0.6);
|
||||
return {
|
||||
top,
|
||||
left: Math.max(8, Math.min(rect.left, window.innerWidth - popupWidth - 8)),
|
||||
maxHeight: Math.min(300, Math.max(0, window.innerHeight - top - 8)),
|
||||
};
|
||||
});
|
||||
|
||||
const { data: labelsList, isLoading } = useLabelsList(
|
||||
{ mailbox_id: selectedMailbox?.id ?? '' },
|
||||
@@ -162,13 +173,17 @@ export const TagsSelector = ({ selectedTags, onTagsChange }: TagsSelectorProps)
|
||||
</LabelledBox>
|
||||
</div>
|
||||
|
||||
{isPopupOpen && (
|
||||
{isPopupOpen && position && createPortal(
|
||||
<>
|
||||
<div className="thread-labels-widget__popup tags-selector__popup">
|
||||
<header className="thread-labels-widget__popup__header">
|
||||
<div className="labels-widget__popup__overlay" onClick={() => setIsPopupOpen(false)}></div>
|
||||
<div
|
||||
className="labels-widget__popup"
|
||||
style={{ position: 'fixed', top: position.top, left: position.left, maxHeight: position.maxHeight }}
|
||||
>
|
||||
<header className="labels-widget__popup__header">
|
||||
<h3><Icon type={IconType.OUTLINED} name="new_label" /> {t('Add tags')}</h3>
|
||||
<Input
|
||||
className="thread-labels-widget__popup__search"
|
||||
className="labels-widget__popup__search"
|
||||
type="search"
|
||||
icon={<Icon type={IconType.OUTLINED} name="search" />}
|
||||
label={t('Search a tag')}
|
||||
@@ -177,7 +192,7 @@ export const TagsSelector = ({ selectedTags, onTagsChange }: TagsSelectorProps)
|
||||
fullWidth
|
||||
/>
|
||||
</header>
|
||||
<ul className="thread-labels-widget__popup__content">
|
||||
<ul className="labels-widget__popup__content">
|
||||
{labelsOptions.map((option) => (
|
||||
<li key={option.id}>
|
||||
<Checkbox
|
||||
@@ -187,7 +202,7 @@ export const TagsSelector = ({ selectedTags, onTagsChange }: TagsSelectorProps)
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
<li className="thread-labels-widget__popup__content__empty">
|
||||
<li className="labels-widget__popup__content__empty">
|
||||
<Button
|
||||
type="button"
|
||||
color="brand"
|
||||
@@ -196,7 +211,7 @@ export const TagsSelector = ({ selectedTags, onTagsChange }: TagsSelectorProps)
|
||||
fullWidth
|
||||
icon={<Icon type={IconType.OUTLINED} name="add" />}
|
||||
>
|
||||
<span className="thread-labels-widget__popup__content__empty__button-label">
|
||||
<span className="labels-widget__popup__content__empty__button-label">
|
||||
{searchQuery && labelsOptions.length === 0
|
||||
? t('Create the label "{{label}}"', { label: searchQuery })
|
||||
: t('Create a new label')}
|
||||
@@ -211,8 +226,8 @@ export const TagsSelector = ({ selectedTags, onTagsChange }: TagsSelectorProps)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="thread-labels-widget__popup__overlay" onClick={() => setIsPopupOpen(false)}></div>
|
||||
</>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@ import { MessageTemplateTypeChoices, StatusEnum, useMailboxesMessageTemplatesLis
|
||||
import { CircularProgress } from "@/features/ui/components/circular-progress";
|
||||
import { TaskImportCacheHelper } from "@/features/utils/task-import-cache";
|
||||
import { useTheme } from "@/features/providers/theme";
|
||||
import { useLayoutContext } from "..";
|
||||
import { useLayoutContext } from "@/features/layouts/components/layout-context";
|
||||
|
||||
|
||||
type AuthenticatedHeaderProps = HeaderProps & {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AppLayout } from "./layout";
|
||||
import { createContext, PropsWithChildren, useContext, useState } from "react";
|
||||
import { PropsWithChildren } from "react";
|
||||
import AuthenticatedView from "./authenticated-view";
|
||||
import { MailboxProvider, useMailboxContext } from "@/features/providers/mailbox";
|
||||
import { NoMailbox } from "./no-mailbox";
|
||||
@@ -8,6 +8,7 @@ import { LeftPanel } from "./left-panel";
|
||||
import { ModalStoreProvider } from "@/features/providers/modal-store";
|
||||
import { ScrollRestoreProvider } from "@/features/providers/scroll-restore";
|
||||
import { useTheme } from "@/features/providers/theme";
|
||||
import { LayoutProvider, useLayoutDragContext } from "@/features/layouts/components/layout-context";
|
||||
import Link from "next/link";
|
||||
|
||||
export const MainLayout = ({ children }: PropsWithChildren) => {
|
||||
@@ -17,7 +18,9 @@ export const MainLayout = ({ children }: PropsWithChildren) => {
|
||||
<MailboxProvider>
|
||||
<SentBoxProvider>
|
||||
<ModalStoreProvider>
|
||||
<MainLayoutContent>{children}</MainLayoutContent>
|
||||
<LayoutProvider draggable>
|
||||
<MainLayoutContent>{children}</MainLayoutContent>
|
||||
</LayoutProvider>
|
||||
</ModalStoreProvider>
|
||||
</SentBoxProvider>
|
||||
</MailboxProvider>
|
||||
@@ -26,52 +29,27 @@ export const MainLayout = ({ children }: PropsWithChildren) => {
|
||||
)
|
||||
}
|
||||
|
||||
type LayoutContextType = {
|
||||
toggleLeftPanel: () => void;
|
||||
closeLeftPanel: () => void;
|
||||
openLeftPanel: () => void;
|
||||
isDragging: boolean;
|
||||
setIsDragging: (prevState: boolean) => void;
|
||||
}
|
||||
|
||||
export const LayoutContext = createContext<LayoutContextType | undefined>(undefined);
|
||||
|
||||
const MainLayoutContent = ({ children }: PropsWithChildren<{ simple?: boolean }>) => {
|
||||
const { mailboxes, queryStates } = useMailboxContext();
|
||||
const hasNoMailbox = queryStates.mailboxes.status === 'success' && mailboxes!.length === 0;
|
||||
const [leftPanelOpen, setLeftPanelOpen] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const { theme, variant } = useTheme();
|
||||
const { isLeftPanelOpen, setIsLeftPanelOpen, isDragging } = useLayoutDragContext();
|
||||
|
||||
return (
|
||||
<LayoutContext.Provider value={{
|
||||
toggleLeftPanel: () => setLeftPanelOpen(!leftPanelOpen),
|
||||
closeLeftPanel: () => setLeftPanelOpen(false),
|
||||
openLeftPanel: () => setLeftPanelOpen(true),
|
||||
isDragging,
|
||||
setIsDragging,
|
||||
}}>
|
||||
<AppLayout
|
||||
enableResize
|
||||
isLeftPanelOpen={leftPanelOpen}
|
||||
setIsLeftPanelOpen={setLeftPanelOpen}
|
||||
leftPanelContent={<LeftPanel hasNoMailbox={hasNoMailbox} />}
|
||||
icon={<Link href="/"><img src={`/images/${theme}/app-logo-${variant}.svg`} alt="logo" height={40} /></Link>}
|
||||
hideLeftPanelOnDesktop={hasNoMailbox}
|
||||
isDragging={isDragging}
|
||||
>
|
||||
{hasNoMailbox ? (
|
||||
<NoMailbox />
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</AppLayout>
|
||||
</LayoutContext.Provider>
|
||||
<AppLayout
|
||||
enableResize
|
||||
isLeftPanelOpen={isLeftPanelOpen}
|
||||
setIsLeftPanelOpen={setIsLeftPanelOpen}
|
||||
leftPanelContent={<LeftPanel hasNoMailbox={hasNoMailbox} />}
|
||||
icon={<Link href="/"><img src={`/images/${theme}/app-logo-${variant}.svg`} alt="logo" height={40} /></Link>}
|
||||
hideLeftPanelOnDesktop={hasNoMailbox}
|
||||
isDragging={isDragging}
|
||||
>
|
||||
{hasNoMailbox ? (
|
||||
<NoMailbox />
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export const useLayoutContext = () => {
|
||||
const context = useContext(LayoutContext);
|
||||
if (!context) throw new Error("useLayoutContext must be used within a LayoutContext.Provider");
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -202,14 +202,53 @@
|
||||
|
||||
// Drag preview styles
|
||||
.thread-drag-preview {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: -100px;
|
||||
z-index: 100000;
|
||||
background: var(--c--contextuals--background--semantic--brand--primary);
|
||||
background: color-mix(in srgb, var(--c--contextuals--background--semantic--brand--primary) 90%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
color: var(--c--contextuals--content--semantic--brand--on-brand);
|
||||
border-radius: 50vw;
|
||||
border: 1px solid var(--c--contextuals--border--semantic--brand--primary);
|
||||
font-size: var(--c--globals--font--sizes--xs);
|
||||
line-height: 1;
|
||||
box-shadow: none;
|
||||
font-weight: 700;
|
||||
padding: var(--c--globals--spacings--xs) var(--c--globals--spacings--sm);
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
cursor: grabbing;
|
||||
animation: popIn 0.15s var(--c--globals--transitions--ease-in-out);
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
.thread-drag-preview--exiting {
|
||||
animation: popOut 0.15s var(--c--globals--transitions--ease-in-out) forwards;
|
||||
}
|
||||
|
||||
@keyframes popIn {
|
||||
0% {
|
||||
scale: 0.7;
|
||||
opacity: 0;
|
||||
}
|
||||
20% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
scale: 1;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes popOut {
|
||||
0% {
|
||||
scale: 1;
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
scale: 0.7;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from "react-i18next"
|
||||
import Link from "next/link"
|
||||
import { useParams, useSearchParams } from "next/navigation"
|
||||
import { useMemo, useRef, useState } from "react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import clsx from "clsx"
|
||||
import { DateHelper } from "@/features/utils/date-helper"
|
||||
@@ -13,8 +13,9 @@ import { PORTALS } from "@/features/config/constants"
|
||||
import { Checkbox } from "@gouvfr-lasuite/cunningham-react"
|
||||
import { Icon, IconSize, IconType } from "@gouvfr-lasuite/ui-kit"
|
||||
import { LabelBadge } from "@/features/ui/components/label-badge"
|
||||
import { useLayoutContext } from "../../../main"
|
||||
import { useLayoutDragContext } from "@/features/layouts/components/layout-context"
|
||||
import ViewHelper from "@/features/utils/view-helper"
|
||||
import useCanEditThreads from "@/features/message/use-can-edit-threads"
|
||||
|
||||
type ThreadItemProps = {
|
||||
thread: Thread
|
||||
@@ -29,7 +30,8 @@ export const ThreadItem = ({ thread, isSelected, onToggleSelection, selectedThre
|
||||
const params = useParams<{ mailboxId: string, threadId: string }>()
|
||||
const searchParams = useSearchParams()
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const { setIsDragging: setGlobalDragging } = useLayoutContext();
|
||||
const [isExiting, setIsExiting] = useState(false)
|
||||
const { setIsDragging: setGlobalDragging, setDragAction } = useLayoutDragContext();
|
||||
const dragPreviewContainer = useRef(document.getElementById(PORTALS.DRAG_PREVIEW));
|
||||
const threadDate = useMemo(() => {
|
||||
if (ViewHelper.isInboxView() && thread.active_messaged_at) {
|
||||
@@ -63,6 +65,16 @@ export const ThreadItem = ({ thread, isSelected, onToggleSelection, selectedThre
|
||||
const hasSelection = isSelectionMode || selectedThreadIds.size > 0;
|
||||
const showCheckbox = hasSelection;
|
||||
|
||||
// Used by drop zones (folders, label auto-archive) to decide whether
|
||||
// the dragged threads can be mutated. Expressed as a dataTransfer
|
||||
// type so drop zones can read it on dragover — JSON payloads are only
|
||||
// readable on drop per the browser security model.
|
||||
const dragThreadIds = useMemo(
|
||||
() => isSelectionMode ? Array.from(selectedThreadIds) : [thread.id],
|
||||
[isSelectionMode, selectedThreadIds, thread.id]
|
||||
);
|
||||
const hasEditableInDrag = useCanEditThreads(dragThreadIds);
|
||||
|
||||
const handleCheckboxClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onToggleSelection(thread.id, e.shiftKey, e.ctrlKey || e.metaKey);
|
||||
@@ -91,27 +103,49 @@ export const ThreadItem = ({ thread, isSelected, onToggleSelection, selectedThre
|
||||
setIsDragging(true)
|
||||
setGlobalDragging(true)
|
||||
|
||||
// If this thread is selected, drag all selected threads
|
||||
const threadsToDrag = isSelectionMode ? Array.from(selectedThreadIds) : [thread.id];
|
||||
|
||||
e.dataTransfer.setData('application/json', JSON.stringify({
|
||||
type: 'thread',
|
||||
threadIds: threadsToDrag,
|
||||
threadIds: dragThreadIds,
|
||||
labels: isSelectionMode ? [] : thread.labels.map((label) => label.id),
|
||||
hasEditable: hasEditableInDrag,
|
||||
}));
|
||||
e.dataTransfer.effectAllowed = 'link'
|
||||
// Set the drag image
|
||||
if (dragPreviewContainer.current) {
|
||||
e.dataTransfer.setDragImage(dragPreviewContainer.current, 40, 40)
|
||||
e.dataTransfer.setData('text/thread-drag', '');
|
||||
// Advertised on dragover so folder drop zones can refuse drops
|
||||
// when the dragged selection has no editable thread (archive,
|
||||
// spam, trash and restore-to-inbox all require edit rights).
|
||||
if (hasEditableInDrag) {
|
||||
e.dataTransfer.setData('text/thread-editable', '');
|
||||
}
|
||||
|
||||
// Hide native drag image by using an offscreen empty element as drag image
|
||||
// (Safari-compatible — `new Image()` with an inline data URL is unreliable
|
||||
// because Safari requires the image to be loaded/in the DOM).
|
||||
// The ThreadDragPreview portal follows the cursor instead.
|
||||
const ghost = document.createElement('div');
|
||||
ghost.style.cssText = 'position:absolute;top:-1000px;left:-1000px;width:1px;height:1px;';
|
||||
document.body.appendChild(ghost);
|
||||
e.dataTransfer.setDragImage(ghost, 0, 0);
|
||||
setTimeout(() => document.body.removeChild(ghost), 0);
|
||||
}
|
||||
const handleDragEnd = () => {
|
||||
setIsDragging(false);
|
||||
setGlobalDragging(false);
|
||||
setIsExiting(true);
|
||||
};
|
||||
|
||||
const handleExitEnd = () => {
|
||||
setIsExiting(false);
|
||||
setDragAction(null);
|
||||
};
|
||||
|
||||
const dragCount = selectedThreadIds.size > 0 ? selectedThreadIds.size : 1;
|
||||
|
||||
// Clear any pending drag action if the item unmounts before the
|
||||
// exit animation completes (e.g. archived after label assignment),
|
||||
// otherwise the stale action would leak into the next drag preview.
|
||||
useEffect(() => () => setDragAction(null), [setDragAction]);
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
@@ -260,8 +294,12 @@ export const ThreadItem = ({ thread, isSelected, onToggleSelection, selectedThre
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
{isDragging && dragPreviewContainer.current && createPortal(
|
||||
<ThreadDragPreview count={dragCount} />,
|
||||
{(isDragging || isExiting) && dragPreviewContainer.current && createPortal(
|
||||
<ThreadDragPreview
|
||||
count={dragCount}
|
||||
exiting={isExiting}
|
||||
onExitEnd={handleExitEnd}
|
||||
/>,
|
||||
dragPreviewContainer.current
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,18 +1,51 @@
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLayoutDragContext } from "@/features/layouts/components/layout-context";
|
||||
|
||||
type ThreadDragPreviewProps = {
|
||||
count: number;
|
||||
exiting?: boolean;
|
||||
onExitEnd?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* This component is used to display a preview of a thread when it is being dragged.
|
||||
* It aims to be rendered within the portal dedicated to drag preview '#drag-preview-container''
|
||||
* Take a look at `_document.tsx`
|
||||
* Custom drag preview that follows the cursor.
|
||||
* The native drag image is hidden (transparent 1x1 pixel) so this component
|
||||
* acts as the visible drag feedback. It reads `dragAction` from layout context
|
||||
* to display the current drop action (e.g. "Assign + Archive").
|
||||
*/
|
||||
export const ThreadDragPreview = ({ count }: { count: number }) => {
|
||||
export const ThreadDragPreview = ({ count, exiting, onExitEnd }: ThreadDragPreviewProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { dragAction } = useLayoutDragContext();
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Follow the cursor and mark the whole document as a valid drop target
|
||||
// (preventDefault on dragover) to suppress Chrome's native "snap-back"
|
||||
// animation on drops outside real drop zones, which would otherwise
|
||||
// delay `dragend` by ~500-800ms and defer our exit animation.
|
||||
const handler = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (ref.current) {
|
||||
ref.current.style.left = `${e.clientX + 7}px`;
|
||||
ref.current.style.top = `${e.clientY - 7}px`;
|
||||
}
|
||||
};
|
||||
document.addEventListener('dragover', handler, true);
|
||||
return () => document.removeEventListener('dragover', handler, true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<span className="thread-drag-preview">
|
||||
{t('{{count}} threads selected', {
|
||||
<span
|
||||
ref={ref}
|
||||
className={clsx("thread-drag-preview", { "thread-drag-preview--exiting": exiting })}
|
||||
onAnimationEnd={exiting ? onExitEnd : undefined}
|
||||
>
|
||||
{dragAction ?? t('Move {{count}} threads', {
|
||||
count: count,
|
||||
defaultValue_one: "{{count}} thread selected",
|
||||
defaultValue_other: "{{count}} threads selected"
|
||||
defaultValue_one: "Move {{count}} thread",
|
||||
defaultValue_other: "Move {{count}} threads"
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -12,9 +12,11 @@ import useArchive from "@/features/message/use-archive";
|
||||
import useSpam from "@/features/message/use-spam";
|
||||
import useTrash from "@/features/message/use-trash";
|
||||
import useStarred from "@/features/message/use-starred";
|
||||
import useCanEditThreads from "@/features/message/use-can-edit-threads";
|
||||
import { ThreadPanelFilter } from "./thread-panel-filter";
|
||||
import { THREAD_PANEL_FILTER_PARAMS, useThreadPanelFilters } from "../hooks/use-thread-panel-filters";
|
||||
import { SelectionReadStatus, SelectionStarredStatus } from "@/features/providers/thread-selection";
|
||||
import { LabelsWidget } from "@/features/layouts/components/labels-widget";
|
||||
|
||||
type ThreadPanelTitleProps = {
|
||||
selectedThreadIds: Set<string>;
|
||||
@@ -50,17 +52,10 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
|
||||
const { activeFilters } = useThreadPanelFilters();
|
||||
|
||||
// Whether at least one selected thread has full edit rights — gates
|
||||
// shared-state mutations (archive, spam, trash). The backend enforces
|
||||
// per-thread permissions; the toast reports the actual updated count.
|
||||
// Star and read/unread are personal state on the user's ThreadAccess
|
||||
// and remain available regardless.
|
||||
const canEditSelection = useMemo(() => {
|
||||
if (selectedThreadIds.size === 0) return false;
|
||||
return Array.from(selectedThreadIds).some((id) => {
|
||||
const thread = threads?.results.find((t) => t.id === id);
|
||||
return thread?.abilities?.edit === true;
|
||||
});
|
||||
}, [selectedThreadIds, threads?.results]);
|
||||
// shared-state mutations (archive, spam, trash). Star and read/unread
|
||||
// are personal state on the user's ThreadAccess and remain available
|
||||
// regardless.
|
||||
const canEditSelection = useCanEditThreads(selectedThreadIds);
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (searchParams.has('search')) return t('folder.search', { defaultValue: 'Search' });
|
||||
@@ -195,10 +190,10 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
|
||||
aria-label={mainReadTooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
{isSelectionMode && canEditSelection && (
|
||||
{isSelectionMode && (
|
||||
<>
|
||||
<VerticalSeparator withPadding={false} />
|
||||
{!isSpamView && !isTrashedView && !isDraftsView && (
|
||||
{canEditSelection && !isSpamView && !isTrashedView && !isDraftsView && (
|
||||
<Tooltip content={archiveLabel} className={selectedThreadIds.size === 0 ? 'hidden' : ''}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
@@ -218,7 +213,7 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!isTrashedView && !isSentView && !isDraftsView && (
|
||||
{canEditSelection && !isTrashedView && !isSentView && !isDraftsView && (
|
||||
<Tooltip content={spamLabel} className={selectedThreadIds.size === 0 ? 'hidden' : ''}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
@@ -238,7 +233,7 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!isDraftsView && (
|
||||
{canEditSelection && !isDraftsView && (
|
||||
<Tooltip content={trashLabel} className={selectedThreadIds.size === 0 ? 'hidden' : ''}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
@@ -258,6 +253,11 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!isSpamView && !isTrashedView && !isDraftsView && (
|
||||
<LabelsWidget
|
||||
threadIds={Array.from(selectedThreadIds)}
|
||||
/>
|
||||
)}
|
||||
<VerticalSeparator withPadding={false} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Button, Tooltip, useModals } from "@gouvfr-lasuite/cunningham-react"
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ThreadAccessesWidget } from "../thread-accesses-widget";
|
||||
import { ThreadLabelsWidget } from "../thread-labels-widget";
|
||||
import { LabelsWidget } from "@/features/layouts/components/labels-widget";
|
||||
import useArchive from "@/features/message/use-archive";
|
||||
import useSpam from "@/features/message/use-spam";
|
||||
import useStarred from "@/features/message/use-starred";
|
||||
@@ -32,6 +32,8 @@ export const ThreadActionBar = ({ canUndelete, canUnarchive }: ThreadActionBarPr
|
||||
// Full edit rights on the thread — gates archive, spam, delete.
|
||||
// Star and "mark as unread" remain visible because they are personal
|
||||
// state on the user's ThreadAccess (read_at / starred_at).
|
||||
// Label assignment is scoped to the mailbox (see `LabelsWidget`) and
|
||||
// therefore stays visible for viewer-only threads.
|
||||
const canEditThread = useAbility(Abilities.CAN_EDIT_THREAD, selectedThread ?? null);
|
||||
const canShowArchiveCTA = canEditThread && !selectedThread?.is_spam
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
@@ -173,7 +175,7 @@ export const ThreadActionBar = ({ canUndelete, canUnarchive }: ThreadActionBarPr
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<ThreadLabelsWidget threadId={selectedThread!.id} selectedLabels={selectedThread!.labels} />
|
||||
<LabelsWidget threadIds={[selectedThread!.id]} initialLabels={selectedThread!.labels} />
|
||||
<ThreadAccessesWidget accesses={selectedThread!.accesses} />
|
||||
<DropdownMenu
|
||||
isOpen={isDropdownOpen}
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
import { ThreadLabel, TreeLabel, useLabelsAddThreadsCreate, useLabelsList, useLabelsRemoveThreadsCreate } from "@/features/api/gen";
|
||||
import { Icon, IconType, Spinner } from "@gouvfr-lasuite/ui-kit";
|
||||
import { Button, Checkbox, Input, Tooltip, useModal } from "@gouvfr-lasuite/cunningham-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMailboxContext } from "@/features/providers/mailbox";
|
||||
import StringHelper from "@/features/utils/string-helper";
|
||||
import useAbility, { Abilities } from "@/hooks/use-ability";
|
||||
import { LabelModal } from "@/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-form-modal";
|
||||
|
||||
type ThreadLabelsWidgetProps = {
|
||||
selectedLabels: readonly ThreadLabel[];
|
||||
threadId: string;
|
||||
}
|
||||
|
||||
export const ThreadLabelsWidget = ({ threadId, selectedLabels = [] }: ThreadLabelsWidgetProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { selectedMailbox, selectedThread } = useMailboxContext();
|
||||
const canManageLabels = useAbility(Abilities.CAN_MANAGE_MAILBOX_LABELS, selectedMailbox);
|
||||
// Labelling a thread is a shared-state mutation: it also requires
|
||||
// full edit rights on the thread itself (the label endpoint silently
|
||||
// drops unauthorized threads otherwise).
|
||||
const canEditThread = useAbility(Abilities.CAN_EDIT_THREAD, selectedThread ?? null);
|
||||
const canAddLabel = canManageLabels && canEditThread;
|
||||
const {data: labelsList, isLoading: isLoadingLabelsList } = useLabelsList(
|
||||
{ mailbox_id: selectedMailbox!.id },
|
||||
{ query: { enabled: canAddLabel } }
|
||||
);
|
||||
const [isPopupOpen, setIsPopupOpen] = useState(false);
|
||||
|
||||
if (!canAddLabel) return null;
|
||||
|
||||
if (isLoadingLabelsList) {
|
||||
return (
|
||||
<div className="thread-labels-widget" aria-busy={true}>
|
||||
<Tooltip
|
||||
content={
|
||||
<span className="thread-labels-widget__loading-labels-tooltip-content">
|
||||
<Spinner size="sm" />
|
||||
{t('Loading labels...')}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="nano"
|
||||
variant="tertiary"
|
||||
aria-label={t('Add label')}
|
||||
icon={<Icon type={IconType.OUTLINED} name="new_label" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="thread-labels-widget">
|
||||
<Tooltip content={t('Add label')}>
|
||||
<Button
|
||||
onClick={() => setIsPopupOpen(true)}
|
||||
size="nano"
|
||||
variant="tertiary"
|
||||
aria-label={t('Add label')}
|
||||
icon={<Icon type={IconType.OUTLINED} name="new_label" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
{isPopupOpen &&
|
||||
<>
|
||||
<LabelsPopup
|
||||
labels={labelsList!.data || []}
|
||||
selectedLabels={selectedLabels}
|
||||
threadId={threadId}
|
||||
/>
|
||||
<div className="thread-labels-widget__popup__overlay" onClick={() => setIsPopupOpen(false)}></div>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type LabelsPopupProps = {
|
||||
labels: TreeLabel[];
|
||||
threadId: string;
|
||||
selectedLabels: readonly ThreadLabel[];
|
||||
}
|
||||
|
||||
const LabelsPopup = ({ labels = [], selectedLabels, threadId }: LabelsPopupProps) => {
|
||||
const { t } = useTranslation();
|
||||
const {open, close, isOpen} = useModal();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const { invalidateThreadMessages } = useMailboxContext();
|
||||
const getFlattenLabelOptions = (label: TreeLabel, level: number = 0): Array<{label: string, value: string, checked: boolean}> => {
|
||||
let children: Array<{label: string, value: string, checked: boolean}> = [];
|
||||
if (label.children.length > 0) {
|
||||
children = label.children.map((child) => getFlattenLabelOptions(child, level + 1)).flat();
|
||||
}
|
||||
return [{
|
||||
label: label.name,
|
||||
value: label.id,
|
||||
checked: selectedLabels.some((selectedLabel) => selectedLabel.id === label.id),
|
||||
}, ...children];
|
||||
}
|
||||
const labelsOptions = labels
|
||||
.map((label) => getFlattenLabelOptions(label))
|
||||
.flat()
|
||||
.filter((option) => {
|
||||
const normalizedLabel = StringHelper.normalizeForSearch(option.label);
|
||||
const normalizedSearchQuery = StringHelper.normalizeForSearch(searchQuery);
|
||||
return normalizedLabel.includes(normalizedSearchQuery);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.checked !== b.checked) return a.checked ? -1 : 1;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
|
||||
const addLabelMutation = useLabelsAddThreadsCreate({
|
||||
mutation: {
|
||||
onSuccess: () => invalidateThreadMessages()
|
||||
}
|
||||
});
|
||||
const deleteLabelMutation = useLabelsRemoveThreadsCreate({
|
||||
mutation: {
|
||||
onSuccess: () => invalidateThreadMessages()
|
||||
}
|
||||
});
|
||||
|
||||
const handleAddLabel = (labelId: string) => {
|
||||
addLabelMutation.mutate({
|
||||
id: labelId,
|
||||
data: {
|
||||
thread_ids: [threadId],
|
||||
},
|
||||
});
|
||||
}
|
||||
const handleDeleteLabel = (labelId: string) => {
|
||||
deleteLabelMutation.mutate({
|
||||
id: labelId,
|
||||
data: {
|
||||
thread_ids: [threadId],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="thread-labels-widget__popup">
|
||||
<header className="thread-labels-widget__popup__header">
|
||||
<h3><Icon type={IconType.OUTLINED} name="new_label" /> {t('Add labels')}</h3>
|
||||
<Input
|
||||
className="thread-labels-widget__popup__search"
|
||||
type="search"
|
||||
icon={<Icon type={IconType.OUTLINED} name="search" />}
|
||||
label={t('Search a label')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
</header>
|
||||
<ul className="thread-labels-widget__popup__content">
|
||||
{labelsOptions.map((option) => (
|
||||
<li key={option.value}>
|
||||
<Checkbox
|
||||
checked={option.checked}
|
||||
onChange={() => option.checked ? handleDeleteLabel(option.value) : handleAddLabel(option.value)}
|
||||
label={option.label}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
<li className="thread-labels-widget__popup__content__empty">
|
||||
<Button color="brand" variant="primary" onClick={open} fullWidth icon={<Icon type={IconType.OUTLINED} name="add" />}>
|
||||
<span className="thread-labels-widget__popup__content__empty__button-label">
|
||||
{searchQuery && labelsOptions.length === 0 ? t('Create the label "{{label}}"', { label: searchQuery }) : t('Create a new label')}
|
||||
</span>
|
||||
</Button>
|
||||
<LabelModal
|
||||
isOpen={isOpen}
|
||||
onClose={close}
|
||||
label={{ display_name: searchQuery }}
|
||||
onSuccess={(label) => { handleAddLabel(label.id)}}
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
LabelsPopup.displayName = 'LabelsPopup';
|
||||
24
src/frontend/src/features/message/use-can-edit-threads.tsx
Normal file
24
src/frontend/src/features/message/use-can-edit-threads.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMailboxContext } from "@/features/providers/mailbox";
|
||||
|
||||
/**
|
||||
* Returns whether at least one of the given threads grants full edit
|
||||
* rights to the current user (`thread.abilities.edit === true`).
|
||||
*
|
||||
* Gates shared-state mutations (archive, spam, trash, auto-archive on
|
||||
* label drop). The backend enforces per-thread permissions and reports
|
||||
* the actual updated count in the toast — this hook only drives UI
|
||||
* affordances (disabled buttons, blocked drop zones).
|
||||
*/
|
||||
const useCanEditThreads = (threadIds: Set<string> | string[]): boolean => {
|
||||
const { threads } = useMailboxContext();
|
||||
return useMemo(() => {
|
||||
const ids = threadIds instanceof Set ? threadIds : new Set(threadIds);
|
||||
if (ids.size === 0) return false;
|
||||
return (threads?.results ?? []).some(
|
||||
(t) => ids.has(t.id) && t.abilities?.edit === true
|
||||
);
|
||||
}, [threadIds, threads?.results]);
|
||||
};
|
||||
|
||||
export default useCanEditThreads;
|
||||
48
src/frontend/src/hooks/use-popup-position.ts
Normal file
48
src/frontend/src/hooks/use-popup-position.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { RefObject, useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Computes and keeps in sync the fixed-position coordinates of a portaled popup
|
||||
* anchored to a trigger element. Recomputes on open, window resize, scroll (capture
|
||||
* phase, so scrollable ancestors are covered), and when the anchor itself resizes
|
||||
* — the latter via ResizeObserver, which prevents the popup from drifting when the
|
||||
* anchor's content changes (e.g. badges added/removed while the popup stays open).
|
||||
*
|
||||
* The `compute` callback receives the anchor's DOMRect and returns whatever shape
|
||||
* the caller needs (top/left, top/right, maxHeight, etc.), so positioning strategy
|
||||
* stays at the call site.
|
||||
*
|
||||
* @returns the computed position, or null before the first measurement.
|
||||
*/
|
||||
export const usePopupPosition = <P,>(
|
||||
anchorRef: RefObject<HTMLElement | null>,
|
||||
isOpen: boolean,
|
||||
compute: (rect: DOMRect) => P,
|
||||
): P | null => {
|
||||
const [position, setPosition] = useState<P | null>(null);
|
||||
const computeRef = useRef(compute);
|
||||
computeRef.current = compute;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const el = anchorRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const updatePosition = () => {
|
||||
setPosition(computeRef.current(el.getBoundingClientRect()));
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
window.addEventListener('resize', updatePosition);
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
const observer = new ResizeObserver(updatePosition);
|
||||
observer.observe(el);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [anchorRef, isOpen]);
|
||||
|
||||
return position;
|
||||
};
|
||||
@@ -35,6 +35,7 @@
|
||||
@use "./../features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-item";
|
||||
@use "./../features/layouts/components/thread-panel";
|
||||
@use "./../features/layouts/components/thread-panel/components/thread-item";
|
||||
@use "./../features/layouts/components/labels-widget";
|
||||
@use "./../features/layouts/components/thread-view";
|
||||
@use "./../features/layouts/components/thread-view/components/thread-action-bar";
|
||||
@use "./../features/layouts/components/thread-view/components/thread-message";
|
||||
@@ -43,7 +44,6 @@
|
||||
@use "./../features/layouts/components/thread-view/components/thread-attachment-list";
|
||||
@use "./../features/layouts/components/thread-view/components/calendar-invite";
|
||||
@use "./../features/layouts/components/thread-view/components/thread-accesses-widget";
|
||||
@use "./../features/layouts/components/thread-view/components/thread-labels-widget";
|
||||
@use "./../features/forms/components/message-form";
|
||||
@use "./../features/forms/components/search-input";
|
||||
@use "./../features/forms/components/search-filters-form";
|
||||
|
||||
Reference in New Issue
Block a user