feat(UI-Overhaul-Phase6): Tags Umstrukturierung
Check Cross-Plugin Imports / check (push) Has been cancelled
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:
@@ -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")
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />) },
|
||||
|
||||
Reference in New Issue
Block a user