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
|
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):
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 />) },
|
||||||
|
|||||||
Reference in New Issue
Block a user