diff --git a/apps/api/drizzle/0003_workable_frog_thor.sql b/apps/api/drizzle/0003_workable_frog_thor.sql new file mode 100644 index 00000000..cfa89cfe --- /dev/null +++ b/apps/api/drizzle/0003_workable_frog_thor.sql @@ -0,0 +1,53 @@ +-- Migration: Convert task-scoped labels to workspace-scoped labels +-- Step 1: Create task_label junction table +CREATE TABLE "task_label" ( + "id" text PRIMARY KEY NOT NULL, + "task_id" text NOT NULL, + "label_id" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); + +-- Step 2: Add workspace_id column as nullable initially +ALTER TABLE "label" ADD COLUMN "workspace_id" text; + +-- Step 3: Populate workspace_id for existing labels by looking up through task -> project -> workspace +UPDATE "label" +SET "workspace_id" = ( + SELECT p."workspace_id" + FROM "task" t + JOIN "project" p ON t."project_id" = p."id" + WHERE t."id" = "label"."task_id" +) +WHERE "workspace_id" IS NULL AND "task_id" IS NOT NULL; + +-- Step 4: For any labels where workspace lookup failed, assign to first available workspace +UPDATE "label" +SET "workspace_id" = (SELECT "id" FROM "workspace" LIMIT 1) +WHERE "workspace_id" IS NULL; + +-- Step 5: Create task_label relationships for existing labels +INSERT INTO "task_label" ("id", "task_id", "label_id", "created_at") +SELECT + 'tl_' || substr(md5(random()::text), 1, 25) as "id", + "task_id", + "id" as "label_id", + "created_at" +FROM "label" +WHERE "task_id" IS NOT NULL; + +-- Step 6: Now make workspace_id NOT NULL and add constraints +ALTER TABLE "label" ALTER COLUMN "workspace_id" SET NOT NULL; + +-- Step 7: Add foreign key constraints +ALTER TABLE "task_label" ADD CONSTRAINT "task_label_task_id_task_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."task"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "task_label" ADD CONSTRAINT "task_label_label_id_label_id_fk" FOREIGN KEY ("label_id") REFERENCES "public"."label"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "label" ADD CONSTRAINT "label_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE cascade; + +-- Step 8: Drop old constraints and task_id column +ALTER TABLE "label" DROP CONSTRAINT "label_task_id_task_id_fk";--> statement-breakpoint +ALTER TABLE "label" DROP COLUMN "task_id"; + +-- Step 9: Add indexes for better performance +CREATE INDEX IF NOT EXISTS "task_label_task_id_idx" ON "task_label" ("task_id"); +CREATE INDEX IF NOT EXISTS "task_label_label_id_idx" ON "task_label" ("label_id"); +CREATE INDEX IF NOT EXISTS "label_workspace_id_idx" ON "label" ("workspace_id"); \ No newline at end of file diff --git a/apps/api/drizzle/meta/0003_snapshot.json b/apps/api/drizzle/meta/0003_snapshot.json new file mode 100644 index 00000000..daeeedad --- /dev/null +++ b/apps/api/drizzle/meta/0003_snapshot.json @@ -0,0 +1,825 @@ +{ + "id": "e1526881-6f36-4513-84c6-5a47e7035d71", + "prevId": "594c9f8c-e00f-4ab1-b4b0-725728bc40eb", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.activity": { + "name": "activity", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "activity_task_id_task_id_fk": { + "name": "activity_task_id_task_id_fk", + "tableFrom": "activity", + "tableTo": "task", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "activity_user_email_user_email_fk": { + "name": "activity_user_email_user_email_fk", + "tableFrom": "activity", + "tableTo": "user", + "columnsFrom": ["user_email"], + "columnsTo": ["email"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_integration": { + "name": "github_integration", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_owner": { + "name": "repository_owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_name": { + "name": "repository_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "github_integration_project_id_project_id_fk": { + "name": "github_integration_project_id_project_id_fk", + "tableFrom": "github_integration", + "tableTo": "project", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_integration_project_id_unique": { + "name": "github_integration_project_id_unique", + "nullsNotDistinct": false, + "columns": ["project_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.label": { + "name": "label", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "label_workspace_id_workspace_id_fk": { + "name": "label_workspace_id_workspace_id_fk", + "tableFrom": "label", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification": { + "name": "notification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "is_read": { + "name": "is_read", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "notification_user_email_user_email_fk": { + "name": "notification_user_email_user_email_fk", + "tableFrom": "notification", + "tableTo": "user", + "columnsFrom": ["user_email"], + "columnsTo": ["email"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'Layout'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "project_workspace_id_workspace_id_fk": { + "name": "project_workspace_id_workspace_id_fk", + "tableFrom": "project", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_label": { + "name": "task_label", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label_id": { + "name": "label_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "task_label_task_id_task_id_fk": { + "name": "task_label_task_id_task_id_fk", + "tableFrom": "task_label", + "tableTo": "task", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "task_label_label_id_label_id_fk": { + "name": "task_label_label_id_label_id_fk", + "tableFrom": "task_label", + "tableTo": "label", + "columnsFrom": ["label_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task": { + "name": "task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "assignee_email": { + "name": "assignee_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'to-do'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'low'" + }, + "due_date": { + "name": "due_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "task_project_id_project_id_fk": { + "name": "task_project_id_project_id_fk", + "tableFrom": "task", + "tableTo": "project", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "task_assignee_email_user_email_fk": { + "name": "task_assignee_email_user_email_fk", + "tableFrom": "task", + "tableTo": "user", + "columnsFrom": ["assignee_email"], + "columnsTo": ["email"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.time_entry": { + "name": "time_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_time": { + "name": "start_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "time_entry_task_id_task_id_fk": { + "name": "time_entry_task_id_task_id_fk", + "tableFrom": "time_entry", + "tableTo": "task", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "time_entry_user_email_user_email_fk": { + "name": "time_entry_user_email_user_email_fk", + "tableFrom": "time_entry", + "tableTo": "user", + "columnsFrom": ["user_email"], + "columnsTo": ["email"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_email": { + "name": "owner_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_owner_email_user_email_fk": { + "name": "workspace_owner_email_user_email_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_email"], + "columnsTo": ["email"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_member": { + "name": "workspace_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_member_workspace_id_workspace_id_fk": { + "name": "workspace_member_workspace_id_workspace_id_fk", + "tableFrom": "workspace_member", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 06bbe6d7..9618b005 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1752666410349, "tag": "0002_easy_miek", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1753636665457, + "tag": "0003_workable_frog_thor", + "breakpoints": true } ] } diff --git a/apps/api/src/database/relations.ts b/apps/api/src/database/relations.ts index a97c0570..e9ec7332 100644 --- a/apps/api/src/database/relations.ts +++ b/apps/api/src/database/relations.ts @@ -6,6 +6,7 @@ import { notificationTable, projectTable, sessionTable, + taskLabelTable, taskTable, timeEntryTable, userTable, @@ -39,6 +40,7 @@ export const workspaceTableRelations = relations( }), members: many(workspaceUserTable), projects: many(projectTable), + labels: many(labelTable), }), ); @@ -75,7 +77,7 @@ export const taskTableRelations = relations(taskTable, ({ one, many }) => ({ }), timeEntries: many(timeEntryTable), activities: many(activityTable), - labels: many(labelTable), + taskLabels: many(taskLabelTable), })); export const timeEntryTableRelations = relations(timeEntryTable, ({ one }) => ({ @@ -100,11 +102,23 @@ export const activityTableRelations = relations(activityTable, ({ one }) => ({ }), })); -export const labelTableRelations = relations(labelTable, ({ one }) => ({ +export const labelTableRelations = relations(labelTable, ({ one, many }) => ({ + workspace: one(workspaceTable, { + fields: [labelTable.workspaceId], + references: [workspaceTable.id], + }), + taskLabels: many(taskLabelTable), +})); + +export const taskLabelTableRelations = relations(taskLabelTable, ({ one }) => ({ task: one(taskTable, { - fields: [labelTable.taskId], + fields: [taskLabelTable.taskId], references: [taskTable.id], }), + label: one(labelTable, { + fields: [taskLabelTable.labelId], + references: [labelTable.id], + }), })); export const notificationTableRelations = relations( diff --git a/apps/api/src/database/schema.ts b/apps/api/src/database/schema.ts index 03509bf9..1e9d90f6 100644 --- a/apps/api/src/database/schema.ts +++ b/apps/api/src/database/schema.ts @@ -148,12 +148,31 @@ export const labelTable = pgTable("label", { name: text("name").notNull(), color: text("color").notNull(), createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), + workspaceId: text("workspace_id") + .notNull() + .references(() => workspaceTable.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), +}); + +export const taskLabelTable = pgTable("task_label", { + id: text("id") + .$defaultFn(() => createId()) + .primaryKey(), taskId: text("task_id") .notNull() .references(() => taskTable.id, { onDelete: "cascade", onUpdate: "cascade", }), + labelId: text("label_id") + .notNull() + .references(() => labelTable.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), + createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), }); export const notificationTable = pgTable("notification", { diff --git a/apps/api/src/label/controllers/assign-label-to-task.ts b/apps/api/src/label/controllers/assign-label-to-task.ts new file mode 100644 index 00000000..0be4da62 --- /dev/null +++ b/apps/api/src/label/controllers/assign-label-to-task.ts @@ -0,0 +1,29 @@ +import { and, eq } from "drizzle-orm"; +import db from "../../database"; +import { taskLabelTable } from "../../database/schema"; + +async function assignLabelToTask(taskId: string, labelId: string) { + const existingAssignment = await db + .select() + .from(taskLabelTable) + .where( + and( + eq(taskLabelTable.taskId, taskId), + eq(taskLabelTable.labelId, labelId), + ), + ) + .limit(1); + + if (existingAssignment.length > 0) { + throw new Error("Label is already assigned to this task"); + } + + const [taskLabel] = await db + .insert(taskLabelTable) + .values({ taskId, labelId }) + .returning(); + + return taskLabel; +} + +export default assignLabelToTask; diff --git a/apps/api/src/label/controllers/create-label.ts b/apps/api/src/label/controllers/create-label.ts index a8371e93..66e26db6 100644 --- a/apps/api/src/label/controllers/create-label.ts +++ b/apps/api/src/label/controllers/create-label.ts @@ -1,10 +1,23 @@ +import { and, eq } from "drizzle-orm"; import db from "../../database"; import { labelTable } from "../../database/schema"; -async function createLabel(name: string, color: string, taskId: string) { +async function createLabel(name: string, color: string, workspaceId: string) { + const existingLabel = await db + .select() + .from(labelTable) + .where( + and(eq(labelTable.name, name), eq(labelTable.workspaceId, workspaceId)), + ) + .limit(1); + + if (existingLabel.length > 0) { + throw new Error("Label with this name already exists in the workspace"); + } + const [label] = await db .insert(labelTable) - .values({ name, color, taskId }) + .values({ name, color, workspaceId }) .returning(); return label; diff --git a/apps/api/src/label/controllers/get-labels-by-task-id.ts b/apps/api/src/label/controllers/get-labels-by-task-id.ts index d03a9270..be3a81b0 100644 --- a/apps/api/src/label/controllers/get-labels-by-task-id.ts +++ b/apps/api/src/label/controllers/get-labels-by-task-id.ts @@ -1,9 +1,20 @@ +import { eq } from "drizzle-orm"; import db from "../../database"; +import { labelTable, taskLabelTable } from "../../database/schema"; async function getLabelsByTaskId(taskId: string) { - const labels = await db.query.labelTable.findMany({ - where: (label, { eq }) => eq(label.taskId, taskId), - }); + const labels = await db + .select({ + id: labelTable.id, + name: labelTable.name, + color: labelTable.color, + workspaceId: labelTable.workspaceId, + createdAt: labelTable.createdAt, + }) + .from(taskLabelTable) + .innerJoin(labelTable, eq(taskLabelTable.labelId, labelTable.id)) + .where(eq(taskLabelTable.taskId, taskId)) + .orderBy(labelTable.name); return labels; } diff --git a/apps/api/src/label/controllers/get-labels-by-workspace-id.ts b/apps/api/src/label/controllers/get-labels-by-workspace-id.ts new file mode 100644 index 00000000..190e6f91 --- /dev/null +++ b/apps/api/src/label/controllers/get-labels-by-workspace-id.ts @@ -0,0 +1,12 @@ +import db from "../../database"; + +async function getLabelsByWorkspaceId(workspaceId: string) { + const labels = await db.query.labelTable.findMany({ + where: (label, { eq }) => eq(label.workspaceId, workspaceId), + orderBy: (label, { asc }) => [asc(label.name)], + }); + + return labels; +} + +export default getLabelsByWorkspaceId; diff --git a/apps/api/src/label/controllers/unassign-label-from-task.ts b/apps/api/src/label/controllers/unassign-label-from-task.ts new file mode 100644 index 00000000..835d97fd --- /dev/null +++ b/apps/api/src/label/controllers/unassign-label-from-task.ts @@ -0,0 +1,23 @@ +import { and, eq } from "drizzle-orm"; +import db from "../../database"; +import { taskLabelTable } from "../../database/schema"; + +async function unassignLabelFromTask(taskId: string, labelId: string) { + const deletedAssignment = await db + .delete(taskLabelTable) + .where( + and( + eq(taskLabelTable.taskId, taskId), + eq(taskLabelTable.labelId, labelId), + ), + ) + .returning(); + + if (deletedAssignment.length === 0) { + throw new Error("Label assignment not found"); + } + + return deletedAssignment[0]; +} + +export default unassignLabelFromTask; diff --git a/apps/api/src/label/index.ts b/apps/api/src/label/index.ts index 12e409d3..65977964 100644 --- a/apps/api/src/label/index.ts +++ b/apps/api/src/label/index.ts @@ -1,10 +1,13 @@ import { zValidator } from "@hono/zod-validator"; import { Hono } from "hono"; import { z } from "zod"; +import assignLabelToTask from "./controllers/assign-label-to-task"; import createLabel from "./controllers/create-label"; import deleteLabel from "./controllers/delete-label"; import getLabel from "./controllers/get-label"; import getLabelsByTaskId from "./controllers/get-labels-by-task-id"; +import getLabelsByWorkspaceId from "./controllers/get-labels-by-workspace-id"; +import unassignLabelFromTask from "./controllers/unassign-label-from-task"; import updateLabel from "./controllers/update-label"; const label = new Hono<{ @@ -13,7 +16,7 @@ const label = new Hono<{ }; }>() .get( - "/:taskId", + "/task/:taskId", zValidator("param", z.object({ taskId: z.string() })), async (c) => { const { taskId } = c.req.valid("param"); @@ -21,16 +24,68 @@ const label = new Hono<{ return c.json(labels); }, ) + .get( + "/workspace/:workspaceId", + zValidator("param", z.object({ workspaceId: z.string() })), + async (c) => { + const { workspaceId } = c.req.valid("param"); + const labels = await getLabelsByWorkspaceId(workspaceId); + return c.json(labels); + }, + ) .post( "/", zValidator( "json", - z.object({ name: z.string(), color: z.string(), taskId: z.string() }), + z.object({ + name: z.string(), + color: z.string(), + workspaceId: z.string(), + }), ), async (c) => { - const { name, color, taskId } = c.req.valid("json"); - const label = await createLabel(name, color, taskId); - return c.json(label); + const { name, color, workspaceId } = c.req.valid("json"); + try { + const label = await createLabel(name, color, workspaceId); + return c.json(label); + } catch (error) { + return c.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + 400, + ); + } + }, + ) + .post( + "/assign", + zValidator("json", z.object({ taskId: z.string(), labelId: z.string() })), + async (c) => { + const { taskId, labelId } = c.req.valid("json"); + try { + const taskLabel = await assignLabelToTask(taskId, labelId); + return c.json(taskLabel); + } catch (error) { + return c.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + 400, + ); + } + }, + ) + .delete( + "/assign", + zValidator("json", z.object({ taskId: z.string(), labelId: z.string() })), + async (c) => { + const { taskId, labelId } = c.req.valid("json"); + try { + const taskLabel = await unassignLabelFromTask(taskId, labelId); + return c.json(taskLabel); + } catch (error) { + return c.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + 400, + ); + } }, ) .delete("/:id", async (c) => { diff --git a/apps/web/src/components/kanban-board/task-card-context-menu/task-card-context-menu-content.tsx b/apps/web/src/components/kanban-board/task-card-context-menu/task-card-context-menu-content.tsx index 03172c10..c7aa044f 100644 --- a/apps/web/src/components/kanban-board/task-card-context-menu/task-card-context-menu-content.tsx +++ b/apps/web/src/components/kanban-board/task-card-context-menu/task-card-context-menu-content.tsx @@ -9,8 +9,12 @@ import { User, } from "lucide-react"; +import useAssignLabelToTask from "@/hooks/mutations/label/use-assign-label-to-task"; +import useUnassignLabelFromTask from "@/hooks/mutations/label/use-unassign-label-from-task"; import useCreateTask from "@/hooks/mutations/task/use-create-task"; import useUpdateTask from "@/hooks/mutations/task/use-update-task"; +import useGetLabelsByTask from "@/hooks/queries/label/use-get-labels-by-task"; +import useGetLabelsByWorkspace from "@/hooks/queries/label/use-get-labels-by-workspace"; import useGetProjects from "@/hooks/queries/project/use-get-projects"; import useGetActiveWorkspaceUsers from "@/hooks/queries/workspace-users/use-active-workspace-users"; @@ -42,6 +46,14 @@ interface TaskCardContext { projectId: string; } +type TaskLabel = { + id: string; + name: string; + color: string; + workspaceId: string; + createdAt: string; +}; + interface TaskCardContextMenuContentProps { task: Task; taskCardContext: TaskCardContext; @@ -60,6 +72,14 @@ export default function TaskCardContextMenuContent({ const { mutateAsync: updateTask } = useUpdateTask(); const { mutateAsync: createTask } = useCreateTask(); const { mutateAsync: deleteTask } = useDeleteTask(); + const { mutateAsync: assignLabel } = useAssignLabelToTask(); + const { mutateAsync: unassignLabel } = useUnassignLabelFromTask(); + + // Get workspace labels and task-assigned labels + const { data: workspaceLabels = [] } = useGetLabelsByWorkspace( + taskCardContext.worskpaceId, + ); + const { data: taskLabels = [] } = useGetLabelsByTask(task.id); const projectsOptions = useMemo(() => { return projects?.map((project) => { @@ -165,6 +185,116 @@ export default function TaskCardContextMenuContent({ } }; + const handleToggleLabel = async (label: TaskLabel) => { + try { + const assignedLabelIds = new Set(taskLabels.map((l: TaskLabel) => l.id)); + + if (assignedLabelIds.has(label.id)) { + await unassignLabel({ taskId: task.id, labelId: label.id }); + toast.success("Label removed"); + } else { + await assignLabel({ taskId: task.id, labelId: label.id }); + toast.success("Label added"); + } + + // Invalidate all related queries with correct keys + await queryClient.invalidateQueries({ queryKey: ["labels", task.id] }); + await queryClient.invalidateQueries({ + queryKey: ["labels", "workspace", taskCardContext.worskpaceId], + }); + await queryClient.invalidateQueries({ queryKey: ["task", task.id] }); + await queryClient.invalidateQueries({ + queryKey: ["tasks", taskCardContext.projectId], + }); + + // Refetch immediately + await queryClient.refetchQueries({ queryKey: ["labels", task.id] }); + await queryClient.refetchQueries({ + queryKey: ["labels", "workspace", taskCardContext.worskpaceId], + }); + await queryClient.refetchQueries({ queryKey: ["task", task.id] }); + await queryClient.refetchQueries({ + queryKey: ["tasks", taskCardContext.projectId], + }); + } catch (error) { + toast.error("Failed to update label"); + } + }; + + const getColorConfig = (colorKey: string) => { + const labelColors = [ + { + value: "gray", + label: "Gray", + color: "#6B7280", + bg: "#F3F4F6", + text: "#374151", + }, + { + value: "blue", + label: "Blue", + color: "#3B82F6", + bg: "#EBF8FF", + text: "#1E40AF", + }, + { + value: "purple", + label: "Purple", + color: "#8B5CF6", + bg: "#F3E8FF", + text: "#6B21A8", + }, + { + value: "teal", + label: "Teal", + color: "#14B8A6", + bg: "#F0FDFA", + text: "#134E4A", + }, + { + value: "green", + label: "Green", + color: "#10B981", + bg: "#ECFDF5", + text: "#065F46", + }, + { + value: "yellow", + label: "Yellow", + color: "#F59E0B", + bg: "#FFFBEB", + text: "#92400E", + }, + { + value: "orange", + label: "Orange", + color: "#F97316", + bg: "#FFF7ED", + text: "#9A3412", + }, + { + value: "pink", + label: "Pink", + color: "#EC4899", + bg: "#FDF2F8", + text: "#BE185D", + }, + { + value: "red", + label: "Red", + color: "#EF4444", + bg: "#FEF2F2", + text: "#DC2626", + }, + ]; + return labelColors.find((c) => c.value === colorKey) || labelColors[1]; // Default to blue + }; + + // Create set of assigned label IDs for quick lookup + const assignedLabelIds = new Set( + taskLabels.map((label: TaskLabel) => label.id), + ); + return ( + + + + Labels + + + {workspaceLabels.length > 0 ? ( + workspaceLabels.map((label: TaskLabel) => { + const colorConfig = getColorConfig(label.color); + return ( + handleToggleLabel(label)} + className="flex items-center gap-2 cursor-pointer" + > +
+ {label.name} + + ); + }) + ) : ( +
+ No labels available +
+ )} + + + {" "} diff --git a/apps/web/src/components/kanban-board/task-labels.tsx b/apps/web/src/components/kanban-board/task-labels.tsx index 6c160760..4120c2d5 100644 --- a/apps/web/src/components/kanban-board/task-labels.tsx +++ b/apps/web/src/components/kanban-board/task-labels.tsx @@ -24,14 +24,6 @@ type LabelColor = | "pink" | "red"; -type Label = { - id: string; - name: string; - color: string; - taskId: string; - createdAt: string; -}; - function TaskCardLabels({ taskId }: { taskId: string }) { const { data: labels = [] } = useGetLabelsByTask(taskId); @@ -39,7 +31,7 @@ function TaskCardLabels({ taskId }: { taskId: string }) { return (
- {labels.map((label: Label) => ( + {labels.map((label) => ( wl.name === label.name, + ); + + if (existingLabel) { + // Assign existing label to task + await assignLabel({ + taskId: newTask.id, + labelId: existingLabel.id, + }); + } else { + // Create new label and assign it + const newLabel = await createLabel({ + name: label.name, + color: label.color, + workspaceId: workspace.id, + }); + + if (newLabel?.id) { + await assignLabel({ taskId: newTask.id, labelId: newLabel.id }); + } + } } catch (error) { - console.error("Failed to create label:", error); + console.error("Failed to assign label:", error); } } + await queryClient.invalidateQueries({ queryKey: ["tasks", project.id] }); + await queryClient.invalidateQueries({ queryKey: ["task", newTask.id] }); + await queryClient.invalidateQueries({ + queryKey: ["labels", "workspace", workspace.id], + }); + + await queryClient.refetchQueries({ queryKey: ["tasks", project.id] }); + await queryClient.refetchQueries({ queryKey: ["task", newTask.id] }); + await queryClient.refetchQueries({ + queryKey: ["labels", "workspace", workspace.id], + }); + const updatedProject = produce(project, (draft) => { if (newTask.status !== "planned" && newTask.status !== "archived") { const targetColumn = draft.columns?.find( @@ -223,14 +270,15 @@ function CreateTaskModal({ open, onClose, status }: CreateTaskModalProps) { const selectedPriority = priorityOptions.find((p) => p.value === priority); const selectedUser = users?.find((u) => u.userEmail === assigneeEmail); - const filteredLabels = labels.filter((label: Label) => + const filteredLabels = workspaceLabels.filter((label: WorkspaceLabel) => label.name.toLowerCase().includes(searchValue.toLowerCase()), ); const isCreatingNewLabel = searchValue && - !labels.some( - (label: Label) => label.name.toLowerCase() === searchValue.toLowerCase(), + !workspaceLabels.some( + (label: WorkspaceLabel) => + label.name.toLowerCase() === searchValue.toLowerCase(), ); useEffect(() => { @@ -257,9 +305,14 @@ function CreateTaskModal({ open, onClose, status }: CreateTaskModalProps) { if (existingLabel) { setLabels(labels.filter((l) => l.name !== labelName)); } else { - const labelToAdd = labels.find((l) => l.name === labelName); + const labelToAdd = workspaceLabels.find( + (l: WorkspaceLabel) => l.name === labelName, + ); if (labelToAdd) { - setLabels([...labels.filter((l) => l.name !== labelName), labelToAdd]); + setLabels([ + ...labels.filter((l) => l.name !== labelName), + { name: labelToAdd.name, color: labelToAdd.color as LabelColor }, + ]); } } }; @@ -279,6 +332,7 @@ function CreateTaskModal({ open, onClose, status }: CreateTaskModalProps) { setLabels([...labels, { name: searchValue.trim(), color: selectedColor }]); setSearchValue(""); setSelectedColor("gray"); + setColorPickerOpen(false); searchInputRef.current?.focus(); }; @@ -286,6 +340,10 @@ function CreateTaskModal({ open, onClose, status }: CreateTaskModalProps) { setLabels(labels.filter((l) => l.name !== labelName)); }; + const getColorValue = (colorKey: string) => { + return labelColors.find((c) => c.value === colorKey)?.color || "#94a3b8"; + }; + return ( @@ -530,53 +588,39 @@ function CreateTaskModal({ open, onClose, status }: CreateTaskModalProps) {
{filteredLabels.length > 0 ? (
- {filteredLabels.map((label: Label) => ( - - ))} + {filteredLabels.map((label: WorkspaceLabel) => { + const isSelected = labels.some( + (l) => l.name === label.name, + ); + return ( + + ); + })}
) : null} {isCreatingNewLabel && (
- - -
+
+

+ Pick a color for label +

+
+
{labelColors.map((color) => ( ))}
diff --git a/apps/web/src/components/task/task-labels.tsx b/apps/web/src/components/task/task-labels.tsx index fb0b9413..678f5672 100644 --- a/apps/web/src/components/task/task-labels.tsx +++ b/apps/web/src/components/task/task-labels.tsx @@ -1,19 +1,27 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { FormItem, FormLabel } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import useAssignLabelToTask from "@/hooks/mutations/label/use-assign-label-to-task"; import useCreateLabel from "@/hooks/mutations/label/use-create-label"; -import useDeleteLabel from "@/hooks/mutations/label/use-delete-label"; +import useUnassignLabelFromTask from "@/hooks/mutations/label/use-unassign-label-from-task"; import useGetLabelsByTask from "@/hooks/queries/label/use-get-labels-by-task"; -import { cn } from "@/lib/cn"; -import * as Popover from "@radix-ui/react-popover"; +import useGetLabelsByWorkspace from "@/hooks/queries/label/use-get-labels-by-workspace"; +import useProjectStore from "@/store/project"; +import useWorkspaceStore from "@/store/workspace"; import { useQueryClient } from "@tanstack/react-query"; -import { Check, PlusIcon, Search, Tag } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { Check, Plus, Search, X } from "lucide-react"; +import { useState } from "react"; import { toast } from "sonner"; const labelColors = [ - { value: "gray", label: "Grey", color: "#94a3b8" }, - { value: "dark-gray", label: "Dark Grey", color: "#64748b" }, + { value: "gray", label: "Gray", color: "#94a3b8" }, + { value: "blue", label: "Blue", color: "#3b82f6" }, { value: "purple", label: "Purple", color: "#a855f7" }, { value: "teal", label: "Teal", color: "#14b8a6" }, { value: "green", label: "Green", color: "#22c55e" }, @@ -25,7 +33,7 @@ const labelColors = [ type LabelColor = | "gray" - | "dark-gray" + | "blue" | "purple" | "teal" | "green" @@ -34,342 +42,320 @@ type LabelColor = | "pink" | "red"; -type Label = { +type TaskLabel = { id: string; name: string; color: string; - taskId: string; + workspaceId: string; createdAt: string; }; -function TaskLabels({ - taskId, - setIsSaving, -}: { +interface TaskLabelsProps { taskId: string; setIsSaving: (isSaving: boolean) => void; -}) { - const [isOpen, setIsOpen] = useState(false); +} + +function TaskLabels({ taskId, setIsSaving }: TaskLabelsProps) { + const [open, setOpen] = useState(false); const [searchValue, setSearchValue] = useState(""); - const [selectedColor, setSelectedColor] = useState("gray"); const [colorPickerOpen, setColorPickerOpen] = useState(false); - const searchInputRef = useRef(null); - const queryClient = useQueryClient(); - const { mutateAsync: createLabel } = useCreateLabel(); - const { mutateAsync: deleteLabel } = useDeleteLabel(); + const { workspace } = useWorkspaceStore(); + const { project } = useProjectStore(); - const { data: labels = [] } = useGetLabelsByTask(taskId); + const { mutateAsync: createLabel } = useCreateLabel(); + const { mutateAsync: assignLabel } = useAssignLabelToTask(); + const { mutateAsync: unassignLabel } = useUnassignLabelFromTask(); - const [taskLabels, setTaskLabels] = useState([]); + const { data: workspaceLabels = [] } = useGetLabelsByWorkspace( + workspace?.id ?? "", + ); + const { data: taskLabels = [] } = useGetLabelsByTask(taskId); - useEffect(() => { - if (labels?.length) { - setTaskLabels(labels.map((label: Label) => label.id)); - } else { - setTaskLabels([]); - } - }, [labels]); + const assignedLabelIds = new Set( + taskLabels.map((label: TaskLabel) => label.id), + ); - const filteredLabels = labels.filter((label: Label) => + const filteredLabels = workspaceLabels.filter((label: TaskLabel) => label.name.toLowerCase().includes(searchValue.toLowerCase()), ); const isCreatingNewLabel = - searchValue && - !labels.some( - (label: Label) => label.name.toLowerCase() === searchValue.toLowerCase(), + searchValue.trim() && + !workspaceLabels.some( + (label: TaskLabel) => + label.name.toLowerCase() === searchValue.trim().toLowerCase(), ); - useEffect(() => { - if (isOpen && searchInputRef.current) { - setTimeout(() => searchInputRef.current?.focus(), 100); - } - }, [isOpen]); - - const handleSearchKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && isCreatingNewLabel) { - e.preventDefault(); - handleCreateLabel(); - } else if (e.key === "Escape") { - if (searchValue) { - setSearchValue(""); - } else { - setIsOpen(false); - } - } - }; - - const toggleLabel = async (labelId: string) => { + const handleToggleLabel = async (label: TaskLabel) => { setIsSaving(true); try { - const label = labels.find((l: Label) => l.id === labelId); - if (!label) { - throw new Error("Label not found"); - } - - if (taskLabels.includes(labelId)) { - setTaskLabels(taskLabels.filter((id) => id !== labelId)); - - await deleteLabel({ id: labelId }); + if (assignedLabelIds.has(label.id)) { + await unassignLabel({ taskId, labelId: label.id }); toast.success("Label removed"); } else { - setTaskLabels([...taskLabels, labelId]); - - await createLabel({ - name: label.name, - color: label.color as LabelColor, - taskId, - }); + await assignLabel({ taskId, labelId: label.id }); toast.success("Label added"); } await queryClient.invalidateQueries({ queryKey: ["labels", taskId] }); - } catch (error) { - toast.error("Failed to update labels"); - console.error(error); + await queryClient.invalidateQueries({ + queryKey: ["labels", "workspace", workspace?.id], + }); + await queryClient.invalidateQueries({ queryKey: ["task", taskId] }); + if (project?.id) { + await queryClient.invalidateQueries({ + queryKey: ["tasks", project.id], + }); + } - if (labels?.length) { - setTaskLabels(labels.map((label: Label) => label.id)); + await queryClient.refetchQueries({ queryKey: ["labels", taskId] }); + await queryClient.refetchQueries({ + queryKey: ["labels", "workspace", workspace?.id], + }); + await queryClient.refetchQueries({ queryKey: ["task", taskId] }); + if (project?.id) { + await queryClient.refetchQueries({ queryKey: ["tasks", project.id] }); } + } catch (error) { + toast.error("Failed to update label"); } finally { setIsSaving(false); } }; - const handleCreateLabel = async () => { - if (!searchValue.trim()) return; + const handleCreateLabelWithColor = async (color: LabelColor) => { + if (!searchValue.trim() || !workspace?.id) return; setIsSaving(true); try { const newLabel = await createLabel({ name: searchValue.trim(), - color: selectedColor, - taskId, + color: color, + workspaceId: workspace.id, }); - setSearchValue(""); - setSelectedColor("gray"); + if (newLabel?.id) { + await assignLabel({ taskId, labelId: newLabel.id }); + } await queryClient.invalidateQueries({ queryKey: ["labels", taskId] }); + await queryClient.invalidateQueries({ + queryKey: ["labels", "workspace", workspace.id], + }); + await queryClient.invalidateQueries({ queryKey: ["task", taskId] }); + if (project?.id) { + await queryClient.invalidateQueries({ + queryKey: ["tasks", project.id], + }); + } - if (newLabel?.id) { - setTaskLabels((prev) => [...prev, newLabel.id]); + await queryClient.refetchQueries({ queryKey: ["labels", taskId] }); + await queryClient.refetchQueries({ + queryKey: ["labels", "workspace", workspace.id], + }); + await queryClient.refetchQueries({ queryKey: ["task", taskId] }); + if (project?.id) { + await queryClient.refetchQueries({ queryKey: ["tasks", project.id] }); + } + + toast.success("Label created and assigned"); + setSearchValue(""); + setColorPickerOpen(false); + setOpen(false); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Failed to create label"; + toast.error(errorMessage); + } finally { + setIsSaving(false); + } + }; + + const handleRemoveLabel = async (labelId: string, e: React.MouseEvent) => { + e.stopPropagation(); + setIsSaving(true); + try { + await unassignLabel({ taskId, labelId }); + + await queryClient.invalidateQueries({ queryKey: ["labels", taskId] }); + await queryClient.invalidateQueries({ queryKey: ["task", taskId] }); + if (project?.id) { + await queryClient.invalidateQueries({ + queryKey: ["tasks", project.id], + }); } - toast.success("Label created successfully"); + await queryClient.refetchQueries({ queryKey: ["labels", taskId] }); + await queryClient.refetchQueries({ queryKey: ["task", taskId] }); + if (project?.id) { + await queryClient.refetchQueries({ queryKey: ["tasks", project.id] }); + } - searchInputRef.current?.focus(); + toast.success("Label removed"); } catch (error) { - console.error("Failed to create label:", error); - toast.error("Failed to create label"); + toast.error("Failed to remove label"); } finally { setIsSaving(false); } }; - return ( - - Labels -
- {labels - .filter((label: Label) => taskLabels.includes(label.id)) - .map((label: Label) => ( - toggleLabel(label.id)} - tabIndex={0} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - toggleLabel(label.id); - } - }} - aria-label={`Remove label ${label.name}`} - > - c.value === label.color)?.color || - "#94a3b8", - }} - aria-hidden="true" - /> - {label.name} - - ))} - - - - - - - -
-
+ const getColorValue = (colorKey: string) => { + return labelColors.find((c) => c.value === colorKey)?.color || "#94a3b8"; + }; -
+ + +
+ {taskLabels.length > 0 && ( +
+ {taskLabels.map((label: TaskLabel) => ( + - {filteredLabels.length > 0 ? ( -
- {filteredLabels.map((label: Label) => ( + + {label.name} + + + ))} +
+ )} + + + + + + +
+ + setSearchValue(e.target.value)} + placeholder="Search or create label..." + className="border-0 h-8 text-sm focus-visible:ring-0 shadow-none" + /> +
+ +
+ {filteredLabels.length > 0 && ( +
+ {filteredLabels.map((label: TaskLabel) => { + const isAssigned = assignedLabelIds.has(label.id); + return ( - ))} -
- ) : null} - - {isCreatingNewLabel && ( -
- + + -
-
+
+ +
+ )} + + {!isCreatingNewLabel && + filteredLabels.length === 0 && + searchValue && ( +
+ No labels found
)} -
- - - +
+ +
- +
); } diff --git a/apps/web/src/fetchers/label/assign-label-to-task.ts b/apps/web/src/fetchers/label/assign-label-to-task.ts new file mode 100644 index 00000000..18013d6e --- /dev/null +++ b/apps/web/src/fetchers/label/assign-label-to-task.ts @@ -0,0 +1,28 @@ +import { client } from "@kaneo/libs"; +import type { InferRequestType } from "hono/client"; + +export type AssignLabelToTaskRequest = InferRequestType< + (typeof client)["label"]["assign"]["$post"] +>["json"]; + +async function assignLabelToTask({ + taskId, + labelId, +}: AssignLabelToTaskRequest) { + const response = await client.label.assign.$post({ + json: { + taskId, + labelId, + }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(error); + } + + const data = await response.json(); + return data; +} + +export default assignLabelToTask; diff --git a/apps/web/src/fetchers/label/create-label.ts b/apps/web/src/fetchers/label/create-label.ts index 41b1423b..0fa6e3f4 100644 --- a/apps/web/src/fetchers/label/create-label.ts +++ b/apps/web/src/fetchers/label/create-label.ts @@ -5,12 +5,12 @@ export type CreateLabelRequest = InferRequestType< (typeof client)["label"]["$post"] >["json"]; -async function createLabel({ name, color, taskId }: CreateLabelRequest) { +async function createLabel({ name, color, workspaceId }: CreateLabelRequest) { const response = await client.label.$post({ json: { name, color, - taskId, + workspaceId, }, }); diff --git a/apps/web/src/fetchers/label/get-labels-by-task.ts b/apps/web/src/fetchers/label/get-labels-by-task.ts index f96b76f2..986f6db1 100644 --- a/apps/web/src/fetchers/label/get-labels-by-task.ts +++ b/apps/web/src/fetchers/label/get-labels-by-task.ts @@ -2,11 +2,11 @@ import { client } from "@kaneo/libs"; import type { InferRequestType } from "hono/client"; export type GetLabelsByTaskRequest = InferRequestType< - (typeof client)["label"][":taskId"]["$get"] + (typeof client)["label"]["task"][":taskId"]["$get"] >["param"]; async function getLabelsByTask({ taskId }: GetLabelsByTaskRequest) { - const response = await client.label[":taskId"].$get({ + const response = await client.label.task[":taskId"].$get({ param: { taskId, }, diff --git a/apps/web/src/fetchers/label/get-labels-by-workspace.ts b/apps/web/src/fetchers/label/get-labels-by-workspace.ts new file mode 100644 index 00000000..0affa118 --- /dev/null +++ b/apps/web/src/fetchers/label/get-labels-by-workspace.ts @@ -0,0 +1,26 @@ +import { client } from "@kaneo/libs"; +import type { InferRequestType } from "hono/client"; + +export type GetLabelsByWorkspaceRequest = InferRequestType< + (typeof client)["label"]["workspace"][":workspaceId"]["$get"] +>["param"]; + +async function getLabelsByWorkspace({ + workspaceId, +}: GetLabelsByWorkspaceRequest) { + const response = await client.label.workspace[":workspaceId"].$get({ + param: { + workspaceId, + }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(error); + } + + const data = await response.json(); + return data; +} + +export default getLabelsByWorkspace; diff --git a/apps/web/src/fetchers/label/unassign-label-from-task.ts b/apps/web/src/fetchers/label/unassign-label-from-task.ts new file mode 100644 index 00000000..ae681621 --- /dev/null +++ b/apps/web/src/fetchers/label/unassign-label-from-task.ts @@ -0,0 +1,28 @@ +import { client } from "@kaneo/libs"; +import type { InferRequestType } from "hono/client"; + +export type UnassignLabelFromTaskRequest = InferRequestType< + (typeof client)["label"]["assign"]["$delete"] +>["json"]; + +async function unassignLabelFromTask({ + taskId, + labelId, +}: UnassignLabelFromTaskRequest) { + const response = await client.label.assign.$delete({ + json: { + taskId, + labelId, + }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(error); + } + + const data = await response.json(); + return data; +} + +export default unassignLabelFromTask; diff --git a/apps/web/src/hooks/mutations/label/use-assign-label-to-task.ts b/apps/web/src/hooks/mutations/label/use-assign-label-to-task.ts new file mode 100644 index 00000000..b5b820ee --- /dev/null +++ b/apps/web/src/hooks/mutations/label/use-assign-label-to-task.ts @@ -0,0 +1,10 @@ +import { useMutation } from "@tanstack/react-query"; +import assignLabelToTask from "../../../fetchers/label/assign-label-to-task"; + +function useAssignLabelToTask() { + return useMutation({ + mutationFn: assignLabelToTask, + }); +} + +export default useAssignLabelToTask; diff --git a/apps/web/src/hooks/mutations/label/use-unassign-label-from-task.ts b/apps/web/src/hooks/mutations/label/use-unassign-label-from-task.ts new file mode 100644 index 00000000..4eb9de98 --- /dev/null +++ b/apps/web/src/hooks/mutations/label/use-unassign-label-from-task.ts @@ -0,0 +1,10 @@ +import { useMutation } from "@tanstack/react-query"; +import unassignLabelFromTask from "../../../fetchers/label/unassign-label-from-task"; + +function useUnassignLabelFromTask() { + return useMutation({ + mutationFn: unassignLabelFromTask, + }); +} + +export default useUnassignLabelFromTask; diff --git a/apps/web/src/hooks/queries/label/use-get-labels-by-workspace.ts b/apps/web/src/hooks/queries/label/use-get-labels-by-workspace.ts new file mode 100644 index 00000000..d1237659 --- /dev/null +++ b/apps/web/src/hooks/queries/label/use-get-labels-by-workspace.ts @@ -0,0 +1,12 @@ +import { useQuery } from "@tanstack/react-query"; +import getLabelsByWorkspace from "../../../fetchers/label/get-labels-by-workspace"; + +function useGetLabelsByWorkspace(workspaceId: string) { + return useQuery({ + queryKey: ["labels", "workspace", workspaceId], + queryFn: () => getLabelsByWorkspace({ workspaceId }), + enabled: !!workspaceId, + }); +} + +export default useGetLabelsByWorkspace;