From 11d91666847cce3284ce1a992eade576ea93b866 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Fri, 28 Jun 2024 17:40:34 +0800 Subject: [PATCH 01/10] misc: initial project favorite in grid view --- .../20240627142223_member-project-favorite.ts | 19 +++ backend/src/db/schemas/org-memberships.ts | 3 +- backend/src/server/routes/index.ts | 4 +- backend/src/server/routes/v1/user-router.ts | 46 +++++- backend/src/services/user/user-service.ts | 52 +++++- frontend/src/hooks/api/users/mutation.tsx | 24 +++ frontend/src/hooks/api/users/queries.tsx | 16 ++ .../src/pages/org/[id]/overview/index.tsx | 155 +++++++++++++----- 8 files changed, 272 insertions(+), 47 deletions(-) create mode 100644 backend/src/db/migrations/20240627142223_member-project-favorite.ts diff --git a/backend/src/db/migrations/20240627142223_member-project-favorite.ts b/backend/src/db/migrations/20240627142223_member-project-favorite.ts new file mode 100644 index 0000000000..0021cca4cb --- /dev/null +++ b/backend/src/db/migrations/20240627142223_member-project-favorite.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.OrgMembership, "projectFavorites"))) { + await knex.schema.alterTable(TableName.OrgMembership, (tb) => { + tb.specificType("projectFavorites", "text[]"); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.OrgMembership, "projectFavorites")) { + await knex.schema.alterTable(TableName.OrgMembership, (t) => { + t.dropColumn("projectFavorites"); + }); + } +} diff --git a/backend/src/db/schemas/org-memberships.ts b/backend/src/db/schemas/org-memberships.ts index 585addb7ce..b1858e5be6 100644 --- a/backend/src/db/schemas/org-memberships.ts +++ b/backend/src/db/schemas/org-memberships.ts @@ -16,7 +16,8 @@ export const OrgMembershipsSchema = z.object({ updatedAt: z.date(), userId: z.string().uuid().nullable().optional(), orgId: z.string().uuid(), - roleId: z.string().uuid().nullable().optional() + roleId: z.string().uuid().nullable().optional(), + projectFavorites: z.string().array().nullable().optional() }); export type TOrgMemberships = z.infer; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index dfcbf82cfa..6b7b18f5c1 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -415,8 +415,10 @@ export const registerRoutes = async ( userAliasDAL, orgMembershipDAL, tokenService, - smtpService + smtpService, + projectMembershipDAL }); + const loginService = authLoginServiceFactory({ userDAL, smtpService, tokenService, orgDAL, tokenDAL: authTokenDAL }); const passwordService = authPaswordServiceFactory({ tokenService, diff --git a/backend/src/server/routes/v1/user-router.ts b/backend/src/server/routes/v1/user-router.ts index b9269d66e1..d3c0db242f 100644 --- a/backend/src/server/routes/v1/user-router.ts +++ b/backend/src/server/routes/v1/user-router.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { logger } from "@app/lib/logger"; -import { authRateLimit, readLimit } from "@app/server/config/rateLimiter"; +import { authRateLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -90,4 +90,48 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { return res.redirect(`${appCfg.SITE_URL}/login`); } }); + + server.route({ + method: "GET", + url: "/me/project-favorites", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + orgId: z.string().trim() + }), + response: { + 200: z.object({ + projectFavorites: z.string().array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + return server.services.user.getUserProjectFavorites(req.permission.id, req.query.orgId); + } + }); + + server.route({ + method: "PUT", + url: "/me/project-favorites", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + orgId: z.string().trim(), + projectFavorites: z.string().array() + }) + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + return server.services.user.updateUserProjectFavorites( + req.permission.id, + req.body.orgId, + req.body.projectFavorites + ); + } + }); }; diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index 4ee8bdc1f7..91eff8c09a 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -8,6 +8,7 @@ import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; import { AuthMethod } from "../auth/auth-type"; +import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { TUserDALFactory } from "./user-dal"; type TUserServiceFactoryDep = { @@ -26,8 +27,9 @@ type TUserServiceFactoryDep = { | "delete" >; userAliasDAL: Pick; - orgMembershipDAL: Pick; + orgMembershipDAL: Pick; tokenService: Pick; + projectMembershipDAL: Pick; smtpService: Pick; }; @@ -37,6 +39,7 @@ export const userServiceFactory = ({ userDAL, userAliasDAL, orgMembershipDAL, + projectMembershipDAL, tokenService, smtpService }: TUserServiceFactoryDep) => { @@ -247,6 +250,49 @@ export const userServiceFactory = ({ return privateKey; }; + const getUserProjectFavorites = async (userId: string, orgId: string) => { + const orgMembership = await orgMembershipDAL.findOne({ + userId, + orgId + }); + + if (!orgMembership) { + throw new BadRequestError({ + message: "User does not belong in the organization." + }); + } + + return { projectFavorites: orgMembership.projectFavorites || [] }; + }; + + const updateUserProjectFavorites = async (userId: string, orgId: string, projectIds: string[]) => { + const orgMembership = await orgMembershipDAL.findOne({ + userId, + orgId + }); + + if (!orgMembership) { + throw new BadRequestError({ + message: "User does not belong in the organization." + }); + } + + const memberProjectFavorites = ( + await projectMembershipDAL.find({ + userId, + $in: { + projectId: projectIds + } + }) + ).map((projectMembership) => projectMembership.projectId); + + return ( + await orgMembershipDAL.updateById(orgMembership.id, { + projectFavorites: memberProjectFavorites + }) + ).projectFavorites; + }; + return { sendEmailVerificationCode, verifyEmailVerificationCode, @@ -258,6 +304,8 @@ export const userServiceFactory = ({ createUserAction, getUserAction, unlockUser, - getUserPrivateKey + getUserPrivateKey, + getUserProjectFavorites, + updateUserProjectFavorites }; }; diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx index 20e986aab3..8785533c5c 100644 --- a/frontend/src/hooks/api/users/mutation.tsx +++ b/frontend/src/hooks/api/users/mutation.tsx @@ -7,6 +7,7 @@ import { import { apiRequest } from "@app/config/request"; import { workspaceKeys } from "../workspace/queries"; +import { userKeys } from "./queries"; import { AddUserToWsDTOE2EE, AddUserToWsDTONonE2EE } from "./types"; export const useAddUserToWsE2EE = () => { @@ -88,3 +89,26 @@ export const useVerifyEmailVerificationCode = () => { } }); }; + +export const useUpdateUserProjectFavorites = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + orgId, + projectFavorites + }: { + orgId: string; + projectFavorites: string[]; + }) => { + await apiRequest.put("/api/v1/user/me/project-favorites", { + orgId, + projectFavorites + }); + + return {}; + }, + onSuccess: () => { + queryClient.invalidateQueries(userKeys.userProjectFavorites); + } + }); +}; diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index fa0b932eaa..4785ccc129 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -22,11 +22,13 @@ export const userKeys = { getUser: ["user"] as const, getPrivateKey: ["user"] as const, userAction: ["user-action"] as const, + userProjectFavorites: ["user-project-favorites"] as const, getOrgUsers: (orgId: string) => [{ orgId }, "user"], myIp: ["ip"] as const, myAPIKeys: ["api-keys"] as const, myAPIKeysV2: ["api-keys-v2"] as const, mySessions: ["sessions"] as const, + myOrganizationProjects: (orgId: string) => [{ orgId }, "organization-projects"] as const }; @@ -74,6 +76,14 @@ export const fetchUserAction = async (action: string) => { return data.userAction || ""; }; +export const fetchUserProjectFavorites = async (orgId: string) => { + const { data } = await apiRequest.get<{ projectFavorites: string[] }>( + `/api/v1/user/me/project-favorites?orgId=${orgId}` + ); + + return data.projectFavorites; +}; + export const useRenameUser = () => { const queryClient = useQueryClient(); @@ -122,6 +132,12 @@ export const fetchOrgUsers = async (orgId: string) => { return data.users; }; +export const useGetUserProjectFavorites = (orgId: string) => + useQuery({ + queryKey: userKeys.userProjectFavorites, + queryFn: () => fetchUserProjectFavorites(orgId) + }); + export const useGetOrgUsers = (orgId: string) => useQuery({ queryKey: userKeys.getOrgUsers(orgId), diff --git a/frontend/src/pages/org/[id]/overview/index.tsx b/frontend/src/pages/org/[id]/overview/index.tsx index 177a6586b1..5ff425c37e 100644 --- a/frontend/src/pages/org/[id]/overview/index.tsx +++ b/frontend/src/pages/org/[id]/overview/index.tsx @@ -1,6 +1,6 @@ // REFACTOR(akhilmhdh): This file needs to be split into multiple components too complex -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import Head from "next/head"; @@ -8,7 +8,7 @@ import Link from "next/link"; import { useRouter } from "next/router"; import { IconProp } from "@fortawesome/fontawesome-svg-core"; import { faSlack } from "@fortawesome/free-brands-svg-icons"; -import { faFolderOpen } from "@fortawesome/free-regular-svg-icons"; +import { faFolderOpen, faStar } from "@fortawesome/free-regular-svg-icons"; import { faArrowRight, faArrowUpRightFromSquare, @@ -24,6 +24,7 @@ import { faNetworkWired, faPlug, faPlus, + faStar as faSolidStar, faUserPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -62,6 +63,9 @@ import { } from "@app/hooks/api"; // import { fetchUserWsKey } from "@app/hooks/api/keys/queries"; import { useFetchServerStatus } from "@app/hooks/api/serverDetails"; +import { Workspace } from "@app/hooks/api/types"; +import { useUpdateUserProjectFavorites } from "@app/hooks/api/users/mutation"; +import { useGetUserProjectFavorites } from "@app/hooks/api/users/queries"; import { usePopUp } from "@app/hooks/usePopUp"; const features = [ @@ -485,6 +489,8 @@ const OrganizationPage = withPermission( const { currentOrg } = useOrganization(); const routerOrgId = String(router.query.id); const orgWorkspaces = workspaces?.filter((workspace) => workspace.orgId === routerOrgId) || []; + const { data: projectFavorites } = useGetUserProjectFavorites(currentOrg?.id!); + const { mutateAsync: updateUserProjectFavorites } = useUpdateUserProjectFavorites(); const addUsersToProject = useAddUserToWsNonE2EE(); @@ -569,52 +575,117 @@ const OrganizationPage = withPermission( const filteredWorkspaces = orgWorkspaces.filter((ws) => ws?.name?.toLowerCase().includes(searchFilter.toLowerCase()) ); + const favoriteWorkspaces = filteredWorkspaces.filter((ws) => projectFavorites?.includes(ws.id)); + const nonFavoriteWorkspaces = filteredWorkspaces.filter((ws) => + favoriteWorkspaces.every((entry) => entry.id !== ws.id) + ); + + const addProjectToFavorites = useCallback( + (projectId: string) => { + if (currentOrg?.id) { + updateUserProjectFavorites({ + orgId: currentOrg?.id, + projectFavorites: [...(projectFavorites || []), projectId] + }); + } + }, + [currentOrg, projectFavorites] + ); + + const removeProjectFromFavorites = useCallback( + (projectId: string) => { + if (currentOrg?.id) { + updateUserProjectFavorites({ + orgId: currentOrg?.id, + projectFavorites: [...(projectFavorites || []).filter((entry) => entry !== projectId)] + }); + } + }, + [currentOrg, projectFavorites] + ); + + const renderProjectGridItem = (workspace: Workspace, isFavorite: boolean) => ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events +
{ + router.push(`/project/${workspace.id}/secrets/overview`); + localStorage.setItem("projectData.id", workspace.id); + }} + key={workspace.id} + className="min-w-72 flex h-40 cursor-pointer flex-col justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4" + > +
+
{workspace.name}
+ {isFavorite ? ( + { + e.stopPropagation(); + removeProjectFromFavorites(workspace.id); + }} + /> + ) : ( + { + e.stopPropagation(); + addProjectToFavorites(workspace.id); + }} + /> + )} +
+
+ {workspace.environments?.length || 0} environments +
+ +
+ ); const projectsGridView = ( -
- {isWorkspaceLoading && - Array.apply(0, Array(3)).map((_x, i) => ( + <> + {favoriteWorkspaces.length > 0 && ( + <> +

Favorites

0 && "border-b border-mineshaft-600" + } py-4 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4`} > -
- -
-
- -
-
- -
+ {favoriteWorkspaces.map((workspace) => renderProjectGridItem(workspace, true))}
- ))} - {filteredWorkspaces.map((workspace) => ( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -
{ - router.push(`/project/${workspace.id}/secrets/overview`); - localStorage.setItem("projectData.id", workspace.id); - }} - key={workspace.id} - className="min-w-72 group flex h-40 cursor-pointer flex-col justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4" - > -
{workspace.name}
-
- {workspace.environments?.length || 0} environments -
- -
- ))} -
+ ))} + {nonFavoriteWorkspaces.map((workspace) => renderProjectGridItem(workspace, false))} + + ); const projectsListView = ( From 5a01edae7a835996fbc38bee9a693131a1fda8d2 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Sat, 29 Jun 2024 01:02:28 +0800 Subject: [PATCH 02/10] misc: added favorites to app layout selection --- frontend/src/layouts/AppLayout/AppLayout.tsx | 81 ++++++++++++++++--- .../src/pages/org/[id]/overview/index.tsx | 77 ++++++++++++------ 2 files changed, 123 insertions(+), 35 deletions(-) diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 72351df88e..7fc10df131 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -5,13 +5,14 @@ /* eslint-disable no-var */ /* eslint-disable func-names */ -import { useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/router"; import { faGithub, faSlack } from "@fortawesome/free-brands-svg-icons"; +import { faStar } from "@fortawesome/free-regular-svg-icons"; import { faAngleDown, faArrowLeft, @@ -23,7 +24,8 @@ import { faInfo, faMobile, faPlus, - faQuestion + faQuestion, + faStar as faSolidStar } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { yupResolver } from "@hookform/resolvers/yup"; @@ -72,6 +74,8 @@ import { useRegisterUserAction, useSelectOrganization } from "@app/hooks/api"; +import { useUpdateUserProjectFavorites } from "@app/hooks/api/users/mutation"; +import { useGetUserProjectFavorites } from "@app/hooks/api/users/queries"; import { navigateUserToOrg } from "@app/views/Login/Login.utils"; import { CreateOrgModal } from "@app/views/Org/components"; @@ -122,6 +126,11 @@ export const AppLayout = ({ children }: LayoutProps) => { const { workspaces, currentWorkspace } = useWorkspace(); const { orgs, currentOrg } = useOrganization(); + const { data: projectFavorites } = useGetUserProjectFavorites(currentOrg?.id!); + const { mutateAsync: updateUserProjectFavorites } = useUpdateUserProjectFavorites(); + const nonFavoriteWorkspaces = workspaces.filter((w) => !projectFavorites?.includes(w.id)); + const favoriteWorkspaces = workspaces.filter((w) => projectFavorites?.includes(w.id)); + const { user } = useUser(); const { subscription } = useSubscription(); const workspaceId = currentWorkspace?.id || ""; @@ -271,6 +280,30 @@ export const AppLayout = ({ children }: LayoutProps) => { } }; + const addProjectToFavorites = useCallback( + (projectId: string) => { + if (currentOrg?.id) { + updateUserProjectFavorites({ + orgId: currentOrg?.id, + projectFavorites: [...(projectFavorites || []), projectId] + }); + } + }, + [currentOrg, projectFavorites] + ); + + const removeProjectFromFavorites = useCallback( + (projectId: string) => { + if (currentOrg?.id) { + updateUserProjectFavorites({ + orgId: currentOrg?.id, + projectFavorites: [...(projectFavorites || []).filter((entry) => entry !== projectId)] + }); + } + }, + [currentOrg, projectFavorites] + ); + return ( <>
@@ -451,19 +484,47 @@ export const AppLayout = ({ children }: LayoutProps) => { dropdownContainerClassName="text-bunker-200 bg-mineshaft-800 border border-mineshaft-600 z-50 max-h-96 border-gray-700" >
- {workspaces + {[...favoriteWorkspaces, ...nonFavoriteWorkspaces] .filter((ws) => ws.orgId === currentOrg?.id) .map(({ id, name }) => ( - - {name} - +
+ + {name} + +
+
+ {projectFavorites?.includes(id) ? ( + { + e.stopPropagation(); + removeProjectFromFavorites(id); + }} + /> + ) : ( + { + e.stopPropagation(); + addProjectToFavorites(id); + }} + /> + )} +
+
))}

diff --git a/frontend/src/pages/org/[id]/overview/index.tsx b/frontend/src/pages/org/[id]/overview/index.tsx index 5ff425c37e..14fd6b5b09 100644 --- a/frontend/src/pages/org/[id]/overview/index.tsx +++ b/frontend/src/pages/org/[id]/overview/index.tsx @@ -619,7 +619,7 @@ const OrganizationPage = withPermission( {isFavorite ? ( { e.stopPropagation(); removeProjectFromFavorites(workspace.id); @@ -628,7 +628,7 @@ const OrganizationPage = withPermission( ) : ( { e.stopPropagation(); addProjectToFavorites(workspace.id); @@ -651,6 +651,49 @@ const OrganizationPage = withPermission( ); + const renderProjectListItem = (workspace: Workspace, isFavorite: boolean, index: number) => ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events +
{ + router.push(`/project/${workspace.id}/secrets/overview`); + localStorage.setItem("projectData.id", workspace.id); + }} + key={workspace.id} + className={`min-w-72 group grid h-14 cursor-pointer grid-cols-6 border-t border-l border-r border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700 ${ + index === 0 && "rounded-t-md" + } ${index === filteredWorkspaces.length - 1 && "rounded-b-md border-b"}`} + > +
+ +
{workspace.name}
+
+
+
+ {workspace.environments?.length || 0} environments +
+ {isFavorite ? ( + { + e.stopPropagation(); + removeProjectFromFavorites(workspace.id); + }} + /> + ) : ( + { + e.stopPropagation(); + addProjectToFavorites(workspace.id); + }} + /> + )} +
+
+ ); + const projectsGridView = ( <> {favoriteWorkspaces.length > 0 && ( @@ -701,29 +744,13 @@ const OrganizationPage = withPermission( ))} - {filteredWorkspaces.map((workspace, ind) => ( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -
{ - router.push(`/project/${workspace.id}/secrets/overview`); - localStorage.setItem("projectData.id", workspace.id); - }} - key={workspace.id} - className={`min-w-72 group grid h-14 cursor-pointer grid-cols-6 border-t border-l border-r border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700 ${ - ind === 0 && "rounded-t-md" - } ${ind === filteredWorkspaces.length - 1 && "rounded-b-md border-b"}`} - > -
- -
{workspace.name}
-
-
-
- {workspace.environments?.length || 0} environments -
-
-
- ))} + {[...favoriteWorkspaces, ...nonFavoriteWorkspaces].map((workspace, ind) => + renderProjectListItem( + workspace, + favoriteWorkspaces.some((w) => w.id === workspace.id), + ind + ) + )} ); From 758a9211abbd46cb7bfc799c91b46af779c7addf Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Mon, 1 Jul 2024 13:11:47 +0800 Subject: [PATCH 03/10] misc: addressed pr comments --- frontend/src/layouts/AppLayout/AppLayout.tsx | 46 +++++++---- .../src/pages/org/[id]/overview/index.tsx | 80 ++++++++++++------- 2 files changed, 84 insertions(+), 42 deletions(-) diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 7fc10df131..816665a467 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -5,7 +5,7 @@ /* eslint-disable no-var */ /* eslint-disable func-names */ -import { useCallback, useEffect, useMemo } from "react"; +import { useEffect, useMemo } from "react"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import Image from "next/image"; @@ -128,8 +128,16 @@ export const AppLayout = ({ children }: LayoutProps) => { const { data: projectFavorites } = useGetUserProjectFavorites(currentOrg?.id!); const { mutateAsync: updateUserProjectFavorites } = useUpdateUserProjectFavorites(); - const nonFavoriteWorkspaces = workspaces.filter((w) => !projectFavorites?.includes(w.id)); - const favoriteWorkspaces = workspaces.filter((w) => projectFavorites?.includes(w.id)); + + const nonFavoriteWorkspaces = useMemo( + () => workspaces.filter((w) => !projectFavorites?.includes(w.id)), + [workspaces, projectFavorites] + ); + + const favoriteWorkspaces = useMemo( + () => workspaces.filter((w) => projectFavorites?.includes(w.id)), + [workspaces, projectFavorites] + ); const { user } = useUser(); const { subscription } = useSubscription(); @@ -280,29 +288,37 @@ export const AppLayout = ({ children }: LayoutProps) => { } }; - const addProjectToFavorites = useCallback( - (projectId: string) => { + const addProjectToFavorites = async (projectId: string) => { + try { if (currentOrg?.id) { - updateUserProjectFavorites({ + await updateUserProjectFavorites({ orgId: currentOrg?.id, projectFavorites: [...(projectFavorites || []), projectId] }); } - }, - [currentOrg, projectFavorites] - ); + } catch (err) { + createNotification({ + text: "Failed to add project to favorites.", + type: "error" + }); + } + }; - const removeProjectFromFavorites = useCallback( - (projectId: string) => { + const removeProjectFromFavorites = async (projectId: string) => { + try { if (currentOrg?.id) { - updateUserProjectFavorites({ + await updateUserProjectFavorites({ orgId: currentOrg?.id, projectFavorites: [...(projectFavorites || []).filter((entry) => entry !== projectId)] }); } - }, - [currentOrg, projectFavorites] - ); + } catch (err) { + createNotification({ + text: "Failed to remove project from favorites.", + type: "error" + }); + } + }; return ( <> diff --git a/frontend/src/pages/org/[id]/overview/index.tsx b/frontend/src/pages/org/[id]/overview/index.tsx index 14fd6b5b09..2a9a6c5551 100644 --- a/frontend/src/pages/org/[id]/overview/index.tsx +++ b/frontend/src/pages/org/[id]/overview/index.tsx @@ -1,6 +1,6 @@ // REFACTOR(akhilmhdh): This file needs to be split into multiple components too complex -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import Head from "next/head"; @@ -489,7 +489,8 @@ const OrganizationPage = withPermission( const { currentOrg } = useOrganization(); const routerOrgId = String(router.query.id); const orgWorkspaces = workspaces?.filter((workspace) => workspace.orgId === routerOrgId) || []; - const { data: projectFavorites } = useGetUserProjectFavorites(currentOrg?.id!); + const { data: projectFavorites, isLoading: isProjectFavoritesLoading } = + useGetUserProjectFavorites(currentOrg?.id!); const { mutateAsync: updateUserProjectFavorites } = useUpdateUserProjectFavorites(); const addUsersToProject = useAddUserToWsNonE2EE(); @@ -575,34 +576,59 @@ const OrganizationPage = withPermission( const filteredWorkspaces = orgWorkspaces.filter((ws) => ws?.name?.toLowerCase().includes(searchFilter.toLowerCase()) ); - const favoriteWorkspaces = filteredWorkspaces.filter((ws) => projectFavorites?.includes(ws.id)); - const nonFavoriteWorkspaces = filteredWorkspaces.filter((ws) => - favoriteWorkspaces.every((entry) => entry.id !== ws.id) + + const workspacesWithFaveProp = useMemo( + () => + filteredWorkspaces + .map((w): Workspace & { isFavorite: boolean } => ({ + ...w, + isFavorite: Boolean(projectFavorites?.includes(w.id)) + })) + .sort((a, b) => Number(b.isFavorite) - Number(a.isFavorite)), + [filteredWorkspaces, projectFavorites] + ); + + const favoriteWorkspaces = useMemo( + () => workspacesWithFaveProp.filter((w) => w.isFavorite), + [workspacesWithFaveProp] + ); + + const nonFavoriteWorkspaces = useMemo( + () => workspacesWithFaveProp.filter((w) => !w.isFavorite), + [workspacesWithFaveProp] ); - const addProjectToFavorites = useCallback( - (projectId: string) => { + const addProjectToFavorites = async (projectId: string) => { + try { if (currentOrg?.id) { - updateUserProjectFavorites({ + await updateUserProjectFavorites({ orgId: currentOrg?.id, projectFavorites: [...(projectFavorites || []), projectId] }); } - }, - [currentOrg, projectFavorites] - ); + } catch (err) { + createNotification({ + text: "Failed to add project to favorites.", + type: "error" + }); + } + }; - const removeProjectFromFavorites = useCallback( - (projectId: string) => { + const removeProjectFromFavorites = async (projectId: string) => { + try { if (currentOrg?.id) { - updateUserProjectFavorites({ + await updateUserProjectFavorites({ orgId: currentOrg?.id, projectFavorites: [...(projectFavorites || []).filter((entry) => entry !== projectId)] }); } - }, - [currentOrg, projectFavorites] - ); + } catch (err) { + createNotification({ + text: "Failed to remove project from favorites.", + type: "error" + }); + } + }; const renderProjectGridItem = (workspace: Workspace, isFavorite: boolean) => ( // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events @@ -709,7 +735,7 @@ const OrganizationPage = withPermission( )}
- {isWorkspaceLoading && + {(isWorkspaceLoading || isProjectFavoritesLoading) && Array.apply(0, Array(3)).map((_x, i) => (
))} - {nonFavoriteWorkspaces.map((workspace) => renderProjectGridItem(workspace, false))} + {!isProjectFavoritesLoading && + !isWorkspaceLoading && + nonFavoriteWorkspaces.map((workspace) => renderProjectGridItem(workspace, false))}
); const projectsListView = (
- {isWorkspaceLoading && + {(isWorkspaceLoading || isProjectFavoritesLoading) && Array.apply(0, Array(3)).map((_x, i) => (
))} - {[...favoriteWorkspaces, ...nonFavoriteWorkspaces].map((workspace, ind) => - renderProjectListItem( - workspace, - favoriteWorkspaces.some((w) => w.id === workspace.id), - ind - ) - )} + {!isProjectFavoritesLoading && + !isWorkspaceLoading && + workspacesWithFaveProp.map((workspace, ind) => + renderProjectListItem(workspace, workspace.isFavorite, ind) + )}
); From f570b3b2ee7320e2904718376867bead373a12c5 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Mon, 1 Jul 2024 18:23:38 +0800 Subject: [PATCH 04/10] misc: combined into one list --- frontend/src/layouts/AppLayout/AppLayout.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 816665a467..70f3656d26 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -129,13 +129,11 @@ export const AppLayout = ({ children }: LayoutProps) => { const { data: projectFavorites } = useGetUserProjectFavorites(currentOrg?.id!); const { mutateAsync: updateUserProjectFavorites } = useUpdateUserProjectFavorites(); - const nonFavoriteWorkspaces = useMemo( - () => workspaces.filter((w) => !projectFavorites?.includes(w.id)), - [workspaces, projectFavorites] - ); - - const favoriteWorkspaces = useMemo( - () => workspaces.filter((w) => projectFavorites?.includes(w.id)), + const workspaceList = useMemo( + () => [ + ...workspaces.filter((w) => projectFavorites?.includes(w.id)), + ...workspaces.filter((w) => !projectFavorites?.includes(w.id)) + ], [workspaces, projectFavorites] ); @@ -500,7 +498,7 @@ export const AppLayout = ({ children }: LayoutProps) => { dropdownContainerClassName="text-bunker-200 bg-mineshaft-800 border border-mineshaft-600 z-50 max-h-96 border-gray-700" >
- {[...favoriteWorkspaces, ...nonFavoriteWorkspaces] + {workspaceList .filter((ws) => ws.orgId === currentOrg?.id) .map(({ id, name }) => (
Date: Mon, 1 Jul 2024 18:29:56 +0800 Subject: [PATCH 05/10] misc: removed use memo --- frontend/src/pages/org/[id]/overview/index.tsx | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/frontend/src/pages/org/[id]/overview/index.tsx b/frontend/src/pages/org/[id]/overview/index.tsx index 2a9a6c5551..5090eef164 100644 --- a/frontend/src/pages/org/[id]/overview/index.tsx +++ b/frontend/src/pages/org/[id]/overview/index.tsx @@ -588,15 +588,8 @@ const OrganizationPage = withPermission( [filteredWorkspaces, projectFavorites] ); - const favoriteWorkspaces = useMemo( - () => workspacesWithFaveProp.filter((w) => w.isFavorite), - [workspacesWithFaveProp] - ); - - const nonFavoriteWorkspaces = useMemo( - () => workspacesWithFaveProp.filter((w) => !w.isFavorite), - [workspacesWithFaveProp] - ); + const favoriteWorkspaces = workspacesWithFaveProp.filter((w) => w.isFavorite); + const nonFavoriteWorkspaces = workspacesWithFaveProp.filter((w) => !w.isFavorite); const addProjectToFavorites = async (projectId: string) => { try { From 1eb9ea9c74535e2605fd5f5237baba0527f8bb4b Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Mon, 1 Jul 2024 20:10:41 +0800 Subject: [PATCH 06/10] misc: implemened more review comments --- frontend/src/layouts/AppLayout/AppLayout.tsx | 20 ++++++---- .../src/pages/org/[id]/overview/index.tsx | 39 ++++++++++--------- 2 files changed, 33 insertions(+), 26 deletions(-) diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 70f3656d26..acf899a7ee 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -74,6 +74,7 @@ import { useRegisterUserAction, useSelectOrganization } from "@app/hooks/api"; +import { Workspace } from "@app/hooks/api/types"; import { useUpdateUserProjectFavorites } from "@app/hooks/api/users/mutation"; import { useGetUserProjectFavorites } from "@app/hooks/api/users/queries"; import { navigateUserToOrg } from "@app/views/Login/Login.utils"; @@ -129,11 +130,14 @@ export const AppLayout = ({ children }: LayoutProps) => { const { data: projectFavorites } = useGetUserProjectFavorites(currentOrg?.id!); const { mutateAsync: updateUserProjectFavorites } = useUpdateUserProjectFavorites(); - const workspaceList = useMemo( - () => [ - ...workspaces.filter((w) => projectFavorites?.includes(w.id)), - ...workspaces.filter((w) => !projectFavorites?.includes(w.id)) - ], + const workspacesWithFaveProp = useMemo( + () => + workspaces + .map((w): Workspace & { isFavorite: boolean } => ({ + ...w, + isFavorite: Boolean(projectFavorites?.includes(w.id)) + })) + .sort((a, b) => Number(b.isFavorite) - Number(a.isFavorite)), [workspaces, projectFavorites] ); @@ -498,9 +502,9 @@ export const AppLayout = ({ children }: LayoutProps) => { dropdownContainerClassName="text-bunker-200 bg-mineshaft-800 border border-mineshaft-600 z-50 max-h-96 border-gray-700" >
- {workspaceList + {workspacesWithFaveProp .filter((ws) => ws.orgId === currentOrg?.id) - .map(({ id, name }) => ( + .map(({ id, name, isFavorite }) => (
{
- {projectFavorites?.includes(id) ? ( + {isFavorite ? ( - filteredWorkspaces - .map((w): Workspace & { isFavorite: boolean } => ({ - ...w, - isFavorite: Boolean(projectFavorites?.includes(w.id)) - })) - .sort((a, b) => Number(b.isFavorite) - Number(a.isFavorite)), - [filteredWorkspaces, projectFavorites] - ); + const { workspacesWithFaveProp, favoriteWorkspaces, nonFavoriteWorkspaces } = useMemo(() => { + const workspacesWithFav = filteredWorkspaces + .map((w): Workspace & { isFavorite: boolean } => ({ + ...w, + isFavorite: Boolean(projectFavorites?.includes(w.id)) + })) + .sort((a, b) => Number(b.isFavorite) - Number(a.isFavorite)); + + const favWorkspaces = workspacesWithFav.filter((w) => w.isFavorite); + const nonFavWorkspaces = workspacesWithFav.filter((w) => !w.isFavorite); - const favoriteWorkspaces = workspacesWithFaveProp.filter((w) => w.isFavorite); - const nonFavoriteWorkspaces = workspacesWithFaveProp.filter((w) => !w.isFavorite); + return { + workspacesWithFaveProp: workspacesWithFav, + favoriteWorkspaces: favWorkspaces, + nonFavoriteWorkspaces: nonFavWorkspaces + }; + }, [filteredWorkspaces, projectFavorites]); const addProjectToFavorites = async (projectId: string) => { try { @@ -728,7 +733,7 @@ const OrganizationPage = withPermission( )}
- {(isWorkspaceLoading || isProjectFavoritesLoading) && + {isProjectViewLoading && Array.apply(0, Array(3)).map((_x, i) => (
))} - {!isProjectFavoritesLoading && - !isWorkspaceLoading && + {!isProjectViewLoading && nonFavoriteWorkspaces.map((workspace) => renderProjectGridItem(workspace, false))}
@@ -754,7 +758,7 @@ const OrganizationPage = withPermission( const projectsListView = (
- {(isWorkspaceLoading || isProjectFavoritesLoading) && + {isProjectViewLoading && Array.apply(0, Array(3)).map((_x, i) => (
))} - {!isProjectFavoritesLoading && - !isWorkspaceLoading && + {!isProjectViewLoading && workspacesWithFaveProp.map((workspace, ind) => renderProjectListItem(workspace, workspace.isFavorite, ind) )} From c976a5ccba75a56779a02a2253861260ffe72169 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Mon, 1 Jul 2024 20:20:15 +0800 Subject: [PATCH 07/10] misc: add scoping to org-level --- frontend/src/hooks/api/users/queries.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index 4785ccc129..1a49e8d7c5 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -22,7 +22,7 @@ export const userKeys = { getUser: ["user"] as const, getPrivateKey: ["user"] as const, userAction: ["user-action"] as const, - userProjectFavorites: ["user-project-favorites"] as const, + userProjectFavorites: (orgId: string) => [{ orgId }, "user-project-favorites"] as const, getOrgUsers: (orgId: string) => [{ orgId }, "user"], myIp: ["ip"] as const, myAPIKeys: ["api-keys"] as const, @@ -134,7 +134,7 @@ export const fetchOrgUsers = async (orgId: string) => { export const useGetUserProjectFavorites = (orgId: string) => useQuery({ - queryKey: userKeys.userProjectFavorites, + queryKey: userKeys.userProjectFavorites(orgId), queryFn: () => fetchUserProjectFavorites(orgId) }); From 46abda9041fe18779f6b3b1335ec77abae052317 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Mon, 1 Jul 2024 20:22:59 +0800 Subject: [PATCH 08/10] misc: add org scoping to mutation --- frontend/src/hooks/api/users/mutation.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx index 8785533c5c..26e932ac6d 100644 --- a/frontend/src/hooks/api/users/mutation.tsx +++ b/frontend/src/hooks/api/users/mutation.tsx @@ -107,8 +107,8 @@ export const useUpdateUserProjectFavorites = () => { return {}; }, - onSuccess: () => { - queryClient.invalidateQueries(userKeys.userProjectFavorites); + onSuccess: (_, { orgId }) => { + queryClient.invalidateQueries(userKeys.userProjectFavorites(orgId)); } }); }; From 9cbf9a675a68806012c509186bc06ab0e032d944 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Mon, 1 Jul 2024 22:22:44 +0800 Subject: [PATCH 09/10] misc: simplified update project favorites logic --- backend/src/services/user/user-service.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index 91eff8c09a..f6d17b38b4 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -286,11 +286,11 @@ export const userServiceFactory = ({ }) ).map((projectMembership) => projectMembership.projectId); - return ( - await orgMembershipDAL.updateById(orgMembership.id, { - projectFavorites: memberProjectFavorites - }) - ).projectFavorites; + const updatedOrgMembership = await orgMembershipDAL.updateById(orgMembership.id, { + projectFavorites: memberProjectFavorites + }); + + return updatedOrgMembership.projectFavorites; }; return { From 06a4e68ac15d2e75f047ea698e2dce2f32eb4df2 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Mon, 1 Jul 2024 22:33:01 +0800 Subject: [PATCH 10/10] misc: more improvements --- backend/src/services/user/user-service.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index f6d17b38b4..5f01066044 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -277,14 +277,16 @@ export const userServiceFactory = ({ }); } - const memberProjectFavorites = ( - await projectMembershipDAL.find({ - userId, - $in: { - projectId: projectIds - } - }) - ).map((projectMembership) => projectMembership.projectId); + const matchingUserProjectMemberships = await projectMembershipDAL.find({ + userId, + $in: { + projectId: projectIds + } + }); + + const memberProjectFavorites = matchingUserProjectMemberships.map( + (projectMembership) => projectMembership.projectId + ); const updatedOrgMembership = await orgMembershipDAL.updateById(orgMembership.id, { projectFavorites: memberProjectFavorites