From 0d4e4f3536e2fe840a4f6b362810abacc8668d12 Mon Sep 17 00:00:00 2001 From: jbpenrath Date: Wed, 18 Jun 2025 15:28:37 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=84(front)=20clean=20a=20little=20bit?= =?UTF-8?q?=20label=20badges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We recently display label badges into thread-item. We revamp that a little bit to improve code structure and prepare the layout for further improvements. --- src/backend/core/api/openapi.json | 43 ++++++++- src/backend/core/api/serializers.py | 92 +++++++++---------- .../src/features/api/gen/models/index.ts | 1 + .../src/features/api/gen/models/thread.ts | 5 +- .../features/api/gen/models/thread_label.ts | 31 +++++++ .../layouts/components/label-badge/index.tsx | 45 --------- .../components/thread-item/_index.scss | 4 - .../components/thread-item/index.tsx | 16 +++- .../features/ui/components/badge/_index.scss | 2 +- .../features/ui/components/badge/index.tsx | 11 ++- .../ui/components/label-badge/index.tsx | 17 ++++ .../features/utils/color-helper/index.test.ts | 75 +++++++++++++++ .../src/features/utils/color-helper/index.tsx | 32 +++++++ 13 files changed, 267 insertions(+), 107 deletions(-) create mode 100644 src/frontend/src/features/api/gen/models/thread_label.ts delete mode 100644 src/frontend/src/features/layouts/components/label-badge/index.tsx create mode 100644 src/frontend/src/features/ui/components/label-badge/index.tsx create mode 100644 src/frontend/src/features/utils/color-helper/index.test.ts create mode 100644 src/frontend/src/features/utils/color-helper/index.tsx diff --git a/src/backend/core/api/openapi.json b/src/backend/core/api/openapi.json index 66bf23d3..7de90aaf 100644 --- a/src/backend/core/api/openapi.json +++ b/src/backend/core/api/openapi.json @@ -4710,7 +4710,10 @@ "readOnly": true }, "labels": { - "type": "string", + "type": "array", + "items": { + "$ref": "#/components/schemas/ThreadLabel" + }, "readOnly": true } }, @@ -4833,6 +4836,44 @@ "type": "string", "description": "* `viewer` - Viewer\n* `editor` - Editor" }, + "ThreadLabel": { + "type": "object", + "description": "Serializer to get labels details for a thread.", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "readOnly": true, + "description": "primary key for the record as UUID" + }, + "name": { + "type": "string", + "description": "Name of the label/folder (can use slashes for hierarchy, e.g. 'Work/Projects')", + "maxLength": 255 + }, + "slug": { + "type": "string", + "readOnly": true, + "description": "URL-friendly version of the name", + "pattern": "^[-a-zA-Z0-9_]+$" + }, + "color": { + "type": "string", + "description": "Color of the label in hex format (e.g. #FF0000)", + "maxLength": 7 + }, + "display_name": { + "type": "string", + "readOnly": true + } + }, + "required": [ + "display_name", + "id", + "name", + "slug" + ] + }, "User": { "type": "object", "description": "Serialize users.", diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 0e3a6372..05d54476 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -146,6 +146,51 @@ class AttachmentSerializer(serializers.ModelSerializer): ] read_only_fields = fields +class ThreadLabelSerializer(serializers.ModelSerializer): + """Serializer to get labels details for a thread.""" + + display_name = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = models.Label + fields = ["id", "name", "slug", "color", "display_name"] + read_only_fields = ["id", "slug", "display_name"] + + def get_display_name(self, instance): + """Return the display name of the label.""" + return instance.name.split("/")[-1] + +class LabelSerializer(serializers.ModelSerializer): + """Serializer for Label model.""" + + class Meta: + model = models.Label + fields = ["id", "name", "slug", "color", "mailbox", "threads"] + read_only_fields = ["id", "slug"] + + def validate(self, attrs): + """Validate that the label name is unique within the mailbox.""" + if models.Label.objects.filter( + name=attrs.get("name"), mailbox=attrs.get("mailbox") + ).exists(): + raise serializers.ValidationError( + {"name": "A label with this name already exists in this mailbox."} + ) + return attrs + + def validate_mailbox(self, value): + """Validate that user has access to the mailbox.""" + user = self.context["request"].user + if not value.accesses.filter( + user=user, + role__in=[ + models.MailboxRoleChoices.ADMIN, + models.MailboxRoleChoices.EDITOR, + ], + ).exists(): + raise PermissionDenied("You don't have access to this mailbox") + return value + class ThreadAccessDetailSerializer(serializers.ModelSerializer): """Serializer for thread access details.""" @@ -196,6 +241,7 @@ class ThreadSerializer(serializers.ModelSerializer): return None return None + @extend_schema_field(ThreadLabelSerializer(many=True)) def get_labels(self, instance): """Get labels for the thread, filtered by user's mailbox access.""" request = self.context.get("request") @@ -549,49 +595,3 @@ class ImportIMAPSerializer(ImportBaseSerializer): default=0, min_value=0, ) - - -class ThreadLabelSerializer(serializers.ModelSerializer): - """Serializer to get labels details for a thread.""" - - display_name = serializers.SerializerMethodField(read_only=True) - - class Meta: - model = models.Label - fields = ["id", "name", "slug", "color", "display_name"] - read_only_fields = ["id", "slug", "display_name"] - - def get_display_name(self, instance): - """Return the display name of the label.""" - return instance.name.split("/")[-1] - -class LabelSerializer(serializers.ModelSerializer): - """Serializer for Label model.""" - - class Meta: - model = models.Label - fields = ["id", "name", "slug", "color", "mailbox", "threads"] - read_only_fields = ["id", "slug"] - - def validate(self, attrs): - """Validate that the label name is unique within the mailbox.""" - if models.Label.objects.filter( - name=attrs.get("name"), mailbox=attrs.get("mailbox") - ).exists(): - raise serializers.ValidationError( - {"name": "A label with this name already exists in this mailbox."} - ) - return attrs - - def validate_mailbox(self, value): - """Validate that user has access to the mailbox.""" - user = self.context["request"].user - if not value.accesses.filter( - user=user, - role__in=[ - models.MailboxRoleChoices.ADMIN, - models.MailboxRoleChoices.EDITOR, - ], - ).exists(): - raise PermissionDenied("You don't have access to this mailbox") - return value diff --git a/src/frontend/src/features/api/gen/models/index.ts b/src/frontend/src/features/api/gen/models/index.ts index eb72b057..14c31785 100644 --- a/src/frontend/src/features/api/gen/models/index.ts +++ b/src/frontend/src/features/api/gen/models/index.ts @@ -71,6 +71,7 @@ export * from "./thread_access"; export * from "./thread_access_detail"; export * from "./thread_access_request"; export * from "./thread_access_role_enum"; +export * from "./thread_label"; export * from "./threads_accesses_create_params"; export * from "./threads_accesses_destroy_params"; export * from "./threads_accesses_list_params"; diff --git a/src/frontend/src/features/api/gen/models/thread.ts b/src/frontend/src/features/api/gen/models/thread.ts index 95cac6f9..aa53a99c 100644 --- a/src/frontend/src/features/api/gen/models/thread.ts +++ b/src/frontend/src/features/api/gen/models/thread.ts @@ -6,7 +6,7 @@ * OpenAPI spec version: 1.0.0 (v1.0) */ import type { ThreadAccessDetail } from "./thread_access_detail"; -import type { Label } from "./label"; +import type { ThreadLabel } from "./thread_label"; /** * Serialize threads. @@ -30,6 +30,5 @@ export interface Thread { readonly updated_at: string; readonly user_role: string; readonly accesses: readonly ThreadAccessDetail[]; - - readonly labels: readonly Label[]; + readonly labels: readonly ThreadLabel[]; } diff --git a/src/frontend/src/features/api/gen/models/thread_label.ts b/src/frontend/src/features/api/gen/models/thread_label.ts new file mode 100644 index 00000000..a88c52d4 --- /dev/null +++ b/src/frontend/src/features/api/gen/models/thread_label.ts @@ -0,0 +1,31 @@ +/** + * Generated by orval v7.9.0 🍺 + * Do not edit manually. + * messages API + * This is the messages API schema. + * OpenAPI spec version: 1.0.0 (v1.0) + */ + +/** + * Serializer to get labels details for a thread. + */ +export interface ThreadLabel { + /** primary key for the record as UUID */ + readonly id: string; + /** + * Name of the label/folder (can use slashes for hierarchy, e.g. 'Work/Projects') + * @maxLength 255 + */ + name: string; + /** + * URL-friendly version of the name + * @pattern ^[-a-zA-Z0-9_]+$ + */ + readonly slug: string; + /** + * Color of the label in hex format (e.g. #FF0000) + * @maxLength 7 + */ + color?: string; + readonly display_name: string; +} diff --git a/src/frontend/src/features/layouts/components/label-badge/index.tsx b/src/frontend/src/features/layouts/components/label-badge/index.tsx deleted file mode 100644 index eb7be102..00000000 --- a/src/frontend/src/features/layouts/components/label-badge/index.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { Badge } from "@/features/ui/components/badge" -import { Label } from "@/features/api/gen/models/label" - -type LabelBadgeProps = { - label: Label - size?: "small" | "medium" -} - -export const LabelBadge = ({ label, size = "medium" }: LabelBadgeProps) => { - return ( - - - {label.name} - - - ) -} - -// Helper function to determine if text should be black or white based on background color -// TODO: Move it in a color (in ui-kit?) -function getContrastColor(hexColor: string): string { - // Remove the # if present - const hex = hexColor.replace("#", "") - - // Convert to RGB - const r = parseInt(hex.substring(0, 2), 16) - const g = parseInt(hex.substring(2, 4), 16) - const b = parseInt(hex.substring(4, 6), 16) - - // Calculate relative luminance - const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255 - - // Return black for light colors, white for dark colors - return luminance > 0.5 ? "#000000" : "#FFFFFF" -} diff --git a/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/_index.scss b/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/_index.scss index a3bafbf6..8d9403f9 100644 --- a/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/_index.scss +++ b/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/_index.scss @@ -142,7 +142,3 @@ gap: var(--c--theme--spacings--2xs); margin-top: var(--c--theme--spacings--2xs); } - -.thread-item__labels .badge { - padding: 0; -} diff --git a/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsx b/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsx index b6f41d5d..6eadeae7 100644 --- a/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsx +++ b/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsx @@ -5,7 +5,8 @@ import { useParams, useSearchParams } from "next/navigation" import { Thread } from "@/features/api/gen/models" import { ThreadItemSenders } from "./thread-item-senders" import ThreadHelper from "@/features/utils/thread-helper" -import { LabelBadge } from "@/features/layouts/components/label-badge" +import { Badge } from "@/features/ui/components/badge" +import { LabelBadge } from "@/features/ui/components/label-badge" type ThreadItemProps = { thread: Thread @@ -42,16 +43,25 @@ export const ThreadItem = ({ thread }: ThreadItemProps) => { ) : null} */} + + +
+
+ {thread.has_draft && ( + + {t('thread_message.draft')} + + )} {thread.labels && thread.labels.length > 0 && (
{thread.labels.map((label) => ( - + ))}
)}
+

