From 51b446b6809e6249594a48deed1fff8b15b48218 Mon Sep 17 00:00:00 2001 From: Jack Andrews Date: Fri, 8 Nov 2024 20:54:06 +0000 Subject: [PATCH 01/11] chore: add extra events (#397) --- apps/api/src/controllers/auth.ts | 24 +++++-- apps/api/src/controllers/config.ts | 18 ++++- apps/api/src/controllers/data.ts | 2 +- apps/api/src/controllers/notebook.ts | 15 ++++ apps/api/src/controllers/queue.ts | 72 ++++++++++++++----- apps/api/src/controllers/todos.ts | 103 --------------------------- apps/api/src/controllers/users.ts | 11 +++ apps/api/src/controllers/webhooks.ts | 11 +++ 8 files changed, 128 insertions(+), 128 deletions(-) delete mode 100644 apps/api/src/controllers/todos.ts diff --git a/apps/api/src/controllers/auth.ts b/apps/api/src/controllers/auth.ts index 02fc29cc2..dffec2a84 100644 --- a/apps/api/src/controllers/auth.ts +++ b/apps/api/src/controllers/auth.ts @@ -46,6 +46,16 @@ function generateRandomPassword(length: number): string { return password; } +async function tracking(event: string, properties: any) { + const client = track(); + + client.capture({ + event: event, + properties: properties, + distinctId: "uuid", + }); +} + export function authRoutes(fastify: FastifyInstance) { // Register a new user fastify.post( @@ -175,7 +185,7 @@ export function authRoutes(fastify: FastifyInstance) { const hog = track(); hog.capture({ - event: "user_registered", + event: "user_registered_external", distinctId: user.id, }); @@ -339,6 +349,8 @@ export function authRoutes(fastify: FastifyInstance) { }, }); + await tracking("user_logged_in_password", {}); + const data = { id: user!.id, email: user!.email, @@ -523,13 +535,11 @@ export function authRoutes(fastify: FastifyInstance) { // Retrieve user information const userInfo = await oidcClient.userinfo(tokens.access_token); - console.log(userInfo); - let user = await prisma.user.findUnique({ where: { email: userInfo.email }, }); - console.log(user); + await tracking("user_logged_in_oidc", {}); if (!user) { // Create a new basic user @@ -665,6 +675,8 @@ export function authRoutes(fastify: FastifyInstance) { }, }); + await tracking("user_logged_in_oauth", {}); + // Send Response reply.send({ token: signed_token, @@ -757,6 +769,8 @@ export function authRoutes(fastify: FastifyInstance) { external_user: user!.external_user, }; + await tracking("user_profile", {}); + reply.send({ user: data, }); @@ -1008,6 +1022,8 @@ export function authRoutes(fastify: FastifyInstance) { }, }); + await tracking("user_first_login", {}); + reply.send({ success: true }); } } diff --git a/apps/api/src/controllers/config.ts b/apps/api/src/controllers/config.ts index f9dafbe95..a3516f83f 100644 --- a/apps/api/src/controllers/config.ts +++ b/apps/api/src/controllers/config.ts @@ -10,8 +10,18 @@ const nodemailer = require("nodemailer"); import { checkToken } from "../lib/jwt"; import { prisma } from "../prisma"; -import { emit } from "process"; import { createTransportProvider } from "../lib/nodemailer/transport"; +import { track } from "../lib/hog"; + +async function tracking(event: string, properties: any) { + const client = track(); + + client.capture({ + event: event, + properties: properties, + distinctId: "uuid", + }); +} export function configRoutes(fastify: FastifyInstance) { // Check auth method @@ -87,6 +97,8 @@ export function configRoutes(fastify: FastifyInstance) { }); } + await tracking("oidc_provider_updated", {}); + reply.send({ success: true, message: "OIDC config Provider updated!", @@ -152,6 +164,8 @@ export function configRoutes(fastify: FastifyInstance) { }); } + await tracking("oauth_provider_updated", {}); + reply.send({ success: true, message: "SSO Provider updated!", @@ -183,6 +197,8 @@ export function configRoutes(fastify: FastifyInstance) { // Delete the OAuth provider await prisma.oAuthProvider.deleteMany({}); + await tracking("sso_provider_deleted", {}); + reply.send({ success: true, message: "SSO Provider deleted!", diff --git a/apps/api/src/controllers/data.ts b/apps/api/src/controllers/data.ts index 6523f1908..dc07d860e 100644 --- a/apps/api/src/controllers/data.ts +++ b/apps/api/src/controllers/data.ts @@ -67,7 +67,7 @@ export function dataRoutes(fastify: FastifyInstance) { if (token) { const result = await prisma.ticket.count({ - where: { userId: null, hidden: false }, + where: { userId: null, hidden: false, isComplete: false }, }); reply.send({ count: result }); diff --git a/apps/api/src/controllers/notebook.ts b/apps/api/src/controllers/notebook.ts index 58c50d324..44c03e974 100644 --- a/apps/api/src/controllers/notebook.ts +++ b/apps/api/src/controllers/notebook.ts @@ -2,6 +2,19 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { checkToken } from "../lib/jwt"; import { checkSession } from "../lib/session"; import { prisma } from "../prisma"; +import { track } from "../lib/hog"; + +async function tracking(event: string, properties: any) { + const client = track(); + + client.capture({ + event: event, + properties: properties, + distinctId: "uuid", + }); + + client.shutdownAsync(); +} export function notebookRoutes(fastify: FastifyInstance) { // Create a new entry @@ -25,6 +38,8 @@ export function notebookRoutes(fastify: FastifyInstance) { }, }); + await tracking("note_created", {}); + const { id } = data; reply.status(200).send({ success: true, id }); diff --git a/apps/api/src/controllers/queue.ts b/apps/api/src/controllers/queue.ts index 558cfb044..146bfbfaf 100644 --- a/apps/api/src/controllers/queue.ts +++ b/apps/api/src/controllers/queue.ts @@ -3,6 +3,19 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { checkToken } from "../lib/jwt"; import { prisma } from "../prisma"; import { OAuth2Client } from "google-auth-library"; +import { track } from "../lib/hog"; + +async function tracking(event: string, properties: any) { + const client = track(); + + client.capture({ + event: event, + properties: properties, + distinctId: "uuid", + }); + + client.shutdownAsync(); +} export function emailQueueRoutes(fastify: FastifyInstance) { // Create a new email queue @@ -41,26 +54,47 @@ export function emailQueueRoutes(fastify: FastifyInstance) { }); // generate redirect uri - if (serviceType === "gmail") { - const google = new OAuth2Client(clientId, clientSecret, redirectUri); - - const authorizeUrl = google.generateAuthUrl({ - access_type: "offline", - scope: "https://mail.google.com", - prompt: "consent", - state: mailbox.id, - }); - - reply.send({ - success: true, - message: "Gmail imap provider created!", - authorizeUrl: authorizeUrl, - }); + switch (serviceType) { + case "gmail": + const google = new OAuth2Client( + clientId, + clientSecret, + redirectUri + ); + + const authorizeUrl = google.generateAuthUrl({ + access_type: "offline", + scope: "https://mail.google.com", + prompt: "consent", + state: mailbox.id, + }); + + tracking("gmail_provider_created", { + provider: "gmail", + }); + + reply.send({ + success: true, + message: "Gmail imap provider created!", + authorizeUrl: authorizeUrl, + }); + break; + case "other": + tracking("imap_provider_created", { + provider: "other", + }); + + reply.send({ + success: true, + message: "Other service type created!", + }); + break; + default: + reply.send({ + success: false, + message: "Unsupported service type", + }); } - - reply.send({ - success: true, - }); } } ); diff --git a/apps/api/src/controllers/todos.ts b/apps/api/src/controllers/todos.ts deleted file mode 100644 index 10f859a9c..000000000 --- a/apps/api/src/controllers/todos.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; -import { checkToken } from "../lib/jwt"; -import { checkSession } from "../lib/session"; -import { prisma } from "../prisma"; - -const doesTodoExist = async (id: any) => { - const exists = await prisma.todos - .findUnique({ - where: { - id: id, - }, - }) - .then(Boolean); - - return exists; -}; - -export function todoRoutes(fastify: FastifyInstance) { - // Create a new todo - fastify.post( - "/api/v1/todo/create", - - async (request: FastifyRequest, reply: FastifyReply) => { - const { todo } = request.body as { todo: string }; - - const bearer = request.headers.authorization!.split(" ")[1]; - const token = checkToken(bearer); - - if (!todo) { - console.log("No text found!"); - reply.status(400).send({ success: false, message: "No text found!" }); - } else { - if (token) { - const user = await checkSession(bearer); - - if (user) { - await prisma.todos.create({ - data: { - text: todo, - userId: user!.id, - }, - }); - reply.send({ success: true, message: "Todo created!" }); - } else { - reply - .status(400) - .send({ success: false, message: "User not found!" }); - } - } - } - } - ); - - // Get all todos - - fastify.get( - "/api/v1/todos/all", - - async (request: FastifyRequest, reply: FastifyReply) => { - const bearer = request.headers.authorization!.split(" ")[1]; - const token = checkToken(bearer); - - if (token) { - const todos = await prisma.todos.findMany({}); - - reply.send({ - todos: todos, - }); - } - } - ); - - // Delete a todo - fastify.delete( - "/api/v1/todo/:id/delete", - - async (request: FastifyRequest, reply: FastifyReply) => { - const bearer = request.headers.authorization!.split(" ")[1]; - const token = checkToken(bearer); - - if (token) { - const { id } = request.params as { id: string }; - - const todo = await doesTodoExist(id); - - if (!todo) { - return reply.status(404).send({ - success: false, - error: "Todo not found.", - }); - } - - await prisma.todos.delete({ - where: { - id: id, - }, - }); - - reply.status(201).send({ success: true, message: "Todo deleted" }); - } - } - ); -} diff --git a/apps/api/src/controllers/users.ts b/apps/api/src/controllers/users.ts index 8627dae65..8b6843aad 100644 --- a/apps/api/src/controllers/users.ts +++ b/apps/api/src/controllers/users.ts @@ -3,6 +3,7 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { checkToken } from "../lib/jwt"; import { prisma } from "../prisma"; +import { track } from "../lib/hog"; export function userRoutes(fastify: FastifyInstance) { // All users @@ -69,6 +70,16 @@ export function userRoutes(fastify: FastifyInstance) { }, }); + + const client = track(); + + client.capture({ + event: "user_created", + distinctId: "uuid", + }); + + client.shutdownAsync(); + reply.send({ success: true, }); diff --git a/apps/api/src/controllers/webhooks.ts b/apps/api/src/controllers/webhooks.ts index 0da80efc0..0a8590985 100644 --- a/apps/api/src/controllers/webhooks.ts +++ b/apps/api/src/controllers/webhooks.ts @@ -1,6 +1,7 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { checkToken } from "../lib/jwt"; import { prisma } from "../prisma"; +import { track } from "../lib/hog"; export function webhookRoutes(fastify: FastifyInstance) { // Create a new webhook @@ -23,6 +24,16 @@ export function webhookRoutes(fastify: FastifyInstance) { createdBy: "375f7799-5485-40ff-ba8f-0a28e0855ecf", }, }); + + const client = track(); + + client.capture({ + event: "webhook_created", + distinctId: "uuid", + }); + + client.shutdownAsync(); + reply.status(200).send({ message: "Hook created!", success: true }); } } From 027e1d7c762b203a690b3f02bb15aa6e56621d26 Mon Sep 17 00:00:00 2001 From: Jack Andrews Date: Fri, 8 Nov 2024 21:46:06 +0000 Subject: [PATCH 02/11] chore: save logging to file --- apps/api/.gitignore | 2 + apps/api/package.json | 1 + apps/api/src/controllers/config.ts | 10 +---- apps/api/src/main.ts | 16 +++++++- yarn.lock | 63 ++++++++++++++++++++++++++++++ 5 files changed, 81 insertions(+), 11 deletions(-) diff --git a/apps/api/.gitignore b/apps/api/.gitignore index 51ea78824..b7d4a37b7 100644 --- a/apps/api/.gitignore +++ b/apps/api/.gitignore @@ -104,3 +104,5 @@ dist .tern-port /uploads + +logs.log \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index 2f5ba508a..9c1bbad7e 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -55,6 +55,7 @@ "mailparser": "^3.6.5", "nodemailer": "^6.9.7", "openid-client": "^5.7.0", + "pino": "^9.5.0", "posthog-node": "^3.1.3", "prisma": "5.6.0", "samlify": "^2.8.11", diff --git a/apps/api/src/controllers/config.ts b/apps/api/src/controllers/config.ts index f9dafbe95..f9cbc825f 100644 --- a/apps/api/src/controllers/config.ts +++ b/apps/api/src/controllers/config.ts @@ -211,14 +211,7 @@ export function configRoutes(fastify: FastifyInstance) { }, }); - if (config === null) { - reply.send({ - success: true, - active: false, - }); - } - - if (config?.active) { + if (config && config?.active) { const provider = await createTransportProvider(); await new Promise((resolve, reject) => { @@ -247,7 +240,6 @@ export function configRoutes(fastify: FastifyInstance) { reply.send({ success: true, active: false, - email: config, }); } } diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index d52d546cf..be66fe11b 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -2,6 +2,7 @@ import cors from "@fastify/cors"; import "dotenv/config"; import Fastify, { FastifyInstance } from "fastify"; import multer from "fastify-multer"; +import fs from "fs"; import { exec } from "child_process"; import { track } from "./lib/hog"; @@ -9,12 +10,23 @@ import { getEmails } from "./lib/imap"; import { prisma } from "./prisma"; import { registerRoutes } from "./routes"; +// Ensure the directory exists +const logFilePath = './logs.log'; // Update this path to a writable location + +// Create a writable stream +const logStream = fs.createWriteStream(logFilePath, { flags: 'a' }); + +// Initialize Fastify with logger const server: FastifyInstance = Fastify({ - logger: true, + logger: { + stream: logStream, // Use the writable stream + }, + disableRequestLogging: true, + trustProxy: true, }); - server.register(cors, { origin: "*", + methods: ["GET", "POST", "PUT", "DELETE"], allowedHeaders: ["Content-Type", "Authorization", "Accept"], }); diff --git a/yarn.lock b/yarn.lock index 1218843d7..89fa73266 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5929,6 +5929,7 @@ __metadata: mailparser: "npm:^3.6.5" nodemailer: "npm:^6.9.7" openid-client: "npm:^5.7.0" + pino: "npm:^9.5.0" posthog-node: "npm:^3.1.3" prisma: "npm:5.6.0" samlify: "npm:^2.8.11" @@ -14447,6 +14448,15 @@ __metadata: languageName: node linkType: hard +"pino-abstract-transport@npm:^2.0.0": + version: 2.0.0 + resolution: "pino-abstract-transport@npm:2.0.0" + dependencies: + split2: "npm:^4.0.0" + checksum: 10c0/02c05b8f2ffce0d7c774c8e588f61e8b77de8ccb5f8125afd4a7325c9ea0e6af7fb78168999657712ae843e4462bb70ac550dfd6284f930ee57f17f486f25a9f + languageName: node + linkType: hard + "pino-std-serializers@npm:^6.0.0": version: 6.2.2 resolution: "pino-std-serializers@npm:6.2.2" @@ -14454,6 +14464,13 @@ __metadata: languageName: node linkType: hard +"pino-std-serializers@npm:^7.0.0": + version: 7.0.0 + resolution: "pino-std-serializers@npm:7.0.0" + checksum: 10c0/73e694d542e8de94445a03a98396cf383306de41fd75ecc07085d57ed7a57896198508a0dec6eefad8d701044af21eb27253ccc352586a03cf0d4a0bd25b4133 + languageName: node + linkType: hard + "pino@npm:^8.12.0": version: 8.21.0 resolution: "pino@npm:8.21.0" @@ -14475,6 +14492,27 @@ __metadata: languageName: node linkType: hard +"pino@npm:^9.5.0": + version: 9.5.0 + resolution: "pino@npm:9.5.0" + dependencies: + atomic-sleep: "npm:^1.0.0" + fast-redact: "npm:^3.1.1" + on-exit-leak-free: "npm:^2.1.0" + pino-abstract-transport: "npm:^2.0.0" + pino-std-serializers: "npm:^7.0.0" + process-warning: "npm:^4.0.0" + quick-format-unescaped: "npm:^4.0.3" + real-require: "npm:^0.2.0" + safe-stable-stringify: "npm:^2.3.1" + sonic-boom: "npm:^4.0.1" + thread-stream: "npm:^3.0.0" + bin: + pino: bin.js + checksum: 10c0/b06590c5f4da43df59905af1aac344432b43154c4c1569ebea168e7ae7fd0a4181ccabb769a6568cf3e781e1d1b9df13d65b3603e25ebb05539bcb02ea04215e + languageName: node + linkType: hard + "pirates@npm:^4.0.1": version: 4.0.6 resolution: "pirates@npm:4.0.6" @@ -14718,6 +14756,13 @@ __metadata: languageName: node linkType: hard +"process-warning@npm:^4.0.0": + version: 4.0.0 + resolution: "process-warning@npm:4.0.0" + checksum: 10c0/5312a72b69d37a1b82ad03f3dfa0090dab3804a8fd995d06c28e3c002852bd82f5584217d9f4a3f197892bb2afc22d57e2c662c7e906b5abb48c0380c7b0880d + languageName: node + linkType: hard + "process@npm:^0.11.10": version: 0.11.10 resolution: "process@npm:0.11.10" @@ -16468,6 +16513,15 @@ __metadata: languageName: node linkType: hard +"sonic-boom@npm:^4.0.1": + version: 4.2.0 + resolution: "sonic-boom@npm:4.2.0" + dependencies: + atomic-sleep: "npm:^1.0.0" + checksum: 10c0/ae897e6c2cd6d3cb7cdcf608bc182393b19c61c9413a85ce33ffd25891485589f39bece0db1de24381d0a38fc03d08c9862ded0c60f184f1b852f51f97af9684 + languageName: node + linkType: hard + "source-list-map@npm:^2.0.0": version: 2.0.1 resolution: "source-list-map@npm:2.0.1" @@ -17090,6 +17144,15 @@ __metadata: languageName: node linkType: hard +"thread-stream@npm:^3.0.0": + version: 3.1.0 + resolution: "thread-stream@npm:3.1.0" + dependencies: + real-require: "npm:^0.2.0" + checksum: 10c0/c36118379940b77a6ef3e6f4d5dd31e97b8210c3f7b9a54eb8fe6358ab173f6d0acfaf69b9c3db024b948c0c5fd2a7df93e2e49151af02076b35ada3205ec9a6 + languageName: node + linkType: hard + "tiny-lru@npm:^11.0.1": version: 11.2.11 resolution: "tiny-lru@npm:11.2.11" From 773da61a240bf851abfc2583b97eb1c7cc6183b8 Mon Sep 17 00:00:00 2001 From: Jack Andrews Date: Fri, 8 Nov 2024 22:02:09 +0000 Subject: [PATCH 03/11] chore: hide fields feature flags --- apps/client/@/shadcn/components/nav-main.tsx | 5 -- .../components/CreateTicketModal/index.tsx | 73 ++++++++++++++----- apps/client/pages/settings/flags.tsx | 23 +++++- 3 files changed, 75 insertions(+), 26 deletions(-) diff --git a/apps/client/@/shadcn/components/nav-main.tsx b/apps/client/@/shadcn/components/nav-main.tsx index 2161b90cc..ca63f6295 100644 --- a/apps/client/@/shadcn/components/nav-main.tsx +++ b/apps/client/@/shadcn/components/nav-main.tsx @@ -1,11 +1,6 @@ import { ChevronRight, type LucideIcon } from "lucide-react"; import { useState, useEffect } from "react"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/shadcn/ui/collapsible"; import { SidebarGroup, SidebarGroupLabel, diff --git a/apps/client/components/CreateTicketModal/index.tsx b/apps/client/components/CreateTicketModal/index.tsx index 8e764f427..68fb88761 100644 --- a/apps/client/components/CreateTicketModal/index.tsx +++ b/apps/client/components/CreateTicketModal/index.tsx @@ -145,8 +145,37 @@ export default function CreateTicketModal({ keypress, setKeyPressDown }) { useEffect(() => checkPress(), [keypress]); - const showKeyboardShortcuts = - localStorage.getItem("hide-keyboard-shortcuts") === "true"; + const [hideKeyboardShortcuts, setHideKeyboardShortcuts] = useState(false); + const [hideName, setHideName] = useState(false); + const [hideEmail, setHideEmail] = useState(false); + + useEffect(() => { + const loadFlags = () => { + const savedFlags = localStorage.getItem("featureFlags"); + if (savedFlags) { + const flags = JSON.parse(savedFlags); + const hideShortcuts = flags.find( + (f: any) => f.name === "Hide Keyboard Shortcuts" + )?.enabled; + + const hideName = flags.find( + (f: any) => f.name === "Hide Name in Create" + )?.enabled; + + const hideEmail = flags.find( + (f: any) => f.name === "Hide Email in Create" + )?.enabled; + + setHideKeyboardShortcuts(hideShortcuts || false); + setHideName(hideName || false); + setHideEmail(hideEmail || false); + } + }; + + loadFlags(); + window.addEventListener("storage", loadFlags); + return () => window.removeEventListener("storage", loadFlags); + }, []); return ( <> @@ -162,7 +191,7 @@ export default function CreateTicketModal({ keypress, setKeyPressDown }) { {state === "expanded" && ( <> New Issue - {showKeyboardShortcuts && ( + {!hideKeyboardShortcuts && (
c @@ -225,26 +254,30 @@ export default function CreateTicketModal({ keypress, setKeyPressDown }) { placeholder="Issue title" maxLength={64} onChange={(e) => setTitle(e.target.value)} - className="w-full pl-0 pr-0 text-md text-foreground bg-background border-none focus:outline-none focus:shadow-none focus:ring-0 focus:border-none" + className="w-full pl-0 pr-0 pt-0 text-md text-foreground bg-background border-none focus:outline-none focus:shadow-none focus:ring-0 focus:border-none" />
- setName(e.target.value)} - className=" w-full pl-0 pr-0text-foreground bg-background sm:text-sm border-none focus:outline-none focus:shadow-none focus:ring-0 focus:border-none" - /> - - setEmail(e.target.value)} - className=" w-full pl-0 pr-0 text-foreground bg-background sm:text-sm border-none focus:outline-none focus:shadow-none focus:ring-0 focus:border-none" - /> + {!hideName && ( + setName(e.target.value)} + className=" w-full pl-0 pr-0text-foreground bg-background sm:text-sm border-none focus:outline-none focus:shadow-none focus:ring-0 focus:border-none" + /> + )} + + {!hideEmail && ( + setEmail(e.target.value)} + className=" w-full pl-0 pr-0 text-foreground bg-background sm:text-sm border-none focus:outline-none focus:shadow-none focus:ring-0 focus:border-none" + /> + )} diff --git a/apps/client/pages/settings/flags.tsx b/apps/client/pages/settings/flags.tsx index c244682b7..81367bf76 100644 --- a/apps/client/pages/settings/flags.tsx +++ b/apps/client/pages/settings/flags.tsx @@ -5,6 +5,7 @@ interface FeatureFlag { name: string; enabled: boolean; description: string; + flagKey: string; } const defaultFlags: FeatureFlag[] = [ @@ -12,6 +13,19 @@ const defaultFlags: FeatureFlag[] = [ name: "Hide Keyboard Shortcuts", enabled: false, description: "Hide keyboard shortcuts", + flagKey: "keyboard_shortcuts_hide", // Added flag key for this feature + }, + { + name: "Hide Name in Create", + enabled: false, + description: "Hide name field in create a new issue", + flagKey: "name_hide", // Added flag key for this feature + }, + { + name: "Hide Email in Create", + enabled: false, + description: "Hide email field in create a new issue", + flagKey: "email_hide", // Added flag key for this feature }, ]; @@ -23,7 +37,14 @@ export default function FeatureFlags() { // Load flags from localStorage on component mount const savedFlags = localStorage.getItem("featureFlags"); if (savedFlags) { - setFlags(JSON.parse(savedFlags)); + const parsedFlags = JSON.parse(savedFlags); + // Merge saved flags with default flags, adding any new flags + const mergedFlags = defaultFlags.map(defaultFlag => { + const savedFlag = parsedFlags.find((f: FeatureFlag) => f.name === defaultFlag.name); + return savedFlag || defaultFlag; + }); + setFlags(mergedFlags); + localStorage.setItem("featureFlags", JSON.stringify(mergedFlags)); } else { setFlags(defaultFlags); localStorage.setItem("featureFlags", JSON.stringify(defaultFlags)); From 2ac1b445891f6f69857011a729eebb01fe745525 Mon Sep 17 00:00:00 2001 From: Jack Andrews Date: Fri, 8 Nov 2024 23:22:30 +0000 Subject: [PATCH 04/11] feat: right click context --- apps/api/src/controllers/ticket.ts | 29 +- apps/api/src/routes.ts | 2 - apps/client/@/shadcn/ui/context-menu.tsx | 198 +++++++++++ .../client/components/TicketDetails/index.tsx | 1 - apps/client/package.json | 1 + apps/client/pages/issues/index.tsx | 318 +++++++++++++++--- yarn.lock | 3 +- 7 files changed, 489 insertions(+), 63 deletions(-) create mode 100644 apps/client/@/shadcn/ui/context-menu.tsx diff --git a/apps/api/src/controllers/ticket.ts b/apps/api/src/controllers/ticket.ts index ebaa11ec7..c6289b020 100644 --- a/apps/api/src/controllers/ticket.ts +++ b/apps/api/src/controllers/ticket.ts @@ -443,20 +443,29 @@ export function ticketRoutes(fastify: FastifyInstance) { const { user, id }: any = request.body; if (token) { - const assigned = await prisma.user.update({ - where: { id: user }, - data: { - tickets: { - connect: { - id: id, + if (user) { + const assigned = await prisma.user.update({ + where: { id: user }, + data: { + tickets: { + connect: { + id: id, + }, }, }, - }, - }); + }); - const { email } = assigned; + const { email } = assigned; - await sendAssignedEmail(email); + await sendAssignedEmail(email); + } else { + await prisma.ticket.update({ + where: { id: id }, + data: { + userId: null, + }, + }); + } reply.send({ success: true, diff --git a/apps/api/src/routes.ts b/apps/api/src/routes.ts index 9f3a67cf5..b19b179f8 100644 --- a/apps/api/src/routes.ts +++ b/apps/api/src/routes.ts @@ -8,14 +8,12 @@ import { emailQueueRoutes } from "./controllers/queue"; import { objectStoreRoutes } from "./controllers/storage"; import { ticketRoutes } from "./controllers/ticket"; import { timeTrackingRoutes } from "./controllers/time"; -import { todoRoutes } from "./controllers/todos"; import { userRoutes } from "./controllers/users"; import { webhookRoutes } from "./controllers/webhooks"; export function registerRoutes(fastify: FastifyInstance) { authRoutes(fastify); emailQueueRoutes(fastify); - todoRoutes(fastify); dataRoutes(fastify); ticketRoutes(fastify); userRoutes(fastify); diff --git a/apps/client/@/shadcn/ui/context-menu.tsx b/apps/client/@/shadcn/ui/context-menu.tsx new file mode 100644 index 000000000..f0695bac4 --- /dev/null +++ b/apps/client/@/shadcn/ui/context-menu.tsx @@ -0,0 +1,198 @@ +import * as React from "react" +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/shadcn/lib/utils" + +const ContextMenu = ContextMenuPrimitive.Root + +const ContextMenuTrigger = ContextMenuPrimitive.Trigger + +const ContextMenuGroup = ContextMenuPrimitive.Group + +const ContextMenuPortal = ContextMenuPrimitive.Portal + +const ContextMenuSub = ContextMenuPrimitive.Sub + +const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup + +const ContextMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName + +const ContextMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName + +const ContextMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName + +const ContextMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName + +const ContextMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +ContextMenuCheckboxItem.displayName = + ContextMenuPrimitive.CheckboxItem.displayName + +const ContextMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName + +const ContextMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName + +const ContextMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName + +const ContextMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +ContextMenuShortcut.displayName = "ContextMenuShortcut" + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +} diff --git a/apps/client/components/TicketDetails/index.tsx b/apps/client/components/TicketDetails/index.tsx index 05f9bbbf5..19406b717 100644 --- a/apps/client/components/TicketDetails/index.tsx +++ b/apps/client/components/TicketDetails/index.tsx @@ -154,7 +154,6 @@ export default function Ticket() { .then((res) => res.json()) .then(() => { setEdit(false); - // refetch(); }); } diff --git a/apps/client/package.json b/apps/client/package.json index 093c14a2c..f9bcfe581 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -17,6 +17,7 @@ "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-collapsible": "^1.1.1", + "@radix-ui/react-context-menu": "^2.2.2", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-label": "^2.1.0", diff --git a/apps/client/pages/issues/index.tsx b/apps/client/pages/issues/index.tsx index bad16cc26..a4872773a 100644 --- a/apps/client/pages/issues/index.tsx +++ b/apps/client/pages/issues/index.tsx @@ -1,16 +1,25 @@ import useTranslation from "next-translate/useTranslation"; import { useRouter } from "next/router"; import Loader from "react-spinners/ClipLoader"; -import { useState, useMemo } from "react"; +import { useState, useMemo, useEffect } from "react"; -import { ContextMenu } from "@radix-ui/themes"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, + ContextMenuSub, + ContextMenuSubTrigger, + ContextMenuSubContent, +} from "@/shadcn/ui/context-menu"; import { getCookie } from "cookies-next"; import moment from "moment"; import Link from "next/link"; import { useQuery } from "react-query"; import { useUser } from "../../store/session"; import { Popover, PopoverContent, PopoverTrigger } from "@/shadcn/ui/popover"; -import { CheckIcon, Filter, PlusCircle, X } from "lucide-react"; +import { CheckIcon, Filter, X } from "lucide-react"; import { Button } from "@/shadcn/ui/button"; import { Command, @@ -22,6 +31,7 @@ import { CommandSeparator, } from "@/shadcn/ui/command"; import { cn } from "@/shadcn/lib/utils"; +import { toast } from "@/shadcn/hooks/use-toast"; async function getUserTickets(token: any) { const res = await fetch(`/api/v1/tickets/all`, { @@ -59,11 +69,11 @@ export default function Tickets() { const { t } = useTranslation("peppermint"); const token = getCookie("session"); - const { data, status, error } = useQuery( + const { data, status, error, refetch } = useQuery( "allusertickets", () => getUserTickets(token), { - refetchInterval: 1000, + refetchInterval: 5000, } ); @@ -77,6 +87,7 @@ export default function Tickets() { const [selectedPriorities, setSelectedPriorities] = useState([]); const [selectedStatuses, setSelectedStatuses] = useState([]); const [selectedAssignees, setSelectedAssignees] = useState([]); + const [users, setUsers] = useState([]); const handlePriorityToggle = (priority: string) => { setSelectedPriorities((prev) => @@ -145,6 +156,115 @@ export default function Tickets() { ); }, [data?.tickets, filterSearch]); + async function fetchUsers() { + await fetch(`/api/v1/users/all`, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }) + .then((res) => res.json()) + .then((res) => { + if (res) { + setUsers(res.users); + } + }); + } + + async function updateTicketStatus(e: any, ticket: any) { + await fetch(`/api/v1/ticket/status/update`, { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ id: ticket.id, status: !ticket.isComplete }), + }) + .then((res) => res.json()) + .then(() => { + toast({ + title: ticket.isComplete ? "Issue re-opened" : "Issue closed", + description: "The status of the issue has been updated.", + duration: 3000, + }); + refetch(); + }); + } + + // Add these new functions + async function updateTicketAssignee(ticketId: string, user: any) { + try { + const response = await fetch(`/api/v1/ticket/transfer`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + user: user ? user.id : undefined, + id: ticketId, + }), + }); + + if (!response.ok) throw new Error("Failed to update assignee"); + + toast({ + title: "Assignee updated", + description: `Transferred issue successfully`, + duration: 3000, + }); + refetch(); + } catch (error) { + toast({ + title: "Error", + description: "Failed to update assignee", + variant: "destructive", + duration: 3000, + }); + } + } + + async function updateTicketPriority(ticket: any, priority: string) { + try { + const response = await fetch(`/api/v1/ticket/update`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + id: ticket.id, + detail: ticket.detail, + note: ticket.note, + title: ticket.title, + priority: priority, + status: ticket.status, + }), + }).then((res) => res.json()); + + if (!response.success) throw new Error("Failed to update priority"); + + toast({ + title: "Priority updated", + description: `Ticket priority set to ${priority}`, + duration: 3000, + }); + refetch(); + } catch (error) { + toast({ + title: "Error", + description: "Failed to update priority", + variant: "destructive", + duration: 3000, + }); + } + } + + useEffect(() => { + fetchUsers(); + }, []); + return (
{status === "loading" && ( @@ -390,9 +510,9 @@ export default function Tickets() { } return ( - - - + + +
@@ -460,47 +580,147 @@ export default function Tickets() {
-
- - {/* Edit */} - {/* - Status - - - - Assigned To - - - Priortiy - - - Label - */} - - {/* - More - - - Move to project… - - Move to folder… - - - Advanced options… - - - */} - - {/* - Share - Add to favorites - - - Delete - */} - -
- + + + + updateTicketStatus(e, ticket)} + > + {ticket.isComplete ? "Re-open Issue" : "Close Issue"} + + + + + Assign To + + + + + + updateTicketAssignee(ticket.id, undefined) + } + > +
+ +
+ Unassigned +
+ {users?.map((user) => ( + + updateTicketAssignee(ticket.id, user) + } + > +
+ +
+ {user.name} +
+ ))} +
+
+
+
+
+ + + + Change Priority + + + + + + {filteredPriorities.map((priority) => ( + + updateTicketPriority(ticket, priority) + } + > +
+ +
+ + {priority} + +
+ ))} +
+
+
+
+
+ + + + { + e.preventDefault(); + toast({ + title: "Link copied to clipboard", + description: + "You can now share the link with others.", + duration: 3000, + }); + navigator.clipboard.writeText( + `${window.location.origin}/issue/${ticket.id}` + ); + }} + > + Share Link + + + + + { + e.preventDefault(); + if ( + confirm( + "Are you sure you want to delete this ticket?" + ) + ) { + fetch(`/api/v1/ticket/delete`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ id: ticket.id }), + }); + } + }} + > + Delete Ticket + +
+ ); }) ) : ( diff --git a/yarn.lock b/yarn.lock index 89fa73266..4dcd78485 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3024,7 +3024,7 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-context-menu@npm:^2.1.5": +"@radix-ui/react-context-menu@npm:^2.1.5, @radix-ui/react-context-menu@npm:^2.2.2": version: 2.2.2 resolution: "@radix-ui/react-context-menu@npm:2.2.2" dependencies: @@ -6787,6 +6787,7 @@ __metadata: "@radix-ui/react-alert-dialog": "npm:^1.1.2" "@radix-ui/react-avatar": "npm:^1.1.1" "@radix-ui/react-collapsible": "npm:^1.1.1" + "@radix-ui/react-context-menu": "npm:^2.2.2" "@radix-ui/react-dialog": "npm:^1.1.2" "@radix-ui/react-dropdown-menu": "npm:^2.1.2" "@radix-ui/react-label": "npm:^2.1.0" From 040a934976f7bc82212b8bfb5a6423f5785c8192 Mon Sep 17 00:00:00 2001 From: Jack Andrews Date: Sat, 9 Nov 2024 00:54:45 +0000 Subject: [PATCH 05/11] fix: hide scroll --- apps/client/styles/globals.css | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/client/styles/globals.css b/apps/client/styles/globals.css index 5331128d4..d3132b661 100644 --- a/apps/client/styles/globals.css +++ b/apps/client/styles/globals.css @@ -516,6 +516,16 @@ } } +.root::-webkit-scrollbar { + display: none !important; +} + +/* Hide scrollbar for IE, Edge and Firefox */ +.root { + -ms-overflow-style: none !important; /* IE and Edge */ + scrollbar-width: none !important; /* Firefox */ +} + .ProseMirror { overflow-y: auto; max-height: 70vh; From 5dd78d645b17b1e5cff95b8379dd2b326346f3e3 Mon Sep 17 00:00:00 2001 From: Jack Andrews Date: Sat, 9 Nov 2024 01:02:56 +0000 Subject: [PATCH 06/11] fix: create first issue tbn --- apps/client/pages/issues/closed.tsx | 610 ++++++++++++++++++-- apps/client/pages/issues/index.tsx | 7 +- apps/client/pages/issues/open.tsx | 612 ++++++++++++++++++--- apps/client/pages/portal/index.tsx | 2 +- apps/client/pages/portal/issues/closed.tsx | 5 +- apps/client/pages/portal/issues/index.tsx | 8 +- apps/client/pages/portal/issues/open.tsx | 5 +- 7 files changed, 1120 insertions(+), 129 deletions(-) diff --git a/apps/client/pages/issues/closed.tsx b/apps/client/pages/issues/closed.tsx index a5036fd74..f9033c8e2 100644 --- a/apps/client/pages/issues/closed.tsx +++ b/apps/client/pages/issues/closed.tsx @@ -1,16 +1,25 @@ import useTranslation from "next-translate/useTranslation"; import { useRouter } from "next/router"; import Loader from "react-spinners/ClipLoader"; +import { useState, useMemo, useEffect } from "react"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, + ContextMenuSub, + ContextMenuSubTrigger, + ContextMenuSubContent, +} from "@/shadcn/ui/context-menu"; import { getCookie } from "cookies-next"; import moment from "moment"; import Link from "next/link"; import { useQuery } from "react-query"; import { useUser } from "../../store/session"; -import { useState } from "react"; -import { ContextMenu } from "@radix-ui/themes"; import { Popover, PopoverContent, PopoverTrigger } from "@/shadcn/ui/popover"; -import { CheckIcon, PlusCircle } from "lucide-react"; +import { CheckIcon, Filter, X } from "lucide-react"; import { Button } from "@/shadcn/ui/button"; import { Command, @@ -22,10 +31,7 @@ import { CommandSeparator, } from "@/shadcn/ui/command"; import { cn } from "@/shadcn/lib/utils"; - -function classNames(...classes: any) { - return classes.filter(Boolean).join(" "); -} +import { toast } from "@/shadcn/hooks/use-toast"; async function getUserTickets(token: any) { const res = await fetch(`/api/v1/tickets/completed`, { @@ -36,16 +42,37 @@ async function getUserTickets(token: any) { return res.json(); } +const FilterBadge = ({ + text, + onRemove, +}: { + text: string; + onRemove: () => void; +}) => ( +
+ {text} + +
+); + export default function Tickets() { const router = useRouter(); const { t } = useTranslation("peppermint"); const token = getCookie("session"); - const { data, status, error } = useQuery( + const { data, status, error, refetch } = useQuery( "allusertickets", () => getUserTickets(token), { - refetchInterval: 1000, + refetchInterval: 5000, } ); @@ -55,7 +82,11 @@ export default function Tickets() { const low = "bg-blue-100 text-blue-800"; const normal = "bg-green-100 text-green-800"; + const [filterSelected, setFilterSelected] = useState(); const [selectedPriorities, setSelectedPriorities] = useState([]); + const [selectedStatuses, setSelectedStatuses] = useState([]); + const [selectedAssignees, setSelectedAssignees] = useState([]); + const [users, setUsers] = useState([]); const handlePriorityToggle = (priority: string) => { setSelectedPriorities((prev) => @@ -65,14 +96,174 @@ export default function Tickets() { ); }; + const handleStatusToggle = (status: string) => { + setSelectedStatuses((prev) => + prev.includes(status) + ? prev.filter((s) => s !== status) + : [...prev, status] + ); + }; + + const handleAssigneeToggle = (assignee: string) => { + setSelectedAssignees((prev) => + prev.includes(assignee) + ? prev.filter((a) => a !== assignee) + : [...prev, assignee] + ); + }; + const filteredTickets = data - ? data.tickets.filter((ticket) => - selectedPriorities.length > 0 - ? selectedPriorities.includes(ticket.priority) - : true - ) + ? data.tickets.filter((ticket) => { + const priorityMatch = + selectedPriorities.length === 0 || + selectedPriorities.includes(ticket.priority); + const statusMatch = + selectedStatuses.length === 0 || + selectedStatuses.includes(ticket.isComplete ? "closed" : "open"); + const assigneeMatch = + selectedAssignees.length === 0 || + selectedAssignees.includes(ticket.assignedTo?.name || "Unassigned"); + + return priorityMatch && statusMatch && assigneeMatch; + }) : []; + type FilterType = "priority" | "status" | "assignee" | null; + const [activeFilter, setActiveFilter] = useState(null); + const [filterSearch, setFilterSearch] = useState(""); + + const filteredPriorities = useMemo(() => { + const priorities = ["low", "medium", "high"]; + return priorities.filter((priority) => + priority.toLowerCase().includes(filterSearch.toLowerCase()) + ); + }, [filterSearch]); + + const filteredStatuses = useMemo(() => { + const statuses = ["open", "closed"]; + return statuses.filter((status) => + status.toLowerCase().includes(filterSearch.toLowerCase()) + ); + }, [filterSearch]); + + const filteredAssignees = useMemo(() => { + const assignees = data?.tickets + .map((t) => t.assignedTo?.name || "Unassigned") + .filter((name, index, self) => self.indexOf(name) === index); + return assignees?.filter((assignee) => + assignee.toLowerCase().includes(filterSearch.toLowerCase()) + ); + }, [data?.tickets, filterSearch]); + + async function fetchUsers() { + await fetch(`/api/v1/users/all`, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }) + .then((res) => res.json()) + .then((res) => { + if (res) { + setUsers(res.users); + } + }); + } + + async function updateTicketStatus(e: any, ticket: any) { + await fetch(`/api/v1/ticket/status/update`, { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ id: ticket.id, status: !ticket.isComplete }), + }) + .then((res) => res.json()) + .then(() => { + toast({ + title: ticket.isComplete ? "Issue re-opened" : "Issue closed", + description: "The status of the issue has been updated.", + duration: 3000, + }); + refetch(); + }); + } + + // Add these new functions + async function updateTicketAssignee(ticketId: string, user: any) { + try { + const response = await fetch(`/api/v1/ticket/transfer`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + user: user ? user.id : undefined, + id: ticketId, + }), + }); + + if (!response.ok) throw new Error("Failed to update assignee"); + + toast({ + title: "Assignee updated", + description: `Transferred issue successfully`, + duration: 3000, + }); + refetch(); + } catch (error) { + toast({ + title: "Error", + description: "Failed to update assignee", + variant: "destructive", + duration: 3000, + }); + } + } + + async function updateTicketPriority(ticket: any, priority: string) { + try { + const response = await fetch(`/api/v1/ticket/update`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + id: ticket.id, + detail: ticket.detail, + note: ticket.note, + title: ticket.title, + priority: priority, + status: ticket.status, + }), + }).then((res) => res.json()); + + if (!response.success) throw new Error("Failed to update priority"); + + toast({ + title: "Priority updated", + description: `Ticket priority set to ${priority}`, + duration: 3000, + }); + refetch(); + } catch (error) { + toast({ + title: "Error", + description: "Failed to update priority", + variant: "destructive", + duration: 3000, + }); + } + } + + useEffect(() => { + fetchUsers(); + }, []); + return (
{status === "loading" && ( @@ -84,63 +275,221 @@ export default function Tickets() { {status === "success" && (
-
-
- - You have {filteredTickets.length} closed ticket - {filteredTickets.length > 1 ? "'s" : ""} - +
+
- - - - - No results found. - - {["low", "medium", "high"].map((priority) => ( + + {!activeFilter ? ( + + + + No results found. + + setActiveFilter("priority")} + > + Priority + + setActiveFilter("status")} + > + Status + + setActiveFilter("assignee")} + > + Assigned To + + + + + ) : activeFilter === "priority" ? ( + + + + No priorities found. + + {filteredPriorities.map((priority) => ( + handlePriorityToggle(priority)} + > +
+ +
+ {priority} +
+ ))} +
+ + handlePriorityToggle(priority)} + onSelect={() => { + setActiveFilter(null); + setFilterSearch(""); + }} + className="justify-center text-center" > -
+ + + + ) : activeFilter === "status" ? ( + + + + No statuses found. + + {filteredStatuses.map((status) => ( + handleStatusToggle(status)} > - -
- {priority} +
+ +
+ {status} +
+ ))} +
+ + + { + setActiveFilter(null); + setFilterSearch(""); + }} + className="justify-center text-center" + > + Back to filters - ))} - - <> +
+
+
+ ) : activeFilter === "assignee" ? ( + + + + No assignees found. + + {filteredAssignees?.map((name) => ( + handleAssigneeToggle(name)} + > +
+ +
+ {name} +
+ ))} +
setSelectedPriorities([])} + onSelect={() => { + setActiveFilter(null); + setFilterSearch(""); + }} className="justify-center text-center" > - Clear filters + Back to filters - -
-
+ + + ) : null}
+ + {/* Display selected filters */} +
+ {selectedPriorities.map((priority) => ( + handlePriorityToggle(priority)} + /> + ))} + + {selectedStatuses.map((status) => ( + handleStatusToggle(status)} + /> + ))} + + {selectedAssignees.map((assignee) => ( + handleAssigneeToggle(assignee)} + /> + ))} + + {/* Clear all filters button - only show if there are filters */} + {(selectedPriorities.length > 0 || + selectedStatuses.length > 0 || + selectedAssignees.length > 0) && ( + + )} +
@@ -160,9 +509,9 @@ export default function Tickets() { } return ( - - - + + +
@@ -230,12 +579,147 @@ export default function Tickets() {
-
- - {/* Context menu items can be added here */} - -
- + + + + updateTicketStatus(e, ticket)} + > + {ticket.isComplete ? "Re-open Issue" : "Close Issue"} + + + + + Assign To + + + + + + updateTicketAssignee(ticket.id, undefined) + } + > +
+ +
+ Unassigned +
+ {users?.map((user) => ( + + updateTicketAssignee(ticket.id, user) + } + > +
+ +
+ {user.name} +
+ ))} +
+
+
+
+
+ + + + Change Priority + + + + + + {filteredPriorities.map((priority) => ( + + updateTicketPriority(ticket, priority) + } + > +
+ +
+ + {priority} + +
+ ))} +
+
+
+
+
+ + + + { + e.preventDefault(); + toast({ + title: "Link copied to clipboard", + description: + "You can now share the link with others.", + duration: 3000, + }); + navigator.clipboard.writeText( + `${window.location.origin}/issue/${ticket.id}` + ); + }} + > + Share Link + + + + + { + e.preventDefault(); + if ( + confirm( + "Are you sure you want to delete this ticket?" + ) + ) { + fetch(`/api/v1/ticket/delete`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ id: ticket.id }), + }); + } + }} + > + Delete Ticket + +
+ ); }) ) : ( @@ -243,6 +727,10 @@ export default function Tickets() {
diff --git a/apps/client/pages/issues/index.tsx b/apps/client/pages/issues/index.tsx index a4872773a..177bc0751 100644 --- a/apps/client/pages/issues/index.tsx +++ b/apps/client/pages/issues/index.tsx @@ -42,7 +42,6 @@ async function getUserTickets(token: any) { return res.json(); } -// Add this new component for the filter badge const FilterBadge = ({ text, onRemove, @@ -728,6 +727,10 @@ export default function Tickets() {
diff --git a/apps/client/pages/issues/open.tsx b/apps/client/pages/issues/open.tsx index afe72aeab..3d84bd0a3 100644 --- a/apps/client/pages/issues/open.tsx +++ b/apps/client/pages/issues/open.tsx @@ -1,16 +1,25 @@ import useTranslation from "next-translate/useTranslation"; import { useRouter } from "next/router"; import Loader from "react-spinners/ClipLoader"; +import { useState, useMemo, useEffect } from "react"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, + ContextMenuSub, + ContextMenuSubTrigger, + ContextMenuSubContent, +} from "@/shadcn/ui/context-menu"; import { getCookie } from "cookies-next"; import moment from "moment"; import Link from "next/link"; import { useQuery } from "react-query"; import { useUser } from "../../store/session"; -import { useState } from "react"; -import { ContextMenu } from "@radix-ui/themes"; import { Popover, PopoverContent, PopoverTrigger } from "@/shadcn/ui/popover"; -import { CheckIcon, PlusCircle } from "lucide-react"; +import { CheckIcon, Filter, X } from "lucide-react"; import { Button } from "@/shadcn/ui/button"; import { Command, @@ -22,13 +31,10 @@ import { CommandSeparator, } from "@/shadcn/ui/command"; import { cn } from "@/shadcn/lib/utils"; - -function classNames(...classes: any) { - return classes.filter(Boolean).join(" "); -} +import { toast } from "@/shadcn/hooks/use-toast"; async function getUserTickets(token: any) { - const res = await fetch(`/api/v1/tickets/user/open`, { + const res = await fetch(`/api/v1/tickets/open`, { headers: { Authorization: `Bearer ${token}`, }, @@ -36,16 +42,37 @@ async function getUserTickets(token: any) { return res.json(); } +const FilterBadge = ({ + text, + onRemove, +}: { + text: string; + onRemove: () => void; +}) => ( +
+ {text} + +
+); + export default function Tickets() { const router = useRouter(); const { t } = useTranslation("peppermint"); const token = getCookie("session"); - const { data, status, error } = useQuery( + const { data, status, error, refetch } = useQuery( "allusertickets", () => getUserTickets(token), { - refetchInterval: 1000, + refetchInterval: 5000, } ); @@ -55,7 +82,11 @@ export default function Tickets() { const low = "bg-blue-100 text-blue-800"; const normal = "bg-green-100 text-green-800"; + const [filterSelected, setFilterSelected] = useState(); const [selectedPriorities, setSelectedPriorities] = useState([]); + const [selectedStatuses, setSelectedStatuses] = useState([]); + const [selectedAssignees, setSelectedAssignees] = useState([]); + const [users, setUsers] = useState([]); const handlePriorityToggle = (priority: string) => { setSelectedPriorities((prev) => @@ -65,14 +96,174 @@ export default function Tickets() { ); }; + const handleStatusToggle = (status: string) => { + setSelectedStatuses((prev) => + prev.includes(status) + ? prev.filter((s) => s !== status) + : [...prev, status] + ); + }; + + const handleAssigneeToggle = (assignee: string) => { + setSelectedAssignees((prev) => + prev.includes(assignee) + ? prev.filter((a) => a !== assignee) + : [...prev, assignee] + ); + }; + const filteredTickets = data - ? data.tickets.filter((ticket) => - selectedPriorities.length > 0 - ? selectedPriorities.includes(ticket.priority) - : true - ) + ? data.tickets.filter((ticket) => { + const priorityMatch = + selectedPriorities.length === 0 || + selectedPriorities.includes(ticket.priority); + const statusMatch = + selectedStatuses.length === 0 || + selectedStatuses.includes(ticket.isComplete ? "closed" : "open"); + const assigneeMatch = + selectedAssignees.length === 0 || + selectedAssignees.includes(ticket.assignedTo?.name || "Unassigned"); + + return priorityMatch && statusMatch && assigneeMatch; + }) : []; + type FilterType = "priority" | "status" | "assignee" | null; + const [activeFilter, setActiveFilter] = useState(null); + const [filterSearch, setFilterSearch] = useState(""); + + const filteredPriorities = useMemo(() => { + const priorities = ["low", "medium", "high"]; + return priorities.filter((priority) => + priority.toLowerCase().includes(filterSearch.toLowerCase()) + ); + }, [filterSearch]); + + const filteredStatuses = useMemo(() => { + const statuses = ["open", "closed"]; + return statuses.filter((status) => + status.toLowerCase().includes(filterSearch.toLowerCase()) + ); + }, [filterSearch]); + + const filteredAssignees = useMemo(() => { + const assignees = data?.tickets + .map((t) => t.assignedTo?.name || "Unassigned") + .filter((name, index, self) => self.indexOf(name) === index); + return assignees?.filter((assignee) => + assignee.toLowerCase().includes(filterSearch.toLowerCase()) + ); + }, [data?.tickets, filterSearch]); + + async function fetchUsers() { + await fetch(`/api/v1/users/all`, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }) + .then((res) => res.json()) + .then((res) => { + if (res) { + setUsers(res.users); + } + }); + } + + async function updateTicketStatus(e: any, ticket: any) { + await fetch(`/api/v1/ticket/status/update`, { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ id: ticket.id, status: !ticket.isComplete }), + }) + .then((res) => res.json()) + .then(() => { + toast({ + title: ticket.isComplete ? "Issue re-opened" : "Issue closed", + description: "The status of the issue has been updated.", + duration: 3000, + }); + refetch(); + }); + } + + // Add these new functions + async function updateTicketAssignee(ticketId: string, user: any) { + try { + const response = await fetch(`/api/v1/ticket/transfer`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + user: user ? user.id : undefined, + id: ticketId, + }), + }); + + if (!response.ok) throw new Error("Failed to update assignee"); + + toast({ + title: "Assignee updated", + description: `Transferred issue successfully`, + duration: 3000, + }); + refetch(); + } catch (error) { + toast({ + title: "Error", + description: "Failed to update assignee", + variant: "destructive", + duration: 3000, + }); + } + } + + async function updateTicketPriority(ticket: any, priority: string) { + try { + const response = await fetch(`/api/v1/ticket/update`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + id: ticket.id, + detail: ticket.detail, + note: ticket.note, + title: ticket.title, + priority: priority, + status: ticket.status, + }), + }).then((res) => res.json()); + + if (!response.success) throw new Error("Failed to update priority"); + + toast({ + title: "Priority updated", + description: `Ticket priority set to ${priority}`, + duration: 3000, + }); + refetch(); + } catch (error) { + toast({ + title: "Error", + description: "Failed to update priority", + variant: "destructive", + duration: 3000, + }); + } + } + + useEffect(() => { + fetchUsers(); + }, []); + return (
{status === "loading" && ( @@ -84,63 +275,221 @@ export default function Tickets() { {status === "success" && (
-
-
- - You have {filteredTickets.length} open ticket - {filteredTickets.length > 1 ? "'s" : ""} - +
+
- - - - - No results found. - - {["low", "medium", "high"].map((priority) => ( + + {!activeFilter ? ( + + + + No results found. + + setActiveFilter("priority")} + > + Priority + + setActiveFilter("status")} + > + Status + + setActiveFilter("assignee")} + > + Assigned To + + + + + ) : activeFilter === "priority" ? ( + + + + No priorities found. + + {filteredPriorities.map((priority) => ( + handlePriorityToggle(priority)} + > +
+ +
+ {priority} +
+ ))} +
+ + handlePriorityToggle(priority)} + onSelect={() => { + setActiveFilter(null); + setFilterSearch(""); + }} + className="justify-center text-center" > -
+ + + + ) : activeFilter === "status" ? ( + + + + No statuses found. + + {filteredStatuses.map((status) => ( + handleStatusToggle(status)} > - -
- {priority} +
+ +
+ {status} +
+ ))} +
+ + + { + setActiveFilter(null); + setFilterSearch(""); + }} + className="justify-center text-center" + > + Back to filters - ))} - - <> +
+
+
+ ) : activeFilter === "assignee" ? ( + + + + No assignees found. + + {filteredAssignees?.map((name) => ( + handleAssigneeToggle(name)} + > +
+ +
+ {name} +
+ ))} +
setSelectedPriorities([])} + onSelect={() => { + setActiveFilter(null); + setFilterSearch(""); + }} className="justify-center text-center" > - Clear filters + Back to filters - -
-
+ + + ) : null}
+ + {/* Display selected filters */} +
+ {selectedPriorities.map((priority) => ( + handlePriorityToggle(priority)} + /> + ))} + + {selectedStatuses.map((status) => ( + handleStatusToggle(status)} + /> + ))} + + {selectedAssignees.map((assignee) => ( + handleAssigneeToggle(assignee)} + /> + ))} + + {/* Clear all filters button - only show if there are filters */} + {(selectedPriorities.length > 0 || + selectedStatuses.length > 0 || + selectedAssignees.length > 0) && ( + + )} +
@@ -160,9 +509,9 @@ export default function Tickets() { } return ( - - - + + +
@@ -230,12 +579,147 @@ export default function Tickets() {
-
- - {/* Context menu items can be added here */} - -
- + + + + updateTicketStatus(e, ticket)} + > + {ticket.isComplete ? "Re-open Issue" : "Close Issue"} + + + + + Assign To + + + + + + updateTicketAssignee(ticket.id, undefined) + } + > +
+ +
+ Unassigned +
+ {users?.map((user) => ( + + updateTicketAssignee(ticket.id, user) + } + > +
+ +
+ {user.name} +
+ ))} +
+
+
+
+
+ + + + Change Priority + + + + + + {filteredPriorities.map((priority) => ( + + updateTicketPriority(ticket, priority) + } + > +
+ +
+ + {priority} + +
+ ))} +
+
+
+
+
+ + + + { + e.preventDefault(); + toast({ + title: "Link copied to clipboard", + description: + "You can now share the link with others.", + duration: 3000, + }); + navigator.clipboard.writeText( + `${window.location.origin}/issue/${ticket.id}` + ); + }} + > + Share Link + + + + + { + e.preventDefault(); + if ( + confirm( + "Are you sure you want to delete this ticket?" + ) + ) { + fetch(`/api/v1/ticket/delete`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ id: ticket.id }), + }); + } + }} + > + Delete Ticket + +
+ ); }) ) : ( @@ -243,6 +727,10 @@ export default function Tickets() {
diff --git a/apps/client/pages/portal/index.tsx b/apps/client/pages/portal/index.tsx index 2ee50564a..4cedf72cd 100644 --- a/apps/client/pages/portal/index.tsx +++ b/apps/client/pages/portal/index.tsx @@ -71,7 +71,7 @@ export default function Home() { /> - Create your first ticket + Create your first issue diff --git a/apps/client/pages/portal/issues/closed.tsx b/apps/client/pages/portal/issues/closed.tsx index c33071b3a..46f530c33 100644 --- a/apps/client/pages/portal/issues/closed.tsx +++ b/apps/client/pages/portal/issues/closed.tsx @@ -104,6 +104,9 @@ export default function Tickets() {
diff --git a/apps/client/pages/portal/issues/index.tsx b/apps/client/pages/portal/issues/index.tsx index 48edcc15b..fbcb91495 100644 --- a/apps/client/pages/portal/issues/index.tsx +++ b/apps/client/pages/portal/issues/index.tsx @@ -5,6 +5,7 @@ import { getCookie } from "cookies-next"; import moment from "moment"; import Link from "next/link"; import { useQuery } from "react-query"; +import { useRouter } from "next/router"; async function getUserTickets(token: any) { const res = await fetch(`/api/v1/tickets/user/external`, { @@ -18,6 +19,8 @@ async function getUserTickets(token: any) { export default function Tickets() { const { t } = useTranslation("peppermint"); + const router = useRouter(); + const token = getCookie("session"); const { data, status, error } = useQuery("allusertickets", () => getUserTickets(token) @@ -124,6 +127,9 @@ export default function Tickets() {
diff --git a/apps/client/pages/portal/issues/open.tsx b/apps/client/pages/portal/issues/open.tsx index ace097df2..22c3df570 100644 --- a/apps/client/pages/portal/issues/open.tsx +++ b/apps/client/pages/portal/issues/open.tsx @@ -104,6 +104,9 @@ export default function Tickets() {
From a82468af3c27554367fad0a77b7d83f845f26543 Mon Sep 17 00:00:00 2001 From: Jack Andrews Date: Sat, 9 Nov 2024 01:07:07 +0000 Subject: [PATCH 07/11] fix: portal nav fix --- apps/client/layouts/portalLayout.tsx | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/apps/client/layouts/portalLayout.tsx b/apps/client/layouts/portalLayout.tsx index 85e99b492..3b5422843 100644 --- a/apps/client/layouts/portalLayout.tsx +++ b/apps/client/layouts/portalLayout.tsx @@ -70,7 +70,6 @@ export default function PortalLayout({ children }: any) { } } - function handleKeyPress(event: any) { const pathname = location.pathname; console.log(pathname); @@ -88,13 +87,13 @@ export default function PortalLayout({ children }: any) { location.push("/portal/"); break; case "t": - location.push("/portal/tickets"); + location.push("/portal/issues"); break; case "o": - location.push("/portal/tickets/open"); + location.push("/portal/issues/open"); break; case "f": - location.push("/portal/tickets/closed"); + location.push("/portal/issues/closed"); break; default: break; @@ -274,16 +273,16 @@ export default function PortalLayout({ children }: any) {
  • - Tickets + Issues
    t @@ -293,9 +292,9 @@ export default function PortalLayout({ children }: any) {
  • - - {/* Profile dropdown */} @@ -384,7 +381,6 @@ export default function PortalLayout({ children }: any) { leaveTo="transform opacity-0 scale-95" > - {({ active }) => (