issue types (task/bug)

This commit is contained in:
2026-01-25 00:15:14 +00:00
parent f65ad0c593
commit d5a0829bad
18 changed files with 2129 additions and 44 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE "Organisation" ADD COLUMN "issueTypes" json DEFAULT '{"Task":{"icon":"checkBox","color":"#e4bd47"},"Bug":{"icon":"bug","color":"#ef4444"}}'::json NOT NULL;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "Organisation" ALTER COLUMN "features" SET DEFAULT '{"userAvatars":true,"issueTypes":true,"issueStatus":true,"issueDescriptions":true,"issueTimeTracking":true,"issueAssignees":true,"issueAssigneesShownInTable":true,"issueCreator":true,"issueComments":true,"sprints":true}'::json;--> statement-breakpoint
ALTER TABLE "Issue" ADD COLUMN "type" varchar(16) DEFAULT 'Task' NOT NULL;

View File

@@ -0,0 +1,923 @@
{
"id": "c0d457d4-fbb8-412b-bc31-6647297996ee",
"prevId": "a147434f-8cb1-41e1-aec4-e745a5030012",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.Issue": {
"name": "Issue",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "Issue_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"projectId": {
"name": "projectId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"number": {
"name": "number",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "varchar(2048)",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "varchar(24)",
"primaryKey": false,
"notNull": true,
"default": "'TO DO'"
},
"creatorId": {
"name": "creatorId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sprintId": {
"name": "sprintId",
"type": "integer",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"unique_project_issue_number": {
"name": "unique_project_issue_number",
"columns": [
{
"expression": "projectId",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "number",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"Issue_projectId_Project_id_fk": {
"name": "Issue_projectId_Project_id_fk",
"tableFrom": "Issue",
"tableTo": "Project",
"columnsFrom": [
"projectId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"Issue_creatorId_User_id_fk": {
"name": "Issue_creatorId_User_id_fk",
"tableFrom": "Issue",
"tableTo": "User",
"columnsFrom": [
"creatorId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"Issue_sprintId_Sprint_id_fk": {
"name": "Issue_sprintId_Sprint_id_fk",
"tableFrom": "Issue",
"tableTo": "Sprint",
"columnsFrom": [
"sprintId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.IssueAssignee": {
"name": "IssueAssignee",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "IssueAssignee_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"issueId": {
"name": "issueId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"userId": {
"name": "userId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"assignedAt": {
"name": "assignedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"unique_issue_user": {
"name": "unique_issue_user",
"columns": [
{
"expression": "issueId",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "userId",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"IssueAssignee_issueId_Issue_id_fk": {
"name": "IssueAssignee_issueId_Issue_id_fk",
"tableFrom": "IssueAssignee",
"tableTo": "Issue",
"columnsFrom": [
"issueId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"IssueAssignee_userId_User_id_fk": {
"name": "IssueAssignee_userId_User_id_fk",
"tableFrom": "IssueAssignee",
"tableTo": "User",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.IssueComment": {
"name": "IssueComment",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "IssueComment_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"issueId": {
"name": "issueId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"userId": {
"name": "userId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"body": {
"name": "body",
"type": "varchar(2048)",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"IssueComment_issueId_Issue_id_fk": {
"name": "IssueComment_issueId_Issue_id_fk",
"tableFrom": "IssueComment",
"tableTo": "Issue",
"columnsFrom": [
"issueId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"IssueComment_userId_User_id_fk": {
"name": "IssueComment_userId_User_id_fk",
"tableFrom": "IssueComment",
"tableTo": "User",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.Organisation": {
"name": "Organisation",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "Organisation_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"name": {
"name": "name",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "varchar(1024)",
"primaryKey": false,
"notNull": false
},
"slug": {
"name": "slug",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"iconURL": {
"name": "iconURL",
"type": "varchar(512)",
"primaryKey": false,
"notNull": false
},
"statuses": {
"name": "statuses",
"type": "json",
"primaryKey": false,
"notNull": true,
"default": "'{\"TO DO\":\"#fafafa\",\"IN PROGRESS\":\"#f97316\",\"REVIEW\":\"#8952bc\",\"DONE\":\"#22c55e\",\"REJECTED\":\"#ef4444\",\"ARCHIVED\":\"#a1a1a1\",\"MERGED\":\"#a1a1a1\"}'::json"
},
"issueTypes": {
"name": "issueTypes",
"type": "json",
"primaryKey": false,
"notNull": true,
"default": "'{\"Task\":{\"icon\":\"checkBox\",\"color\":\"#e4bd47\"},\"Bug\":{\"icon\":\"bug\",\"color\":\"#ef4444\"}}'::json"
},
"features": {
"name": "features",
"type": "json",
"primaryKey": false,
"notNull": true,
"default": "'{\"userAvatars\":true,\"issueStatus\":true,\"issueDescriptions\":true,\"issueTimeTracking\":true,\"issueAssignees\":true,\"issueAssigneesShownInTable\":true,\"issueCreator\":true,\"issueComments\":true,\"sprints\":true}'::json"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"Organisation_slug_unique": {
"name": "Organisation_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.OrganisationMember": {
"name": "OrganisationMember",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "OrganisationMember_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"organisationId": {
"name": "organisationId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"userId": {
"name": "userId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"OrganisationMember_organisationId_Organisation_id_fk": {
"name": "OrganisationMember_organisationId_Organisation_id_fk",
"tableFrom": "OrganisationMember",
"tableTo": "Organisation",
"columnsFrom": [
"organisationId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"OrganisationMember_userId_User_id_fk": {
"name": "OrganisationMember_userId_User_id_fk",
"tableFrom": "OrganisationMember",
"tableTo": "User",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.Project": {
"name": "Project",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "Project_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"key": {
"name": "key",
"type": "varchar(4)",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"organisationId": {
"name": "organisationId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"creatorId": {
"name": "creatorId",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"Project_organisationId_Organisation_id_fk": {
"name": "Project_organisationId_Organisation_id_fk",
"tableFrom": "Project",
"tableTo": "Organisation",
"columnsFrom": [
"organisationId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"Project_creatorId_User_id_fk": {
"name": "Project_creatorId_User_id_fk",
"tableFrom": "Project",
"tableTo": "User",
"columnsFrom": [
"creatorId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.Session": {
"name": "Session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "Session_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"userId": {
"name": "userId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"csrfToken": {
"name": "csrfToken",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"expiresAt": {
"name": "expiresAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"Session_userId_User_id_fk": {
"name": "Session_userId_User_id_fk",
"tableFrom": "Session",
"tableTo": "User",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.Sprint": {
"name": "Sprint",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "Sprint_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"projectId": {
"name": "projectId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"color": {
"name": "color",
"type": "varchar(7)",
"primaryKey": false,
"notNull": true,
"default": "'#a1a1a1'"
},
"startDate": {
"name": "startDate",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"endDate": {
"name": "endDate",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"Sprint_projectId_Project_id_fk": {
"name": "Sprint_projectId_Project_id_fk",
"tableFrom": "Sprint",
"tableTo": "Project",
"columnsFrom": [
"projectId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.TimedSession": {
"name": "TimedSession",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "TimedSession_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"userId": {
"name": "userId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"issueId": {
"name": "issueId",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"timestamps": {
"name": "timestamps",
"type": "timestamp[]",
"primaryKey": false,
"notNull": true
},
"endedAt": {
"name": "endedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"TimedSession_userId_User_id_fk": {
"name": "TimedSession_userId_User_id_fk",
"tableFrom": "TimedSession",
"tableTo": "User",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"TimedSession_issueId_Issue_id_fk": {
"name": "TimedSession_issueId_Issue_id_fk",
"tableFrom": "TimedSession",
"tableTo": "Issue",
"columnsFrom": [
"issueId"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.User": {
"name": "User",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "User_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"name": {
"name": "name",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"username": {
"name": "username",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true
},
"passwordHash": {
"name": "passwordHash",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"avatarURL": {
"name": "avatarURL",
"type": "varchar(512)",
"primaryKey": false,
"notNull": false
},
"iconPreference": {
"name": "iconPreference",
"type": "varchar(10)",
"primaryKey": false,
"notNull": true,
"default": "'lucide'"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"User_username_unique": {
"name": "User_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,930 @@
{
"id": "95c0fe99-9c32-4baf-a635-3be4c92b90de",
"prevId": "c0d457d4-fbb8-412b-bc31-6647297996ee",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.Issue": {
"name": "Issue",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "Issue_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"projectId": {
"name": "projectId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"number": {
"name": "number",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true,
"default": "'Task'"
},
"status": {
"name": "status",
"type": "varchar(24)",
"primaryKey": false,
"notNull": true,
"default": "'TO DO'"
},
"title": {
"name": "title",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "varchar(2048)",
"primaryKey": false,
"notNull": true
},
"creatorId": {
"name": "creatorId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sprintId": {
"name": "sprintId",
"type": "integer",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"unique_project_issue_number": {
"name": "unique_project_issue_number",
"columns": [
{
"expression": "projectId",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "number",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"Issue_projectId_Project_id_fk": {
"name": "Issue_projectId_Project_id_fk",
"tableFrom": "Issue",
"tableTo": "Project",
"columnsFrom": [
"projectId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"Issue_creatorId_User_id_fk": {
"name": "Issue_creatorId_User_id_fk",
"tableFrom": "Issue",
"tableTo": "User",
"columnsFrom": [
"creatorId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"Issue_sprintId_Sprint_id_fk": {
"name": "Issue_sprintId_Sprint_id_fk",
"tableFrom": "Issue",
"tableTo": "Sprint",
"columnsFrom": [
"sprintId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.IssueAssignee": {
"name": "IssueAssignee",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "IssueAssignee_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"issueId": {
"name": "issueId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"userId": {
"name": "userId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"assignedAt": {
"name": "assignedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"unique_issue_user": {
"name": "unique_issue_user",
"columns": [
{
"expression": "issueId",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "userId",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"IssueAssignee_issueId_Issue_id_fk": {
"name": "IssueAssignee_issueId_Issue_id_fk",
"tableFrom": "IssueAssignee",
"tableTo": "Issue",
"columnsFrom": [
"issueId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"IssueAssignee_userId_User_id_fk": {
"name": "IssueAssignee_userId_User_id_fk",
"tableFrom": "IssueAssignee",
"tableTo": "User",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.IssueComment": {
"name": "IssueComment",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "IssueComment_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"issueId": {
"name": "issueId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"userId": {
"name": "userId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"body": {
"name": "body",
"type": "varchar(2048)",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"IssueComment_issueId_Issue_id_fk": {
"name": "IssueComment_issueId_Issue_id_fk",
"tableFrom": "IssueComment",
"tableTo": "Issue",
"columnsFrom": [
"issueId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"IssueComment_userId_User_id_fk": {
"name": "IssueComment_userId_User_id_fk",
"tableFrom": "IssueComment",
"tableTo": "User",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.Organisation": {
"name": "Organisation",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "Organisation_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"name": {
"name": "name",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "varchar(1024)",
"primaryKey": false,
"notNull": false
},
"slug": {
"name": "slug",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"iconURL": {
"name": "iconURL",
"type": "varchar(512)",
"primaryKey": false,
"notNull": false
},
"statuses": {
"name": "statuses",
"type": "json",
"primaryKey": false,
"notNull": true,
"default": "'{\"TO DO\":\"#fafafa\",\"IN PROGRESS\":\"#f97316\",\"REVIEW\":\"#8952bc\",\"DONE\":\"#22c55e\",\"REJECTED\":\"#ef4444\",\"ARCHIVED\":\"#a1a1a1\",\"MERGED\":\"#a1a1a1\"}'::json"
},
"issueTypes": {
"name": "issueTypes",
"type": "json",
"primaryKey": false,
"notNull": true,
"default": "'{\"Task\":{\"icon\":\"checkBox\",\"color\":\"#e4bd47\"},\"Bug\":{\"icon\":\"bug\",\"color\":\"#ef4444\"}}'::json"
},
"features": {
"name": "features",
"type": "json",
"primaryKey": false,
"notNull": true,
"default": "'{\"userAvatars\":true,\"issueTypes\":true,\"issueStatus\":true,\"issueDescriptions\":true,\"issueTimeTracking\":true,\"issueAssignees\":true,\"issueAssigneesShownInTable\":true,\"issueCreator\":true,\"issueComments\":true,\"sprints\":true}'::json"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"Organisation_slug_unique": {
"name": "Organisation_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.OrganisationMember": {
"name": "OrganisationMember",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "OrganisationMember_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"organisationId": {
"name": "organisationId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"userId": {
"name": "userId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"OrganisationMember_organisationId_Organisation_id_fk": {
"name": "OrganisationMember_organisationId_Organisation_id_fk",
"tableFrom": "OrganisationMember",
"tableTo": "Organisation",
"columnsFrom": [
"organisationId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"OrganisationMember_userId_User_id_fk": {
"name": "OrganisationMember_userId_User_id_fk",
"tableFrom": "OrganisationMember",
"tableTo": "User",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.Project": {
"name": "Project",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "Project_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"key": {
"name": "key",
"type": "varchar(4)",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"organisationId": {
"name": "organisationId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"creatorId": {
"name": "creatorId",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"Project_organisationId_Organisation_id_fk": {
"name": "Project_organisationId_Organisation_id_fk",
"tableFrom": "Project",
"tableTo": "Organisation",
"columnsFrom": [
"organisationId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"Project_creatorId_User_id_fk": {
"name": "Project_creatorId_User_id_fk",
"tableFrom": "Project",
"tableTo": "User",
"columnsFrom": [
"creatorId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.Session": {
"name": "Session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "Session_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"userId": {
"name": "userId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"csrfToken": {
"name": "csrfToken",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"expiresAt": {
"name": "expiresAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"Session_userId_User_id_fk": {
"name": "Session_userId_User_id_fk",
"tableFrom": "Session",
"tableTo": "User",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.Sprint": {
"name": "Sprint",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "Sprint_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"projectId": {
"name": "projectId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"color": {
"name": "color",
"type": "varchar(7)",
"primaryKey": false,
"notNull": true,
"default": "'#a1a1a1'"
},
"startDate": {
"name": "startDate",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"endDate": {
"name": "endDate",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"Sprint_projectId_Project_id_fk": {
"name": "Sprint_projectId_Project_id_fk",
"tableFrom": "Sprint",
"tableTo": "Project",
"columnsFrom": [
"projectId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.TimedSession": {
"name": "TimedSession",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "TimedSession_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"userId": {
"name": "userId",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"issueId": {
"name": "issueId",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"timestamps": {
"name": "timestamps",
"type": "timestamp[]",
"primaryKey": false,
"notNull": true
},
"endedAt": {
"name": "endedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"TimedSession_userId_User_id_fk": {
"name": "TimedSession_userId_User_id_fk",
"tableFrom": "TimedSession",
"tableTo": "User",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"TimedSession_issueId_Issue_id_fk": {
"name": "TimedSession_issueId_Issue_id_fk",
"tableFrom": "TimedSession",
"tableTo": "Issue",
"columnsFrom": [
"issueId"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.User": {
"name": "User",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "User_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"name": {
"name": "name",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"username": {
"name": "username",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true
},
"passwordHash": {
"name": "passwordHash",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"avatarURL": {
"name": "avatarURL",
"type": "varchar(512)",
"primaryKey": false,
"notNull": false
},
"iconPreference": {
"name": "iconPreference",
"type": "varchar(10)",
"primaryKey": false,
"notNull": true,
"default": "'lucide'"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"User_username_unique": {
"name": "User_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -162,6 +162,20 @@
"when": 1769265175455, "when": 1769265175455,
"tag": "0022_unique_johnny_blaze", "tag": "0022_unique_johnny_blaze",
"breakpoints": true "breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1769294969462,
"tag": "0023_loving_raza",
"breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1769295524021,
"tag": "0024_military_stryfe",
"breakpoints": true
} }
] ]
} }

View File

@@ -62,6 +62,7 @@ export async function updateIssue(
title?: string; title?: string;
description?: string; description?: string;
sprintId?: number | null; sprintId?: number | null;
type?: string;
status?: string; status?: string;
}, },
) { ) {

View File

@@ -13,12 +13,13 @@ export default async function issueUpdate(req: AuthedRequest) {
const parsed = await parseJsonBody(req, IssueUpdateRequestSchema); const parsed = await parseJsonBody(req, IssueUpdateRequestSchema);
if ("error" in parsed) return parsed.error; if ("error" in parsed) return parsed.error;
const { id, title, description, status, assigneeIds, sprintId } = parsed.data; const { id, title, description, type, status, assigneeIds, sprintId } = parsed.data;
// check that at least one field is being updated // check that at least one field is being updated
if ( if (
title === undefined && title === undefined &&
description === undefined && description === undefined &&
type === undefined &&
status === undefined && status === undefined &&
assigneeIds === undefined && assigneeIds === undefined &&
sprintId === undefined sprintId === undefined
@@ -27,7 +28,11 @@ export default async function issueUpdate(req: AuthedRequest) {
} }
const hasIssueFieldUpdates = const hasIssueFieldUpdates =
title !== undefined || description !== undefined || status !== undefined || sprintId !== undefined; title !== undefined ||
description !== undefined ||
type !== undefined ||
status !== undefined ||
sprintId !== undefined;
const existingIssue = await getIssueByID(id); const existingIssue = await getIssueByID(id);
if (!existingIssue) { if (!existingIssue) {
@@ -53,6 +58,7 @@ export default async function issueUpdate(req: AuthedRequest) {
title, title,
description, description,
sprintId, sprintId,
type,
status, status,
}); });
} }

View File

@@ -122,7 +122,7 @@ export function IssueComments({ issueId, className }: { issueId: number; classNa
disabled={deletingId === comment.Comment.id} disabled={deletingId === comment.Comment.id}
title="Delete comment" title="Delete comment"
> >
<Icon icon="trash" className="size-4" /> <Icon icon="trash" color="var(--destructive)" />
</IconButton> </IconButton>
) : null} ) : null}
</div> </div>

View File

@@ -11,9 +11,10 @@ import { StatusSelect } from "@/components/status-select";
import StatusTag from "@/components/status-tag"; import StatusTag from "@/components/status-tag";
import { TimerDisplay } from "@/components/timer-display"; import { TimerDisplay } from "@/components/timer-display";
import { TimerModal } from "@/components/timer-modal"; import { TimerModal } from "@/components/timer-modal";
import { TypeSelect } from "@/components/type-select";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import Icon from "@/components/ui/icon"; import Icon, { type IconName } from "@/components/ui/icon";
import { IconButton } from "@/components/ui/icon-button"; import { IconButton } from "@/components/ui/icon-button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { SelectTrigger } from "@/components/ui/select"; import { SelectTrigger } from "@/components/ui/select";
@@ -58,6 +59,7 @@ export function IssueDetails({
const [assigneeIds, setAssigneeIds] = useState<string[]>([]); const [assigneeIds, setAssigneeIds] = useState<string[]>([]);
const [sprintId, setSprintId] = useState<string>("unassigned"); const [sprintId, setSprintId] = useState<string>("unassigned");
const [status, setStatus] = useState<string>(""); const [status, setStatus] = useState<string>("");
const [type, setType] = useState<string>("");
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [linkCopied, setLinkCopied] = useState(false); const [linkCopied, setLinkCopied] = useState(false);
const copyTimeoutRef = useRef<number | null>(null); const copyTimeoutRef = useRef<number | null>(null);
@@ -72,6 +74,11 @@ export function IssueDetails({
const [isSavingDescription, setIsSavingDescription] = useState(false); const [isSavingDescription, setIsSavingDescription] = useState(false);
const descriptionRef = useRef<HTMLTextAreaElement>(null); const descriptionRef = useRef<HTMLTextAreaElement>(null);
const issueTypes = (organisation?.Organisation.issueTypes ?? {}) as Record<
string,
{ icon: string; color: string }
>;
const isAssignee = assigneeIds.some((id) => user?.id === Number(id)); const isAssignee = assigneeIds.some((id) => user?.id === Number(id));
const actualAssigneeIds = assigneeIds.filter((id) => id !== "unassigned"); const actualAssigneeIds = assigneeIds.filter((id) => id !== "unassigned");
const hasMultipleAssignees = actualAssigneeIds.length > 1; const hasMultipleAssignees = actualAssigneeIds.length > 1;
@@ -80,6 +87,7 @@ export function IssueDetails({
setSprintId(issueData.Issue.sprintId?.toString() ?? "unassigned"); setSprintId(issueData.Issue.sprintId?.toString() ?? "unassigned");
setAssigneeIds(assigneesToStringArray(issueData.Assignees)); setAssigneeIds(assigneesToStringArray(issueData.Assignees));
setStatus(issueData.Issue.status); setStatus(issueData.Issue.status);
setType(issueData.Issue.type);
setTitle(issueData.Issue.title); setTitle(issueData.Issue.title);
setOriginalTitle(issueData.Issue.title); setOriginalTitle(issueData.Issue.title);
setDescription(issueData.Issue.description); setDescription(issueData.Issue.description);
@@ -206,6 +214,38 @@ export function IssueDetails({
} }
}; };
const handleTypeChange = async (value: string) => {
setType(value);
const typeConfig = issueTypes[value];
try {
await updateIssue.mutateAsync({
id: issueData.Issue.id,
type: value,
});
toast.success(
<span className="inline-flex items-center gap-1.5">
{issueID(projectKey, issueData.Issue.number)}'s type updated to{" "}
{typeConfig ? (
<span className="inline-flex items-center gap-1.5">
<Icon icon={typeConfig.icon as IconName} size={16} color={typeConfig.color} />
{value}
</span>
) : (
value
)}
</span>,
{ dismissible: false },
);
} catch (error) {
console.error("error updating type:", error);
setType(issueData.Issue.type);
toast.error(`Error updating type: ${parseError(error as Error)}`, {
dismissible: false,
});
}
};
const handleDelete = () => { const handleDelete = () => {
setDeleteOpen(true); setDeleteOpen(true);
}; };
@@ -310,7 +350,7 @@ export function IssueDetails({
{linkCopied ? <Icon icon="check" /> : <Icon icon="link" />} {linkCopied ? <Icon icon="check" /> : <Icon icon="link" />}
</IconButton> </IconButton>
<IconButton variant="destructive" onClick={handleDelete} title={"Delete issue"}> <IconButton variant="destructive" onClick={handleDelete} title={"Delete issue"}>
<Icon icon="trash" /> <Icon icon="trash" color="var(--destructive)" />
</IconButton> </IconButton>
<IconButton onClick={onClose} title={"Close"}> <IconButton onClick={onClose} title={"Close"}>
<Icon icon="x" /> <Icon icon="x" />
@@ -321,6 +361,30 @@ export function IssueDetails({
<div className="flex flex-col w-full p-2 py-2 gap-2 max-h-[75vh] overflow-y-scroll"> <div className="flex flex-col w-full p-2 py-2 gap-2 max-h-[75vh] overflow-y-scroll">
<div className="flex gap-2"> <div className="flex gap-2">
{organisation?.Organisation.features.issueTypes && Object.keys(issueTypes).length > 0 && (
<TypeSelect
issueTypes={issueTypes}
value={type}
onChange={handleTypeChange}
trigger={({ isOpen, value }) => {
const typeConfig = issueTypes[value];
return (
<SelectTrigger
className="group w-auto flex items-center"
variant="unstyled"
chevronClassName="hidden"
isOpen={isOpen}
>
{typeConfig ? (
<Icon icon={typeConfig.icon as IconName} size={18} color={typeConfig.color} />
) : (
<span className="text-xs text-muted-foreground">Type</span>
)}
</SelectTrigger>
);
}}
/>
)}
{organisation?.Organisation.features.issueStatus && ( {organisation?.Organisation.features.issueStatus && (
<StatusSelect <StatusSelect
statuses={statuses} statuses={statuses}

View File

@@ -7,6 +7,7 @@ import { useAuthenticatedSession } from "@/components/session-provider";
import { SprintSelect } from "@/components/sprint-select"; import { SprintSelect } from "@/components/sprint-select";
import { StatusSelect } from "@/components/status-select"; import { StatusSelect } from "@/components/status-select";
import StatusTag from "@/components/status-tag"; import StatusTag from "@/components/status-tag";
import { TypeSelect } from "@/components/type-select";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -17,6 +18,7 @@ import {
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Field } from "@/components/ui/field"; import { Field } from "@/components/ui/field";
import Icon, { type IconName } from "@/components/ui/icon";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { SelectTrigger } from "@/components/ui/select"; import { SelectTrigger } from "@/components/ui/select";
import { import {
@@ -39,8 +41,14 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
const members = useMemo(() => membersData.map((member) => member.User), [membersData]); const members = useMemo(() => membersData.map((member) => member.User), [membersData]);
const statuses = selectedOrganisation?.Organisation.statuses ?? {}; const statuses = selectedOrganisation?.Organisation.statuses ?? {};
const issueTypes = (selectedOrganisation?.Organisation.issueTypes ?? {}) as Record<
string,
{ icon: string; color: string }
>;
const statusOptions = useMemo(() => Object.keys(statuses), [statuses]); const statusOptions = useMemo(() => Object.keys(statuses), [statuses]);
const typeOptions = useMemo(() => Object.keys(issueTypes), [issueTypes]);
const defaultStatus = statusOptions[0] ?? ""; const defaultStatus = statusOptions[0] ?? "";
const defaultType = typeOptions[0] ?? "";
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
@@ -48,6 +56,7 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
const [sprintId, setSprintId] = useState<string>("unassigned"); const [sprintId, setSprintId] = useState<string>("unassigned");
const [assigneeIds, setAssigneeIds] = useState<string[]>(["unassigned"]); const [assigneeIds, setAssigneeIds] = useState<string[]>(["unassigned"]);
const [status, setStatus] = useState<string>(defaultStatus); const [status, setStatus] = useState<string>(defaultStatus);
const [type, setType] = useState<string>(defaultType);
const [submitAttempted, setSubmitAttempted] = useState(false); const [submitAttempted, setSubmitAttempted] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -58,6 +67,7 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
setSprintId("unassigned"); setSprintId("unassigned");
setAssigneeIds(["unassigned"]); setAssigneeIds(["unassigned"]);
setStatus(defaultStatus); setStatus(defaultStatus);
setType(defaultType);
setSubmitAttempted(false); setSubmitAttempted(false);
setSubmitting(false); setSubmitting(false);
setError(null); setError(null);
@@ -103,6 +113,7 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
sprintId: sprintId === "unassigned" ? null : Number(sprintId), sprintId: sprintId === "unassigned" ? null : Number(sprintId),
assigneeIds: assigneeIds.filter((id) => id !== "unassigned").map((id) => Number(id)), assigneeIds: assigneeIds.filter((id) => id !== "unassigned").map((id) => Number(id)),
status: status.trim() === "" ? undefined : status, status: status.trim() === "" ? undefined : status,
type: type.trim() === "" ? undefined : type,
}); });
setOpen(false); setOpen(false);
reset(); reset();
@@ -136,27 +147,61 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="grid"> <div className="grid">
{statusOptions.length > 0 && ( {(typeOptions.length > 0 || statusOptions.length > 0) && (
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-3 mb-4 flex-wrap">
<Label>Status</Label> {selectedOrganisation?.Organisation.features.issueTypes && typeOptions.length > 0 && (
<StatusSelect <div className="flex items-center gap-2">
statuses={statuses} <Label>Type</Label>
value={status} <TypeSelect
onChange={(newValue) => { issueTypes={issueTypes}
if (newValue.trim() === "") return; value={type}
setStatus(newValue); onChange={(newValue) => {
}} if (newValue.trim() === "") return;
trigger={({ isOpen, value }) => ( setType(newValue);
<SelectTrigger }}
className="group flex items-center w-min" trigger={({ isOpen, value }) => {
variant="unstyled" const typeConfig = issueTypes[value];
chevronClassName="hidden" return (
isOpen={isOpen} <SelectTrigger
> className="group flex items-center w-min"
<StatusTag status={value} colour={statuses[value]} className="hover:opacity-85" /> variant="unstyled"
</SelectTrigger> chevronClassName="hidden"
)} isOpen={isOpen}
/> >
{typeConfig ? (
<Icon icon={typeConfig.icon as IconName} size={20} color={typeConfig.color} />
) : (
<span className="text-xs text-muted-foreground">Type</span>
)}
</SelectTrigger>
);
}}
/>
</div>
)}
{statusOptions.length > 0 && (
<div className="flex items-center gap-2">
<Label>Status</Label>
<StatusSelect
statuses={statuses}
value={status}
onChange={(newValue) => {
if (newValue.trim() === "") return;
setStatus(newValue);
}}
trigger={({ isOpen, value }) => (
<SelectTrigger
className="group flex items-center w-min"
variant="unstyled"
chevronClassName="hidden"
isOpen={isOpen}
>
<StatusTag status={value} colour={statuses[value]} className="hover:opacity-85" />
</SelectTrigger>
)}
/>
</div>
)}
</div> </div>
)} )}

View File

@@ -2,6 +2,7 @@ import { useMemo } from "react";
import Avatar from "@/components/avatar"; import Avatar from "@/components/avatar";
import { useSelection } from "@/components/selection-provider"; import { useSelection } from "@/components/selection-provider";
import StatusTag from "@/components/status-tag"; import StatusTag from "@/components/status-tag";
import Icon, { type IconName } from "@/components/ui/icon";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useIssues, useSelectedOrganisation, useSelectedProject } from "@/lib/query/hooks"; import { useIssues, useSelectedOrganisation, useSelectedProject } from "@/lib/query/hooks";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -18,6 +19,10 @@ export function IssuesTable({
const selectedOrganisation = useSelectedOrganisation(); const selectedOrganisation = useSelectedOrganisation();
const selectedProject = useSelectedProject(); const selectedProject = useSelectedProject();
const statuses = selectedOrganisation?.Organisation.statuses ?? {}; const statuses = selectedOrganisation?.Organisation.statuses ?? {};
const issueTypes = (selectedOrganisation?.Organisation.issueTypes ?? {}) as Record<
string,
{ icon: string; color: string }
>;
const issues = useMemo(() => [...issuesData].reverse(), [issuesData]); const issues = useMemo(() => [...issuesData].reverse(), [issuesData]);
@@ -90,6 +95,14 @@ export function IssuesTable({
onClick={handleLinkClick} onClick={handleLinkClick}
className="flex items-center gap-2 min-w-0 w-full h-full px-2 py-1 text-inherit hover:underline decoration-transparent" className="flex items-center gap-2 min-w-0 w-full h-full px-2 py-1 text-inherit hover:underline decoration-transparent"
> >
{selectedOrganisation?.Organisation.features.issueTypes &&
issueTypes[issueData.Issue.type] && (
<Icon
icon={issueTypes[issueData.Issue.type].icon as IconName}
size={16}
color={issueTypes[issueData.Issue.type].color}
/>
)}
{selectedOrganisation?.Organisation.features.issueStatus && {selectedOrganisation?.Organisation.features.issueStatus &&
(columns.status == null || columns.status === true) && ( (columns.status == null || columns.status === true) && (
<StatusTag status={issueData.Issue.status} colour={statuses[issueData.Issue.status]} /> <StatusTag status={issueData.Issue.status} colour={statuses[issueData.Issue.status]} />

View File

@@ -0,0 +1,59 @@
import type { ReactNode } from "react";
import { useState } from "react";
import Icon, { type IconName } from "@/components/ui/icon";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
type IssueTypeConfig = {
icon: string;
color: string;
};
export function TypeSelect({
issueTypes,
value,
onChange,
placeholder = "Select type",
trigger,
}: {
issueTypes: Record<string, IssueTypeConfig>;
value: string;
onChange: (value: string) => void;
placeholder?: string;
trigger?: (args: { isOpen: boolean; value: string }) => ReactNode;
}) {
const [isOpen, setIsOpen] = useState(false);
const selectedType = issueTypes[value];
return (
<Select value={value} onValueChange={onChange} onOpenChange={setIsOpen}>
{trigger ? (
trigger({ isOpen, value })
) : (
<SelectTrigger
className="w-fit px-2 text-sm gap-1"
size="sm"
chevronClassName="size-3 -mr-1"
isOpen={isOpen}
>
{selectedType ? (
<span className="flex items-center gap-1.5">
<Icon icon={selectedType.icon as IconName} size={20} color={selectedType.color} />
</span>
) : (
<SelectValue placeholder={placeholder} />
)}
</SelectTrigger>
)}
<SelectContent side="bottom" position="popper" align="start">
{Object.entries(issueTypes).map(([typeName, typeConfig]) => (
<SelectItem key={typeName} value={typeName} textClassName="text-sm">
<span className="flex items-center gap-2">
<Icon icon={typeConfig.icon as IconName} size={20} color={typeConfig.color} />
<span>{typeName}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
}

View File

@@ -1,6 +1,7 @@
import { import {
Alert as PixelAlert, Alert as PixelAlert,
Check as PixelCheck, Check as PixelCheck,
Checkbox as PixelCheckbox,
ChevronDown as PixelChevronDown, ChevronDown as PixelChevronDown,
ChevronLeft as PixelChevronLeft, ChevronLeft as PixelChevronLeft,
ChevronRight as PixelChevronRight, ChevronRight as PixelChevronRight,
@@ -8,6 +9,7 @@ import {
Circle as PixelCircle, Circle as PixelCircle,
Clock as PixelClock, Clock as PixelClock,
Close as PixelClose, Close as PixelClose,
Debug as PixelDebug,
Edit as PixelEdit, Edit as PixelEdit,
Home as PixelHome, Home as PixelHome,
InfoBox as PixelInfo, InfoBox as PixelInfo,
@@ -26,8 +28,10 @@ import {
ViewportWide as PixelViewportWide, ViewportWide as PixelViewportWide,
} from "@nsmr/pixelart-react"; } from "@nsmr/pixelart-react";
import { import {
BugIcon as PhosphorBug,
CheckIcon as PhosphorCheck, CheckIcon as PhosphorCheck,
CheckCircleIcon as PhosphorCheckCircle, CheckCircleIcon as PhosphorCheckCircle,
CheckSquareIcon as PhosphorCheckSquare,
CaretDownIcon as PhosphorChevronDown, CaretDownIcon as PhosphorChevronDown,
CaretLeftIcon as PhosphorChevronLeft, CaretLeftIcon as PhosphorChevronLeft,
CaretRightIcon as PhosphorChevronRight, CaretRightIcon as PhosphorChevronRight,
@@ -59,6 +63,7 @@ import {
import type { IconStyle } from "@sprint/shared"; import type { IconStyle } from "@sprint/shared";
import { import {
AlertTriangle, AlertTriangle,
Bug,
Check, Check,
CheckIcon, CheckIcon,
ChevronDown, ChevronDown,
@@ -84,6 +89,7 @@ import {
OctagonXIcon, OctagonXIcon,
Plus, Plus,
ServerIcon, ServerIcon,
SquareCheck,
Sun, Sun,
Timer, Timer,
Trash, Trash,
@@ -95,10 +101,14 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useSessionSafe } from "@/components/session-provider"; import { useSessionSafe } from "@/components/session-provider";
// lucide: https://lucide.dev/icons
// pixel: https://pixelarticons.com/ (CLICK "Legacy") - these ones are free
const icons = { const icons = {
alertTriangle: { lucide: AlertTriangle, pixel: PixelAlert, phosphor: PhosphorWarning }, alertTriangle: { lucide: AlertTriangle, pixel: PixelAlert, phosphor: PhosphorWarning },
bug: { lucide: Bug, pixel: PixelDebug, phosphor: PhosphorBug },
check: { lucide: Check, pixel: PixelCheck, phosphor: PhosphorCheck }, check: { lucide: Check, pixel: PixelCheck, phosphor: PhosphorCheck },
checkIcon: { lucide: CheckIcon, pixel: PixelCheck, phosphor: PhosphorCheck }, checkIcon: { lucide: CheckIcon, pixel: PixelCheck, phosphor: PhosphorCheck },
checkBox: { lucide: SquareCheck, pixel: PixelCheckbox, phosphor: PhosphorCheckSquare },
chevronDown: { lucide: ChevronDown, pixel: PixelChevronDown, phosphor: PhosphorChevronDown }, chevronDown: { lucide: ChevronDown, pixel: PixelChevronDown, phosphor: PhosphorChevronDown },
chevronDownIcon: { lucide: ChevronDownIcon, pixel: PixelChevronDown, phosphor: PhosphorChevronDown }, chevronDownIcon: { lucide: ChevronDownIcon, pixel: PixelChevronDown, phosphor: PhosphorChevronDown },
chevronLeftIcon: { lucide: ChevronLeftIcon, pixel: PixelChevronLeft, phosphor: PhosphorChevronLeft }, chevronLeftIcon: { lucide: ChevronLeftIcon, pixel: PixelChevronLeft, phosphor: PhosphorChevronLeft },
@@ -149,6 +159,7 @@ export default function Icon({
icon, icon,
iconStyle, iconStyle,
size = 24, size = 24,
color,
...props ...props
}: { }: {
icon: IconName; icon: IconName;
@@ -174,12 +185,14 @@ export default function Icon({
<IconComponent <IconComponent
size={size} size={size}
fill={ fill={
(resolvedStyle === "pixel" && icon === "moon") || color
(resolvedStyle === "pixel" && icon === "hash") || ? color
resolvedStyle === "phosphor" : (resolvedStyle === "pixel" && ["bug", "moon", "hash"].includes(icon)) ||
? "var(--foreground)" resolvedStyle === "phosphor"
: "transparent" ? "var(--foreground)"
: "transparent"
} }
style={{ color: color ? color : "var(--foreground)" }}
{...props} {...props}
/> />
); );

View File

@@ -1,7 +1,7 @@
import "./App.css"; import "./App.css";
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; import { BrowserRouter, Route, Routes } from "react-router-dom";
import { QueryProvider } from "@/components/query-provider"; import { QueryProvider } from "@/components/query-provider";
import { SelectionProvider } from "@/components/selection-provider"; import { SelectionProvider } from "@/components/selection-provider";
import { RequireAuth, SessionProvider } from "@/components/session-provider"; import { RequireAuth, SessionProvider } from "@/components/session-provider";

View File

@@ -4,6 +4,7 @@ import {
ISSUE_DESCRIPTION_MAX_LENGTH, ISSUE_DESCRIPTION_MAX_LENGTH,
ISSUE_STATUS_MAX_LENGTH, ISSUE_STATUS_MAX_LENGTH,
ISSUE_TITLE_MAX_LENGTH, ISSUE_TITLE_MAX_LENGTH,
ISSUE_TYPE_MAX_LENGTH,
ORG_DESCRIPTION_MAX_LENGTH, ORG_DESCRIPTION_MAX_LENGTH,
ORG_NAME_MAX_LENGTH, ORG_NAME_MAX_LENGTH,
ORG_SLUG_MAX_LENGTH, ORG_SLUG_MAX_LENGTH,
@@ -66,9 +67,10 @@ export type AuthResponse = z.infer<typeof AuthResponseSchema>;
export const IssueCreateRequestSchema = z.object({ export const IssueCreateRequestSchema = z.object({
projectId: z.number().int().positive("projectId must be a positive integer"), projectId: z.number().int().positive("projectId must be a positive integer"),
type: z.string().max(ISSUE_TYPE_MAX_LENGTH).optional(),
status: z.string().max(ISSUE_STATUS_MAX_LENGTH).optional(),
title: z.string().min(1, "Title is required").max(ISSUE_TITLE_MAX_LENGTH), title: z.string().min(1, "Title is required").max(ISSUE_TITLE_MAX_LENGTH),
description: z.string().max(ISSUE_DESCRIPTION_MAX_LENGTH).default(""), description: z.string().max(ISSUE_DESCRIPTION_MAX_LENGTH).default(""),
status: z.string().max(ISSUE_STATUS_MAX_LENGTH).optional(),
assigneeIds: z.array(z.number().int().positive()).optional(), assigneeIds: z.array(z.number().int().positive()).optional(),
sprintId: z.number().int().positive().nullable().optional(), sprintId: z.number().int().positive().nullable().optional(),
}); });
@@ -77,9 +79,10 @@ export type IssueCreateRequest = z.infer<typeof IssueCreateRequestSchema>;
export const IssueUpdateRequestSchema = z.object({ export const IssueUpdateRequestSchema = z.object({
id: z.number().int().positive("id must be a positive integer"), id: z.number().int().positive("id must be a positive integer"),
type: z.string().max(ISSUE_TYPE_MAX_LENGTH).optional(),
status: z.string().max(ISSUE_STATUS_MAX_LENGTH).optional(),
title: z.string().min(1, "Title must be at least 1 character").max(ISSUE_TITLE_MAX_LENGTH).optional(), title: z.string().min(1, "Title must be at least 1 character").max(ISSUE_TITLE_MAX_LENGTH).optional(),
description: z.string().max(ISSUE_DESCRIPTION_MAX_LENGTH).optional(), description: z.string().max(ISSUE_DESCRIPTION_MAX_LENGTH).optional(),
status: z.string().max(ISSUE_STATUS_MAX_LENGTH).optional(),
assigneeIds: z.array(z.number().int().positive()).nullable().optional(), assigneeIds: z.array(z.number().int().positive()).nullable().optional(),
sprintId: z.number().int().positive().nullable().optional(), sprintId: z.number().int().positive().nullable().optional(),
}); });
@@ -388,9 +391,10 @@ export const IssueRecordSchema = z.object({
id: z.number(), id: z.number(),
projectId: z.number(), projectId: z.number(),
number: z.number(), number: z.number(),
type: z.string(),
status: z.string(),
title: z.string(), title: z.string(),
description: z.string(), description: z.string(),
status: z.string(),
creatorId: z.number(), creatorId: z.number(),
sprintId: z.number().nullable(), sprintId: z.number().nullable(),
}); });

View File

@@ -11,5 +11,6 @@ export const PROJECT_SLUG_MAX_LENGTH = 64;
export const ISSUE_TITLE_MAX_LENGTH = 64; export const ISSUE_TITLE_MAX_LENGTH = 64;
export const ISSUE_DESCRIPTION_MAX_LENGTH = 2048; export const ISSUE_DESCRIPTION_MAX_LENGTH = 2048;
export const ISSUE_TYPE_MAX_LENGTH = 16;
export const ISSUE_STATUS_MAX_LENGTH = 24; export const ISSUE_STATUS_MAX_LENGTH = 24;
export const ISSUE_COMMENT_MAX_LENGTH = 2048; export const ISSUE_COMMENT_MAX_LENGTH = 2048;

View File

@@ -6,6 +6,7 @@ import {
ISSUE_DESCRIPTION_MAX_LENGTH, ISSUE_DESCRIPTION_MAX_LENGTH,
ISSUE_STATUS_MAX_LENGTH, ISSUE_STATUS_MAX_LENGTH,
ISSUE_TITLE_MAX_LENGTH, ISSUE_TITLE_MAX_LENGTH,
ISSUE_TYPE_MAX_LENGTH,
ORG_DESCRIPTION_MAX_LENGTH, ORG_DESCRIPTION_MAX_LENGTH,
ORG_NAME_MAX_LENGTH, ORG_NAME_MAX_LENGTH,
ORG_SLUG_MAX_LENGTH, ORG_SLUG_MAX_LENGTH,
@@ -28,9 +29,15 @@ export const DEFAULT_STATUS_COLOURS: Record<string, string> = {
MERGED: DEFAULT_STATUS_COLOUR, MERGED: DEFAULT_STATUS_COLOUR,
}; };
export const DEFAULT_ISSUE_TYPES: Record<string, { icon: string; color: string }> = {
Task: { icon: "checkBox", color: "#e4bd47" },
Bug: { icon: "bug", color: "#ef4444" },
};
export const DEFAULT_FEATURES: Record<string, boolean> = { export const DEFAULT_FEATURES: Record<string, boolean> = {
userAvatars: true, userAvatars: true,
issueTypes: true,
issueStatus: true, issueStatus: true,
issueDescriptions: true, issueDescriptions: true,
issueTimeTracking: true, issueTimeTracking: true,
@@ -63,6 +70,10 @@ export const Organisation = pgTable("Organisation", {
slug: varchar({ length: ORG_SLUG_MAX_LENGTH }).notNull().unique(), slug: varchar({ length: ORG_SLUG_MAX_LENGTH }).notNull().unique(),
iconURL: varchar({ length: 512 }), iconURL: varchar({ length: 512 }),
statuses: json("statuses").$type<Record<string, string>>().notNull().default(DEFAULT_STATUS_COLOURS), statuses: json("statuses").$type<Record<string, string>>().notNull().default(DEFAULT_STATUS_COLOURS),
issueTypes: json("issueTypes")
.$type<Record<string, { icon: string; color: string }>>()
.notNull()
.default(DEFAULT_ISSUE_TYPES),
features: json("features").$type<Record<string, boolean>>().notNull().default(DEFAULT_FEATURES), features: json("features").$type<Record<string, boolean>>().notNull().default(DEFAULT_FEATURES),
createdAt: timestamp({ withTimezone: false }).defaultNow(), createdAt: timestamp({ withTimezone: false }).defaultNow(),
updatedAt: timestamp({ withTimezone: false }).defaultNow(), updatedAt: timestamp({ withTimezone: false }).defaultNow(),
@@ -135,9 +146,10 @@ export const Issue = pgTable(
number: integer("number").notNull(), number: integer("number").notNull(),
type: varchar({ length: ISSUE_TYPE_MAX_LENGTH }).notNull().default("Task"),
status: varchar({ length: ISSUE_STATUS_MAX_LENGTH }).notNull().default("TO DO"),
title: varchar({ length: ISSUE_TITLE_MAX_LENGTH }).notNull(), title: varchar({ length: ISSUE_TITLE_MAX_LENGTH }).notNull(),
description: varchar({ length: ISSUE_DESCRIPTION_MAX_LENGTH }).notNull(), description: varchar({ length: ISSUE_DESCRIPTION_MAX_LENGTH }).notNull(),
status: varchar({ length: ISSUE_STATUS_MAX_LENGTH }).notNull().default("TO DO"),
creatorId: integer() creatorId: integer()
.notNull() .notNull()

13
todo.md
View File

@@ -1,28 +1,25 @@
# HIGH PRIORITY # HIGH PRIORITY
- FEATURES: - FEATURES:
- issues - org settings:
- issue type (options stored on Organisation) - manage issue types
- create, edit, delete
- assign icons to issue types (ensure each available icon is in EACH icon set)
- filters - filters
- time tracking: - time tracking:
- add overlay in the bottom left for active timers if there are any. this should be minimal with the issue key (API-005), the time, and a play/pause + end button - add overlay in the bottom left for active timers if there are any. this should be minimal with the issue key (API-005), the time, and a play/pause + end button
- pricing page - pricing page
- see jira and other competitors - see jira and other competitors
- explore payment providers (stripe is the only one i know) - explore payment providers (stripe is the only one i know)
- add "modal=true" in issue urls that are copied, and open issue-modal.tsx instead of the issue-detail-pane.tsx
# LOW PRIORITY # LOW PRIORITY
- dedicated /register route (currently login/register are combined on /login) - dedicated /register route (currently login/register are combined on /login)
- real logo - real logo
- org settings
- manage issue types, default is [bug, feature]
- create, edit, delete
- assign icons to issue types (ensure each available icon is in EACH icon set)
- issues - issues
- assignee "note" for extra context on their role in the task - assignee "note" for extra context on their role in the task
- deadline - deadline
- comments
- admins are capable of deleting comments from members who are at their permission level or below (not sure if this should apply, or if ANYONE should have control over others' comments - people in an org tend to be trusted to not be trolls)
- user preferences - user preferences
- colour scheme - colour scheme
- "assign to me by default" option for new issues - "assign to me by default" option for new issues