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
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 app.core.db import Base, TenantMixin
@@ -24,6 +24,7 @@ class Tag(Base, TenantMixin, OwnedMixin):
__table_args__ = (
UniqueConstraint("tenant_id", "name", name="uq_tags_tenant_name"),
Index("ix_tags_tenant", "tenant_id"),
Index("ix_tags_parent", "parent_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
@@ -31,6 +32,11 @@ class Tag(Base, TenantMixin, OwnedMixin):
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
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):
+24 -1
View File
@@ -78,6 +78,9 @@ async def list_tags(
"name": tag.name,
"color": tag.color,
"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
]
@@ -102,7 +105,15 @@ async def create_tag(
from app.core.hooks import do_action
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)
await db.flush()
from app.core.hooks import do_action
@@ -112,6 +123,9 @@ async def create_tag(
"name": tag.name,
"color": tag.color,
"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"]
if "color" in data:
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()
return {
@@ -152,6 +172,9 @@ async def update_tag(
"name": tag.name,
"color": tag.color,
"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):
name: str = Field(..., min_length=1, max_length=100)
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):
name: str | None = Field(None, min_length=1, max_length=100)
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):
@@ -20,6 +26,9 @@ class TagResponse(BaseModel):
name: str
color: str
entity_count: int = 0
parent_id: str | None = None
applicable_to: list[str] | None = None
icon: str | None = None
class TagAssignRequest(BaseModel):
+9
View File
@@ -20,6 +20,9 @@ export interface Tag {
created_at?: string | null;
updated_at?: string | null;
usage_count?: number;
parent_id?: string | null;
applicable_to?: string[] | null;
icon?: string | null;
}
export interface TagAssignment {
@@ -43,12 +46,18 @@ export interface CreateTagPayload {
name: string;
color?: string;
description?: string | null;
parent_id?: string | null;
applicable_to?: string[] | null;
icon?: string | null;
}
export interface UpdateTagPayload {
name?: string;
color?: string;
description?: string | null;
parent_id?: string | null;
applicable_to?: string[] | null;
icon?: string | null;
}
export interface AssignTagPayload {
+96 -2
View File
@@ -27,18 +27,25 @@ interface TagFormModalProps {
open: boolean;
onClose: () => void;
tag?: Tag | null;
tags?: Tag[];
onSubmit: (data: CreateTagPayload | UpdateTagPayload) => void;
isSubmitting: boolean;
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 isEdit = !!tag;
const [name, setName] = useState(tag?.name ?? '');
const [color, setColor] = useState(tag?.color ?? 'blue');
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
React.useEffect(() => {
@@ -46,6 +53,9 @@ function TagFormModal({ open, onClose, tag, onSubmit, isSubmitting, error }: Tag
setName(tag?.name ?? '');
setColor(tag?.color ?? 'blue');
setDescription(tag?.description ?? '');
setParentId(tag?.parent_id ?? '');
setIcon(tag?.icon ?? '');
setApplicableTo(tag?.applicable_to ?? []);
}
}, [open, tag]);
@@ -58,12 +68,18 @@ function TagFormModal({ open, onClose, tag, onSubmit, isSubmitting, error }: Tag
name: trimmedName,
color,
description: description.trim() || null,
parent_id: parentId || null,
icon: icon || null,
applicable_to: applicableTo.length > 0 ? applicableTo : null,
};
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 (
<Modal
open={open}
@@ -132,6 +148,83 @@ function TagFormModal({ open, onClose, tag, onSubmit, isSubmitting, error }: Tag
/>
</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 && (
<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}
onClose={handleFormClose}
tag={editingTag}
tags={tags}
onSubmit={handleFormSubmit}
isSubmitting={isSubmitting}
error={formError}
+1 -1
View File
@@ -259,7 +259,7 @@ const router = createBrowserRouter([
{ path: '/workflows', element: <PermissionRoute permission="workflows:read">{withSuspense(<WorkflowsPage />)}</PermissionRoute> },
{ path: '/contacts/dedup', element: <PermissionRoute permission="contacts:read">{withSuspense(<DedupMergePage />)}</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: '/activity', element: <PermissionRoute permission="activity:read">{withSuspense(<ActivityTimelinePage />)}</PermissionRoute> },
{ path: '/wiki', element: withSuspense(<WikiPage />) },