feat(UI-Overhaul-Phase6): Tags Umstrukturierung
Check Cross-Plugin Imports / check (push) Has been cancelled

Migration 0138: Add parent_id, applicable_to, icon columns to tags table

Backend:
- Tag model: add parent_id (self-FK), applicable_to (JSONB), icon (VARCHAR)
- TagCreate/TagUpdate/TagResponse schemas: add new fields
- Tags routes: create_tag, update_tag, list_tags return new fields

Frontend:
- api/tags.ts: Tag interface, CreateTagPayload, UpdateTagPayload updated with new fields
- Tags route moved from /tags to /settings/tags (under Settings)
- Tags.tsx: TagFormModal updated with parent tag selector, icon picker, applicable_to multi-select
- TagsPage passes tags list to TagFormModal for parent selection

tsc clean, backend import OK
This commit is contained in:
Agent Zero
2026-08-21 13:43:29 +02:00
parent 9f89cb17a0
commit b59289fc6e
7 changed files with 188 additions and 5 deletions
@@ -0,0 +1,42 @@
"""Tags: parent_id, applicable_to, icon columns
Revision ID: 0138
Revises: 0137
Create Date: 2026-08-21
Adds parent_id for tree structure, applicable_to for entity-type filtering,
and icon for per-tag icon selection.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
# revision identifiers, used by Alembic.
revision = "0138"
down_revision = "0137"
branch_labels = None
depends_on = None
def upgrade() -> None:
# parent_id for tree structure (self-referencing FK)
op.add_column("tags", sa.Column("parent_id", PGUUID(as_uuid=True), nullable=True))
op.create_foreign_key(
"fk_tags_parent_id", "tags", "tags", ["parent_id"], ["id"], ondelete="SET NULL"
)
op.create_index("ix_tags_parent", "tags", ["parent_id"])
# applicable_to: list of entity types where this tag can be applied
op.add_column("tags", sa.Column("applicable_to", JSONB, nullable=True))
# icon: icon name for frontend display
op.add_column("tags", sa.Column("icon", sa.String(50), nullable=True))
def downgrade() -> None:
op.drop_column("tags", "icon")
op.drop_column("tags", "applicable_to")
op.drop_index("ix_tags_parent", table_name="tags")
op.drop_constraint("fk_tags_parent_id", "tags", type_="foreignkey")
op.drop_column("tags", "parent_id")
+7 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import uuid import uuid
from sqlalchemy import ForeignKey, Index, String, UniqueConstraint from sqlalchemy import ForeignKey, Index, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin from app.core.db import Base, TenantMixin
@@ -24,6 +24,7 @@ class Tag(Base, TenantMixin, OwnedMixin):
__table_args__ = ( __table_args__ = (
UniqueConstraint("tenant_id", "name", name="uq_tags_tenant_name"), UniqueConstraint("tenant_id", "name", name="uq_tags_tenant_name"),
Index("ix_tags_tenant", "tenant_id"), Index("ix_tags_tenant", "tenant_id"),
Index("ix_tags_parent", "parent_id"),
) )
id: Mapped[uuid.UUID] = mapped_column( id: Mapped[uuid.UUID] = mapped_column(
@@ -31,6 +32,11 @@ class Tag(Base, TenantMixin, OwnedMixin):
) )
name: Mapped[str] = mapped_column(String(100), nullable=False) name: Mapped[str] = mapped_column(String(100), nullable=False)
color: Mapped[str] = mapped_column(String(7), nullable=False, default="#6B7280") color: Mapped[str] = mapped_column(String(7), nullable=False, default="#6B7280")
parent_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("tags.id", ondelete="SET NULL"), nullable=True
)
applicable_to: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True)
icon: Mapped[str | None] = mapped_column(String(50), nullable=True)
class TagAssignment(Base, TenantMixin, OwnedMixin): class TagAssignment(Base, TenantMixin, OwnedMixin):
+24 -1
View File
@@ -78,6 +78,9 @@ async def list_tags(
"name": tag.name, "name": tag.name,
"color": tag.color, "color": tag.color,
"entity_count": count, "entity_count": count,
"parent_id": str(tag.parent_id) if tag.parent_id else None,
"applicable_to": tag.applicable_to,
"icon": tag.icon,
} }
for tag, count in rows for tag, count in rows
] ]
@@ -102,7 +105,15 @@ async def create_tag(
from app.core.hooks import do_action from app.core.hooks import do_action
await do_action("tag.before_create", body, db=db, tenant_id=tenant_id, user_id=user_id) await do_action("tag.before_create", body, db=db, tenant_id=tenant_id, user_id=user_id)
tag = Tag(tenant_id=tenant_id, name=body.name, color=body.color, owner_id=user_id) tag = Tag(
tenant_id=tenant_id,
name=body.name,
color=body.color,
owner_id=user_id,
parent_id=uuid.UUID(body.parent_id) if body.parent_id else None,
applicable_to=body.applicable_to,
icon=body.icon,
)
db.add(tag) db.add(tag)
await db.flush() await db.flush()
from app.core.hooks import do_action from app.core.hooks import do_action
@@ -112,6 +123,9 @@ async def create_tag(
"name": tag.name, "name": tag.name,
"color": tag.color, "color": tag.color,
"entity_count": 0, "entity_count": 0,
"parent_id": str(tag.parent_id) if tag.parent_id else None,
"applicable_to": tag.applicable_to,
"icon": tag.icon,
} }
@@ -145,6 +159,12 @@ async def update_tag(
tag.name = data["name"] tag.name = data["name"]
if "color" in data: if "color" in data:
tag.color = data["color"] tag.color = data["color"]
if "parent_id" in data:
tag.parent_id = uuid.UUID(data["parent_id"]) if data["parent_id"] else None
if "applicable_to" in data:
tag.applicable_to = data["applicable_to"]
if "icon" in data:
tag.icon = data["icon"]
await db.flush() await db.flush()
return { return {
@@ -152,6 +172,9 @@ async def update_tag(
"name": tag.name, "name": tag.name,
"color": tag.color, "color": tag.color,
"entity_count": 0, "entity_count": 0,
"parent_id": str(tag.parent_id) if tag.parent_id else None,
"applicable_to": tag.applicable_to,
"icon": tag.icon,
} }
+9
View File
@@ -8,11 +8,17 @@ from pydantic import BaseModel, Field
class TagCreate(BaseModel): class TagCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100) name: str = Field(..., min_length=1, max_length=100)
color: str = Field("#6B7280", max_length=7) color: str = Field("#6B7280", max_length=7)
parent_id: str | None = None
applicable_to: list[str] | None = None
icon: str | None = None
class TagUpdate(BaseModel): class TagUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=100) name: str | None = Field(None, min_length=1, max_length=100)
color: str | None = Field(None, max_length=7) color: str | None = Field(None, max_length=7)
parent_id: str | None = None
applicable_to: list[str] | None = None
icon: str | None = None
class TagResponse(BaseModel): class TagResponse(BaseModel):
@@ -20,6 +26,9 @@ class TagResponse(BaseModel):
name: str name: str
color: str color: str
entity_count: int = 0 entity_count: int = 0
parent_id: str | None = None
applicable_to: list[str] | None = None
icon: str | None = None
class TagAssignRequest(BaseModel): class TagAssignRequest(BaseModel):
+9
View File
@@ -20,6 +20,9 @@ export interface Tag {
created_at?: string | null; created_at?: string | null;
updated_at?: string | null; updated_at?: string | null;
usage_count?: number; usage_count?: number;
parent_id?: string | null;
applicable_to?: string[] | null;
icon?: string | null;
} }
export interface TagAssignment { export interface TagAssignment {
@@ -43,12 +46,18 @@ export interface CreateTagPayload {
name: string; name: string;
color?: string; color?: string;
description?: string | null; description?: string | null;
parent_id?: string | null;
applicable_to?: string[] | null;
icon?: string | null;
} }
export interface UpdateTagPayload { export interface UpdateTagPayload {
name?: string; name?: string;
color?: string; color?: string;
description?: string | null; description?: string | null;
parent_id?: string | null;
applicable_to?: string[] | null;
icon?: string | null;
} }
export interface AssignTagPayload { export interface AssignTagPayload {
+96 -2
View File
@@ -27,18 +27,25 @@ interface TagFormModalProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
tag?: Tag | null; tag?: Tag | null;
tags?: Tag[];
onSubmit: (data: CreateTagPayload | UpdateTagPayload) => void; onSubmit: (data: CreateTagPayload | UpdateTagPayload) => void;
isSubmitting: boolean; isSubmitting: boolean;
error?: string | null; error?: string | null;
} }
function TagFormModal({ open, onClose, tag, onSubmit, isSubmitting, error }: TagFormModalProps) { function TagFormModal({ open, onClose, tag, tags = [], onSubmit, isSubmitting, error }: TagFormModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const isEdit = !!tag; const isEdit = !!tag;
const [name, setName] = useState(tag?.name ?? ''); const [name, setName] = useState(tag?.name ?? '');
const [color, setColor] = useState(tag?.color ?? 'blue'); const [color, setColor] = useState(tag?.color ?? 'blue');
const [description, setDescription] = useState(tag?.description ?? ''); const [description, setDescription] = useState(tag?.description ?? '');
const [parentId, setParentId] = useState(tag?.parent_id ?? '');
const [icon, setIcon] = useState(tag?.icon ?? '');
const [applicableTo, setApplicableTo] = useState<string[]>(tag?.applicable_to ?? []);
const ENTITY_TYPES = ['contact', 'file', 'calendar_entry', 'mail', 'task'];
const ICON_OPTIONS = ['Tag', 'Star', 'Heart', 'Flag', 'Bookmark', 'Circle', 'Square', 'Hash', 'AlertCircle', 'CheckCircle'];
// Reset form when modal opens or tag changes // Reset form when modal opens or tag changes
React.useEffect(() => { React.useEffect(() => {
@@ -46,6 +53,9 @@ function TagFormModal({ open, onClose, tag, onSubmit, isSubmitting, error }: Tag
setName(tag?.name ?? ''); setName(tag?.name ?? '');
setColor(tag?.color ?? 'blue'); setColor(tag?.color ?? 'blue');
setDescription(tag?.description ?? ''); setDescription(tag?.description ?? '');
setParentId(tag?.parent_id ?? '');
setIcon(tag?.icon ?? '');
setApplicableTo(tag?.applicable_to ?? []);
} }
}, [open, tag]); }, [open, tag]);
@@ -58,12 +68,18 @@ function TagFormModal({ open, onClose, tag, onSubmit, isSubmitting, error }: Tag
name: trimmedName, name: trimmedName,
color, color,
description: description.trim() || null, description: description.trim() || null,
parent_id: parentId || null,
icon: icon || null,
applicable_to: applicableTo.length > 0 ? applicableTo : null,
}; };
onSubmit(data); onSubmit(data);
}, },
[name, color, description, onSubmit] [name, color, description, parentId, icon, applicableTo, onSubmit]
); );
// Filter out self and descendants for parent selection
const availableParents = tags.filter((t) => t.id !== tag?.id);
return ( return (
<Modal <Modal
open={open} open={open}
@@ -132,6 +148,83 @@ function TagFormModal({ open, onClose, tag, onSubmit, isSubmitting, error }: Tag
/> />
</div> </div>
{/* Parent Tag */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('tags.parentTag', 'Übergeordneter Tag')}
</label>
<select
value={parentId}
onChange={(e) => setParentId(e.target.value)}
className="block w-full rounded-md border border-secondary-300 px-3 py-2 text-base min-h-touch focus:outline-none focus:ring-2 focus:ring-primary-500"
data-testid="tag-form-parent"
>
<option value="">{t('tags.noParent', 'Kein übergeordneter Tag')}</option>
{availableParents.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
{/* Icon */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('tags.icon', 'Symbol')}
</label>
<div className="flex items-center gap-2 flex-wrap">
{ICON_OPTIONS.map((iconName) => (
<button
key={iconName}
type="button"
onClick={() => setIcon(iconName === icon ? '' : iconName)}
className={clsx(
'px-3 py-1.5 rounded-md text-xs font-medium border transition-all',
icon === iconName
? 'border-primary-500 bg-primary-50 text-primary-700'
: 'border-secondary-200 text-secondary-600 hover:bg-secondary-50'
)}
aria-pressed={icon === iconName}
>
{iconName}
</button>
))}
</div>
</div>
{/* Applicable To */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('tags.applicableTo', 'Anwendbar auf')}
</label>
<div className="flex items-center gap-2 flex-wrap">
{ENTITY_TYPES.map((entityType) => (
<label
key={entityType}
className={clsx(
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium border cursor-pointer transition-all',
applicableTo.includes(entityType)
? 'border-primary-500 bg-primary-50 text-primary-700'
: 'border-secondary-200 text-secondary-600 hover:bg-secondary-50'
)}
>
<input
type="checkbox"
checked={applicableTo.includes(entityType)}
onChange={(e) => {
if (e.target.checked) {
setApplicableTo([...applicableTo, entityType]);
} else {
setApplicableTo(applicableTo.filter((t) => t !== entityType));
}
}}
className="sr-only"
/>
{entityType}
</label>
))}
</div>
</div>
{/* Error */} {/* Error */}
{error && ( {error && (
<div className="rounded-md bg-danger-50 border border-danger-200 px-3 py-2 text-sm text-danger-700"> <div className="rounded-md bg-danger-50 border border-danger-200 px-3 py-2 text-sm text-danger-700">
@@ -475,6 +568,7 @@ export function TagsPage() {
open={showFormModal} open={showFormModal}
onClose={handleFormClose} onClose={handleFormClose}
tag={editingTag} tag={editingTag}
tags={tags}
onSubmit={handleFormSubmit} onSubmit={handleFormSubmit}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
error={formError} error={formError}
+1 -1
View File
@@ -259,7 +259,7 @@ const router = createBrowserRouter([
{ path: '/workflows', element: <PermissionRoute permission="workflows:read">{withSuspense(<WorkflowsPage />)}</PermissionRoute> }, { path: '/workflows', element: <PermissionRoute permission="workflows:read">{withSuspense(<WorkflowsPage />)}</PermissionRoute> },
{ path: '/contacts/dedup', element: <PermissionRoute permission="contacts:read">{withSuspense(<DedupMergePage />)}</PermissionRoute> }, { path: '/contacts/dedup', element: <PermissionRoute permission="contacts:read">{withSuspense(<DedupMergePage />)}</PermissionRoute> },
{ path: '/import-export', element: <PermissionRoute permission="contacts:read">{withSuspense(<ImportExportPage />)}</PermissionRoute> }, { path: '/import-export', element: <PermissionRoute permission="contacts:read">{withSuspense(<ImportExportPage />)}</PermissionRoute> },
{ path: '/tags', element: <PermissionRoute permission="tags:read">{withSuspense(<TagsPage />)}</PermissionRoute> }, { path: 'tags', element: <PermissionRoute permission="tags:read">{withSuspense(<TagsPage />)}</PermissionRoute> },
{ path: '/api-docs', element: <PermissionRoute permission="settings:read">{withSuspense(<ApiDocsPage />)}</PermissionRoute> }, { path: '/api-docs', element: <PermissionRoute permission="settings:read">{withSuspense(<ApiDocsPage />)}</PermissionRoute> },
{ path: '/activity', element: <PermissionRoute permission="activity:read">{withSuspense(<ActivityTimelinePage />)}</PermissionRoute> }, { path: '/activity', element: <PermissionRoute permission="activity:read">{withSuspense(<ActivityTimelinePage />)}</PermissionRoute> },
{ path: '/wiki', element: withSuspense(<WikiPage />) }, { path: '/wiki', element: withSuspense(<WikiPage />) },