{thread.subject}

-

{thread.subject}

diff --git a/src/frontend/src/features/ui/components/badge/_index.scss b/src/frontend/src/features/ui/components/badge/_index.scss index eeb64ec5..8d67f2b4 100644 --- a/src/frontend/src/features/ui/components/badge/_index.scss +++ b/src/frontend/src/features/ui/components/badge/_index.scss @@ -8,4 +8,4 @@ color: var(--c--theme--colors--primary-400); font-weight: 600; font-variant-numeric: tabular-nums; -} \ No newline at end of file +} diff --git a/src/frontend/src/features/ui/components/badge/index.tsx b/src/frontend/src/features/ui/components/badge/index.tsx index af2a631e..3e879eff 100644 --- a/src/frontend/src/features/ui/components/badge/index.tsx +++ b/src/frontend/src/features/ui/components/badge/index.tsx @@ -1,10 +1,13 @@ -import { PropsWithChildren } from "react" +import clsx from "clsx" +import { HTMLAttributes, PropsWithChildren } from "react" + +type BadgeProps = PropsWithChildren> -export const Badge = ({ children }: PropsWithChildren) => { +export const Badge = ({ children, className, ...props }: BadgeProps) => { return ( -
+
{children}
) -} \ No newline at end of file +} diff --git a/src/frontend/src/features/ui/components/label-badge/index.tsx b/src/frontend/src/features/ui/components/label-badge/index.tsx new file mode 100644 index 00000000..f95818f9 --- /dev/null +++ b/src/frontend/src/features/ui/components/label-badge/index.tsx @@ -0,0 +1,17 @@ +import { Badge } from "@/features/ui/components/badge" +import { ColorHelper } from "@/features/utils/color-helper" +import { ThreadLabel } from "@/features/api/gen" + +type LabelBadgeProps = { + label: ThreadLabel +} + +export const LabelBadge = ({ label }: LabelBadgeProps) => { + const badgeColor = ColorHelper.getContrastColor(label.color!); + + return ( + + {label.display_name} + + ) +} diff --git a/src/frontend/src/features/utils/color-helper/index.test.ts b/src/frontend/src/features/utils/color-helper/index.test.ts new file mode 100644 index 00000000..bbacb46a --- /dev/null +++ b/src/frontend/src/features/utils/color-helper/index.test.ts @@ -0,0 +1,75 @@ +import { ColorHelper } from './index'; + +describe('ColorHelper', () => { + describe('getContrastColor', () => { + describe('with default options', () => { + it('should return dark color for light backgrounds', () => { + expect(ColorHelper.getContrastColor('#FFFFFF')).toBe('#000000'); + expect(ColorHelper.getContrastColor('#F0F0F0')).toBe('#000000'); + expect(ColorHelper.getContrastColor('#E0E0E0')).toBe('#000000'); + expect(ColorHelper.getContrastColor('#D0D0D0')).toBe('#000000'); + }); + + it('should return light color for dark backgrounds', () => { + expect(ColorHelper.getContrastColor('#000000')).toBe('#FFFFFF'); + expect(ColorHelper.getContrastColor('#1A1A1A')).toBe('#FFFFFF'); + expect(ColorHelper.getContrastColor('#2D2D2D')).toBe('#FFFFFF'); + expect(ColorHelper.getContrastColor('#404040')).toBe('#FFFFFF'); + }); + + it('should handle colors with # prefix', () => { + expect(ColorHelper.getContrastColor('#FFFFFF')).toBe('#000000'); + expect(ColorHelper.getContrastColor('#000000')).toBe('#FFFFFF'); + }); + + it('should handle colors without # prefix', () => { + expect(ColorHelper.getContrastColor('FFFFFF')).toBe('#000000'); + expect(ColorHelper.getContrastColor('000000')).toBe('#FFFFFF'); + }); + }); + + describe('with custom options', () => { + it('should use custom light and dark colors', () => { + const options = { lightColor: '#00FF00', darkColor: '#FF0000' }; + + expect(ColorHelper.getContrastColor('#FFFFFF', options)).toBe('#FF0000'); + expect(ColorHelper.getContrastColor('#000000', options)).toBe('#00FF00'); + }); + + it('should use custom light color only', () => { + const options = { lightColor: '#00FF00' }; + + expect(ColorHelper.getContrastColor('#FFFFFF', options)).toBe('#000000'); // default dark + expect(ColorHelper.getContrastColor('#000000', options)).toBe('#00FF00'); // custom light + }); + + it('should use custom dark color only', () => { + const options = { darkColor: '#FF0000' }; + + expect(ColorHelper.getContrastColor('#FFFFFF', options)).toBe('#FF0000'); // custom dark + expect(ColorHelper.getContrastColor('#000000', options)).toBe('#FFFFFF'); // default light + }); + }); + + describe('edge cases', () => { + it('should handle gray colors around the threshold', () => { + // Colors with luminance close to 0.5 + expect(ColorHelper.getContrastColor('#808080')).toBe('#000000'); + expect(ColorHelper.getContrastColor('#7F7F7F')).toBe('#FFFFFF'); + expect(ColorHelper.getContrastColor('#818181')).toBe('#000000'); + }); + + it('should handle pure colors', () => { + expect(ColorHelper.getContrastColor('#FF0000')).toBe('#FFFFFF'); + expect(ColorHelper.getContrastColor('#00FF00')).toBe('#000000'); + expect(ColorHelper.getContrastColor('#0000FF')).toBe('#FFFFFF'); + }); + + it('should handle mixed colors', () => { + expect(ColorHelper.getContrastColor('#FF8000')).toBe('#000000'); + expect(ColorHelper.getContrastColor('#8000FF')).toBe('#FFFFFF'); + expect(ColorHelper.getContrastColor('#00FFFF')).toBe('#000000'); + }); + }); + }); +}); diff --git a/src/frontend/src/features/utils/color-helper/index.tsx b/src/frontend/src/features/utils/color-helper/index.tsx new file mode 100644 index 00000000..8dfe236d --- /dev/null +++ b/src/frontend/src/features/utils/color-helper/index.tsx @@ -0,0 +1,32 @@ +type ContractColorOptions = { + lightColor?: string; + darkColor?: string; +} + +/** + * Helper class about colors + */ +export class ColorHelper { + /** + * Act like the contrast-color css function + * https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/contrast-color + * According the luminance of the provided color, it will pick the best color between lightColor and darkColor + * As options, you can provide the lightColor and darkColor to use (can be any valid css color) + * + * TODO: Use CSS properties for light and dark colors to be support theme switch + */ + static getContrastColor(hexColor: string, { lightColor = "#FFFFFF", darkColor = "#000000" }: ContractColorOptions = {}) { + // Remove the # if present + const hex = hexColor.slice(1); + + // Convert to RGB + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + + // Calculate relative luminance + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + + return luminance > 0.5 ? darkColor : lightColor + } +}