feat: Add nesting of tags in a tree

- levels are joined by forward slashes in the keywords
- behaviour is opt-in for now
- long paths are shortened if there is not enough display room

Closes #687.
This commit is contained in:
Mathy Vanvoorden
2026-07-29 17:18:38 +02:00
parent ce97a54aaa
commit ca0ba818b7
22 changed files with 1084 additions and 95 deletions
@@ -3,14 +3,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { KeywordSettings } from '../keyword-settings';
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
// Mock SettingsSection to just render children
vi.mock('../settings-section', () => ({
// Mock SettingsSection to just render children, keeping the real controls
vi.mock('../settings-section', async (importOriginal) => ({
...(await importOriginal<typeof import('../settings-section')>()),
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
describe('KeywordSettings', () => {
beforeEach(() => {
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS] });
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], nestedTags: false });
});
it('renders all default keywords', () => {
@@ -139,4 +140,74 @@ describe('KeywordSettings', () => {
expect(added.id).toBe('my-custom-tag');
expect(added.label).toBe('My Custom Tag!');
});
it('offers no parent picker while nesting is off', () => {
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
expect(screen.queryByLabelText('parent_field')).not.toBeInTheDocument();
});
it('nests a new tag under the selected parent', () => {
useSettingsStore.setState({
emailKeywords: [{ id: 'work', label: 'Work', color: 'blue' }],
nestedTags: true,
});
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
fireEvent.change(screen.getByLabelText('parent_field'), { target: { value: 'work' } });
fireEvent.change(screen.getByPlaceholderText('label_placeholder'), { target: { value: 'Clients' } });
fireEvent.click(screen.getByText('add'));
const keywords = useSettingsStore.getState().emailKeywords;
expect(keywords[keywords.length - 1]).toMatchObject({ id: 'work/clients', label: 'Clients' });
});
it('shows nested tags by their full path', () => {
useSettingsStore.setState({
emailKeywords: [
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'work/clients', label: 'Clients', color: 'green' },
],
nestedTags: true,
});
render(<KeywordSettings />);
expect(screen.getByText('Work/Clients')).toBeInTheDocument();
expect(screen.getByText('$label:work/clients')).toBeInTheDocument();
});
it('rejects a path that would exceed the keyword length limit', () => {
const deepId = 'a'.repeat(240);
useSettingsStore.setState({
emailKeywords: [{ id: deepId, label: 'Deep', color: 'blue' }],
nestedTags: true,
});
render(<KeywordSettings />);
fireEvent.click(screen.getByText('add_keyword'));
fireEvent.change(screen.getByLabelText('parent_field'), { target: { value: deepId } });
fireEvent.change(screen.getByPlaceholderText('label_placeholder'), { target: { value: 'Overflowing name' } });
expect(screen.getByText('too_long')).toBeInTheDocument();
expect(screen.getByText('add').closest('button')).toBeDisabled();
});
it('locks the name and the delete action of a tag that has nested tags', () => {
useSettingsStore.setState({
emailKeywords: [
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'work/clients', label: 'Clients', color: 'green' },
],
nestedTags: true,
});
render(<KeywordSettings />);
expect(screen.getByTitle('has_children_delete')).toBeDisabled();
fireEvent.click(screen.getAllByTitle('edit')[0]);
expect(screen.getByDisplayValue('Work')).toBeDisabled();
expect(screen.getByText('has_children_locked')).toBeInTheDocument();
});
});
+121 -15
View File
@@ -2,12 +2,30 @@
import React, { useState } from "react";
import { useTranslations } from "next-intl";
import { useSettingsStore, KEYWORD_PALETTE, DEFAULT_KEYWORDS, type KeywordDefinition } from "@/stores/settings-store";
import {
useSettingsStore,
KEYWORD_PALETTE,
DEFAULT_KEYWORDS,
type KeywordDefinition,
} from "@/stores/settings-store";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { SettingsSection } from "./settings-section";
import { SettingsSection, SettingItem, ToggleSwitch, Select } from "./settings-section";
import { Plus, Pencil, Trash2, GripVertical, Check, X, RotateCcw, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { KEYWORD_PREFIX } from "@/lib/thread-utils";
import {
buildKeywordTree,
composeKeywordId,
getParentKeywordId,
hasChildKeywords,
isKeywordDescendant,
keywordLevels,
type KeywordNode,
MAX_KEYWORD_ID_LENGTH,
} from "@/lib/keyword-nesting";
import { formatKeyword, formatKeywordLabels, keywordRenderings } from "@/lib/keyword-format";
import { useShortenedText } from "@/hooks/use-shortened-text";
const PALETTE_KEYS = Object.keys(KEYWORD_PALETTE);
@@ -39,6 +57,8 @@ function KeywordColorPicker({
function KeywordRow({
keyword,
keywords,
nestedTags,
onEdit,
onDelete,
onDragStart,
@@ -49,6 +69,8 @@ function KeywordRow({
isDragging,
}: {
keyword: KeywordDefinition;
keywords: KeywordDefinition[];
nestedTags: boolean;
onEdit: () => void;
onDelete: () => void;
onDragStart: () => void;
@@ -60,6 +82,13 @@ function KeywordRow({
}) {
const t = useTranslations("settings.keywords");
const palette = KEYWORD_PALETTE[keyword.color];
const hasChildren = hasChildKeywords(keyword.id, keywords);
const nameCandidates = keywordRenderings(formatKeywordLabels(keyword.id, keywords, nestedTags));
const [nameRef, shortenedName] = useShortenedText(nameCandidates);
// Measured with the prefix attached, since that is what occupies the column.
const keywordCandidates = (nestedTags ? keywordRenderings(keywordLevels(keyword.id)) : [keyword.id])
.map((rendering) => KEYWORD_PREFIX + rendering);
const [keywordRef, shortenedKeyword] = useShortenedText(keywordCandidates);
return (
<div
@@ -76,8 +105,20 @@ function KeywordRow({
>
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-50 cursor-grab" />
<div className={cn("w-5 h-5 rounded-full shrink-0", palette?.dot || "bg-gray-500")} />
<span className="flex-1 text-sm font-medium truncate">{keyword.label}</span>
<span className="text-xs text-muted-foreground font-mono">{"$label:" + keyword.id}</span>
<span
ref={nameRef}
className="flex-1 min-w-0 text-sm font-medium truncate"
title={formatKeyword(keyword.id, keywords, nestedTags)}
>
{shortenedName}
</span>
<span
ref={keywordRef}
className="hidden md:block min-w-0 max-w-52 truncate text-xs text-muted-foreground font-mono"
title={KEYWORD_PREFIX + keyword.id}
>
{shortenedKeyword}
</span>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
@@ -90,8 +131,9 @@ function KeywordRow({
<button
type="button"
onClick={onDelete}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
title={t("delete")}
disabled={hasChildren}
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
title={hasChildren ? t("has_children_delete") : t("delete")}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
@@ -102,37 +144,74 @@ function KeywordRow({
function KeywordEditForm({
initial,
keywords,
existingIds,
nestedTags,
onSave,
onCancel,
}: {
initial?: KeywordDefinition;
keywords: KeywordDefinition[];
existingIds: string[];
nestedTags: boolean;
onSave: (keyword: KeywordDefinition) => void;
onCancel: () => void;
}) {
const t = useTranslations("settings.keywords");
const [label, setLabel] = useState(initial?.label || "");
const [color, setColor] = useState(initial?.color || "blue");
const [parentId, setParentId] = useState(initial ? getParentKeywordId(initial.id) ?? "" : "");
const isEditing = !!initial;
const normalizedId = label
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
// Renaming or re-parenting a tag rewrites the keyword on every message below
// it, and this client only knows about the tags in its own settings - the
// server may hold nested keywords created elsewhere. Freeze the identity of a
// tag that has children and allow the color to change.
const isLocked = !!initial && hasChildKeywords(initial.id, keywords);
const normalizedId = isLocked && initial ? initial.id : composeKeywordId(parentId || null, label);
const isDuplicate = normalizedId.length > 0 && existingIds.includes(normalizedId);
const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate;
const isTooLong = normalizedId.length > MAX_KEYWORD_ID_LENGTH;
const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate && !isTooLong;
// Every tag is a candidate parent except the one being edited and anything
// already below it, which would detach the branch from its own root.
const parentOptions: { value: string; label: string }[] = [{ value: "", label: t("no_parent") }];
const collectParentOptions = (nodes: KeywordNode[]) => {
for (const node of nodes) {
if (initial && (node.id === initial.id || isKeywordDescendant(node.id, initial.id))) continue;
parentOptions.push({ value: node.id, label: formatKeyword(node.id, keywords, true) });
collectParentOptions(node.children);
}
};
collectParentOptions(buildKeywordTree(keywords));
const handleSave = () => {
if (!isValid) return;
if (isLocked && initial) {
onSave({ ...initial, color });
return;
}
onSave({ id: normalizedId, label: label.trim(), color });
};
return (
<div className="space-y-3 p-3 rounded-md border border-primary/30 bg-accent/30">
{nestedTags && (
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("parent_field")}
</label>
<Select
value={parentId}
onChange={setParentId}
options={parentOptions}
disabled={isLocked}
ariaLabel={t("parent_field")}
className="w-full"
/>
</div>
)}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("label_field")}
@@ -141,15 +220,29 @@ function KeywordEditForm({
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring"
disabled={isLocked}
className="w-full px-2.5 py-1.5 text-sm rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-60"
placeholder={t("label_placeholder")}
autoFocus
maxLength={30}
onKeyDown={(e) => e.key === "Enter" && handleSave()}
/>
{nestedTags && normalizedId.length > 0 && (
<p className="text-xs text-muted-foreground font-mono mt-1 break-all">
{KEYWORD_PREFIX + normalizedId}
</p>
)}
{isLocked && (
<p className="text-xs text-muted-foreground mt-1">{t("has_children_locked")}</p>
)}
{isDuplicate && (
<p className="text-xs text-destructive mt-1">{t("id_exists")}</p>
)}
{isTooLong && (
<p className="text-xs text-destructive mt-1">
{t("too_long", { max: MAX_KEYWORD_ID_LENGTH })}
</p>
)}
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1.5 block">
@@ -182,7 +275,7 @@ function KeywordEditForm({
export function KeywordSettings() {
const t = useTranslations("settings.keywords");
const { emailKeywords, addKeyword, updateKeyword, renameKeyword, removeKeyword, reorderKeywords } =
const { emailKeywords, nestedTags, addKeyword, updateKeyword, renameKeyword, removeKeyword, reorderKeywords, updateSetting } =
useSettingsStore();
const { client } = useAuthStore();
const { fetchTagCounts } = useEmailStore();
@@ -266,6 +359,13 @@ export function KeywordSettings() {
return (
<SettingsSection title={t("title")} description={t("description")}>
<SettingItem label={t("nesting.label")} description={t("nesting.description")}>
<ToggleSwitch
checked={nestedTags}
onChange={(checked) => updateSetting("nestedTags", checked)}
/>
</SettingItem>
<div className="space-y-2">
{isMigrating && (
<div className="flex items-center gap-2 p-2 text-xs text-muted-foreground bg-accent/50 rounded-md">
@@ -278,7 +378,9 @@ export function KeywordSettings() {
<KeywordEditForm
key={keyword.id}
initial={keyword}
keywords={emailKeywords}
existingIds={existingIds.filter((id) => id !== keyword.id)}
nestedTags={nestedTags}
onSave={handleEdit}
onCancel={() => setEditingId(null)}
/>
@@ -286,6 +388,8 @@ export function KeywordSettings() {
<KeywordRow
key={keyword.id}
keyword={keyword}
keywords={emailKeywords}
nestedTags={nestedTags}
onEdit={() => {
setEditingId(keyword.id);
setIsAdding(false);
@@ -303,7 +407,9 @@ export function KeywordSettings() {
{isAdding ? (
<KeywordEditForm
keywords={emailKeywords}
existingIds={existingIds}
nestedTags={nestedTags}
onSave={handleAdd}
onCancel={() => setIsAdding(false)}
/>
+11 -2
View File
@@ -111,15 +111,24 @@ interface SelectProps {
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
disabled?: boolean;
className?: string;
ariaLabel?: string;
}
export function Select({ value, onChange, options }: SelectProps) {
export function Select({ value, onChange, options, disabled, className, ariaLabel }: SelectProps) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
aria-label={ariaLabel}
dir="auto"
className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 cursor-pointer hover:border-muted-foreground"
className={cn(
"px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150",
disabled ? "opacity-60 cursor-not-allowed" : "cursor-pointer hover:border-muted-foreground",
className
)}
>
{options.map((option) => (
<option key={option.value} value={option.value}>