diff --git a/docs/content/docs/guides/mailboxes.mdx b/docs/content/docs/guides/mailboxes.mdx index 59632789d..b209940e7 100644 --- a/docs/content/docs/guides/mailboxes.mdx +++ b/docs/content/docs/guides/mailboxes.mdx @@ -15,7 +15,7 @@ New mailboxes default to diagnostic participation **off**. The organization-auth Use the **Tags** dropdown above the mailbox list to show accounts with a particular tag. The selection is kept in the page URL and remembered for your user and workspace in the browser tab, so refreshing, opening a bookmark, or visiting another page and returning keeps the filter. Choose **All accounts** to clear it. A tag that no longer exists in the current workspace falls back to **All accounts**. -Without an explicit column sort, **All accounts** groups the loaded mailboxes by workspace tag order, then by email address, with untagged accounts last. A mailbox with several tags belongs to its first tag in that order and appears only once. Selecting a column sort takes priority over grouping. +Without an explicit column sort, **All accounts** groups the loaded mailboxes by their complete set of tags. Mailboxes with **Tag A + Tag B** stay together even when those tags were assigned in a different order, separately from mailboxes with only **Tag A**. Groups follow the workspace's tag order, with untagged mailboxes last; mailboxes within a group are ordered by email address. Each mailbox appears only once. Selecting a column sort takes priority over grouping. ## Connecting an account diff --git a/web/src/lib/groupMailboxes.test.ts b/web/src/lib/groupMailboxes.test.ts index df4cb44a5..16bc3b835 100644 --- a/web/src/lib/groupMailboxes.test.ts +++ b/web/src/lib/groupMailboxes.test.ts @@ -11,9 +11,34 @@ describe("default mailbox tag groups", () => { expect(groupMailboxes(rows, tags).map((row) => row.id)).toEqual(["a", "b", "c", "z"]); expect(rows.map((row) => row.id)).toEqual(["z", "c", "b", "a"]); }); - it("assigns overlapping tags to the first tag in the workspace order and ignores assignment order", () => { + it("orders tag sets by workspace order rather than assignment order", () => { const rows = [mailbox("b", ["receiving"]), mailbox("a", ["receiving", "sending"]), mailbox("c", ["deleted"])]; expect(groupMailboxes(rows, tags).map((row) => row.id)).toEqual(["a", "b", "c"]); expect(groupMailboxes(rows, [...tags].reverse()).map((row) => row.id)).toEqual(["a", "b", "c"]); }); + it("keeps all four identical multi-tag sets together instead of using only their first tag", () => { + const allTags = [...tags, { id: "other", title: "Other", position: 2 } as Tag]; + const rows = [ + mailbox("h", ["receiving", "sending"]), mailbox("a", ["sending"]), + mailbox("e", ["sending", "receiving"]), mailbox("d", ["sending", "other"]), + mailbox("b", ["receiving", "sending"]), mailbox("z", []), + mailbox("c", ["sending"]), mailbox("f", ["sending", "receiving"]), + ]; + const before = rows.map((row) => [...row.tags]); + expect(groupMailboxes(rows, allTags).map((row) => row.id)).toEqual(["a", "c", "b", "e", "f", "h", "d", "z"]); + expect(rows.map((row) => row.tags)).toEqual(before); + }); + it("treats duplicate assignments as a set and preserves unknown tag identities", () => { + const rows = [ + mailbox("d", ["missing-y"]), mailbox("b", ["missing-x"]), + mailbox("c", ["missing-y", "missing-y"]), mailbox("a", ["missing-x"]), + mailbox("f", ["sending", "sending"]), mailbox("e", ["sending"]), mailbox("z", []), + ]; + expect(groupMailboxes(rows, tags).map((row) => row.id)).toEqual(["e", "f", "a", "b", "c", "d", "z"]); + }); + it("uses stable email and id ordering inside each tag set, including an empty workspace", () => { + const rows = [mailbox("b", []), mailbox("a", []), mailbox("c", [])]; + rows[0].email = rows[1].email; + expect(groupMailboxes(rows, []).map((row) => row.id)).toEqual(["a", "b", "c"]); + }); }); diff --git a/web/src/lib/groupMailboxes.ts b/web/src/lib/groupMailboxes.ts index 97f13215d..c72aba61e 100644 --- a/web/src/lib/groupMailboxes.ts +++ b/web/src/lib/groupMailboxes.ts @@ -4,6 +4,16 @@ import type Tag from "./api/models/app/Tag"; export default function groupMailboxes(mailboxes: readonly Inbox[], tags: readonly Tag[]): Inbox[] { const ordered = [...tags].sort((a, b) => a.position - b.position || a.title.localeCompare(b.title) || a.id.localeCompare(b.id)); const rank = new Map(ordered.map((tag, index) => [tag.id, index])); - const group = (mailbox: Inbox) => Math.min(...mailbox.tags.map((id) => rank.get(id) ?? ordered.length), ordered.length); - return [...mailboxes].sort((a, b) => group(a) - group(b) || a.email.localeCompare(b.email) || a.id.localeCompare(b.id)); + const compareTag = (a: string, b: string) => + (rank.get(a) ?? ordered.length) - (rank.get(b) ?? ordered.length) || a.localeCompare(b); + const groups = mailboxes.map((mailbox) => ({ mailbox, tags: [...new Set(mailbox.tags)].sort(compareTag) })); + return groups.sort((a, b) => { + if (!a.tags.length && b.tags.length) return 1; + if (a.tags.length && !b.tags.length) return -1; + for (let i = 0; i < Math.min(a.tags.length, b.tags.length); i++) { + const order = compareTag(a.tags[i], b.tags[i]); + if (order) return order; + } + return a.tags.length - b.tags.length || a.mailbox.email.localeCompare(b.mailbox.email) || a.mailbox.id.localeCompare(b.mailbox.id); + }).map(({ mailbox }) => mailbox); }