mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-03 10:26:00 +00:00
feat: updated ui for request access feature
This commit is contained in:
@@ -9,12 +9,12 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const PageHeader = ({ title, description, children, className }: Props) => (
|
export const PageHeader = ({ title, description, children, className }: Props) => (
|
||||||
<div className={twMerge("mb-4", className)}>
|
<div className={twMerge("mb-4 w-full", className)}>
|
||||||
<div className="flex w-full justify-between">
|
<div className="flex w-full justify-between">
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<h1 className="mr-4 text-3xl font-semibold capitalize text-white">{title}</h1>
|
<h1 className="mr-4 text-3xl font-semibold capitalize text-white">{title}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div>{children}</div>
|
<div className="flex items-center">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-gray-400">{description}</div>
|
<div className="mt-2 text-gray-400">{description}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export {
|
|||||||
useDeleteGroupFromWorkspace,
|
useDeleteGroupFromWorkspace,
|
||||||
useLeaveProject,
|
useLeaveProject,
|
||||||
useMigrateProjectToV3,
|
useMigrateProjectToV3,
|
||||||
|
useRequestProjectAccess,
|
||||||
useUpdateGroupWorkspaceRole
|
useUpdateGroupWorkspaceRole
|
||||||
} from "./mutations";
|
} from "./mutations";
|
||||||
export {
|
export {
|
||||||
@@ -36,6 +37,7 @@ export {
|
|||||||
useListWorkspaceSshCertificates,
|
useListWorkspaceSshCertificates,
|
||||||
useListWorkspaceSshCertificateTemplates,
|
useListWorkspaceSshCertificateTemplates,
|
||||||
useNameWorkspaceSecrets,
|
useNameWorkspaceSecrets,
|
||||||
|
useSearchProjects,
|
||||||
useToggleAutoCapitalization,
|
useToggleAutoCapitalization,
|
||||||
useUpdateIdentityWorkspaceRole,
|
useUpdateIdentityWorkspaceRole,
|
||||||
useUpdateProject,
|
useUpdateProject,
|
||||||
|
|||||||
@@ -107,3 +107,13 @@ export const useMigrateProjectToV3 = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useRequestProjectAccess = () => {
|
||||||
|
return useMutation<object, object, { projectId: string; comment: string }>({
|
||||||
|
mutationFn: ({ projectId, comment }) => {
|
||||||
|
return apiRequest.post(`/api/v1/workspace/${projectId}/project-access`, {
|
||||||
|
comment
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
TGetUpgradeProjectStatusDTO,
|
TGetUpgradeProjectStatusDTO,
|
||||||
TListProjectIdentitiesDTO,
|
TListProjectIdentitiesDTO,
|
||||||
ToggleAutoCapitalizationDTO,
|
ToggleAutoCapitalizationDTO,
|
||||||
|
TSearchProjectsDTO,
|
||||||
TUpdateWorkspaceIdentityRoleDTO,
|
TUpdateWorkspaceIdentityRoleDTO,
|
||||||
TUpdateWorkspaceUserRoleDTO,
|
TUpdateWorkspaceUserRoleDTO,
|
||||||
UpdateAuditLogsRetentionDTO,
|
UpdateAuditLogsRetentionDTO,
|
||||||
@@ -145,14 +146,31 @@ export const useGetWorkspaceById = (
|
|||||||
|
|
||||||
export const useGetUserWorkspaces = ({
|
export const useGetUserWorkspaces = ({
|
||||||
includeRoles,
|
includeRoles,
|
||||||
type = "all"
|
type = "all",
|
||||||
|
options = {}
|
||||||
}: {
|
}: {
|
||||||
includeRoles?: boolean;
|
includeRoles?: boolean;
|
||||||
type?: ProjectType | "all";
|
type?: ProjectType | "all";
|
||||||
|
options?: { enabled?: boolean };
|
||||||
} = {}) =>
|
} = {}) =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: workspaceKeys.getAllUserWorkspace(type),
|
queryKey: workspaceKeys.getAllUserWorkspace(type),
|
||||||
queryFn: () => fetchUserWorkspaces(includeRoles, type)
|
queryFn: () => fetchUserWorkspaces(includeRoles, type),
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useSearchProjects = ({ options, ...dto }: TSearchProjectsDTO) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: workspaceKeys.searchWorkspace(dto),
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await apiRequest.post<{
|
||||||
|
projects: (Workspace & { isMember: boolean })[];
|
||||||
|
totalCount: number;
|
||||||
|
}>("/api/v1/workspace/search", dto);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
...options
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchUserWorkspaceMemberships = async (orgId: string) => {
|
const fetchUserWorkspaceMemberships = async (orgId: string) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TListProjectIdentitiesDTO } from "@app/hooks/api/workspace/types";
|
import { TListProjectIdentitiesDTO, TSearchProjectsDTO } from "@app/hooks/api/workspace/types";
|
||||||
|
|
||||||
import type { CaStatus } from "../ca";
|
import type { CaStatus } from "../ca";
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ export const workspaceKeys = {
|
|||||||
...params
|
...params
|
||||||
}: TListProjectIdentitiesDTO) =>
|
}: TListProjectIdentitiesDTO) =>
|
||||||
[...workspaceKeys.getWorkspaceIdentityMemberships(workspaceId), params] as const,
|
[...workspaceKeys.getWorkspaceIdentityMemberships(workspaceId), params] as const,
|
||||||
|
searchWorkspace: (dto: TSearchProjectsDTO) => ["search-projects", dto] as const,
|
||||||
getWorkspaceGroupMemberships: (workspaceId: string) =>
|
getWorkspaceGroupMemberships: (workspaceId: string) =>
|
||||||
[{ workspaceId }, "workspace-groups"] as const,
|
[{ workspaceId }, "workspace-groups"] as const,
|
||||||
getWorkspaceCas: ({ projectSlug }: { projectSlug: string }) =>
|
getWorkspaceCas: ({ projectSlug }: { projectSlug: string }) =>
|
||||||
|
|||||||
@@ -173,3 +173,12 @@ export type TListProjectIdentitiesDTO = {
|
|||||||
export enum ProjectIdentityOrderBy {
|
export enum ProjectIdentityOrderBy {
|
||||||
Name = "name"
|
Name = "name"
|
||||||
}
|
}
|
||||||
|
export type TSearchProjectsDTO = {
|
||||||
|
type?: ProjectType;
|
||||||
|
name?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
options?: { enabled?: boolean };
|
||||||
|
orderBy?: ProjectIdentityOrderBy;
|
||||||
|
orderDirection?: OrderByDirection;
|
||||||
|
};
|
||||||
|
|||||||
+14
-12
@@ -14,6 +14,7 @@ import {
|
|||||||
faPlug,
|
faPlug,
|
||||||
faSignOut,
|
faSignOut,
|
||||||
faUser,
|
faUser,
|
||||||
|
faUserCog,
|
||||||
faUsers
|
faUsers
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
@@ -385,6 +386,19 @@ export const MinimizedOrgSidebar = () => {
|
|||||||
Organization Settings
|
Organization Settings
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</Link>
|
</Link>
|
||||||
|
<DropdownMenuLabel>Admin Panels</DropdownMenuLabel>
|
||||||
|
{user?.superAdmin && (
|
||||||
|
<Link to="/admin">
|
||||||
|
<DropdownMenuItem icon={<FontAwesomeIcon className="w-3" icon={faUserCog} />}>
|
||||||
|
Server Admin Console
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<Link to="/organization/admin">
|
||||||
|
<DropdownMenuItem icon={<FontAwesomeIcon className="w-3" icon={faCog} />}>
|
||||||
|
Organization Admin Console
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</Link>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
@@ -541,18 +555,6 @@ export const MinimizedOrgSidebar = () => {
|
|||||||
/>
|
/>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</a>
|
</a>
|
||||||
{user?.superAdmin && (
|
|
||||||
<Link to="/admin">
|
|
||||||
<DropdownMenuItem className="mt-1 border-t border-mineshaft-600">
|
|
||||||
Server Admin Console
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
<div className="mt-1 border-t border-mineshaft-600 pt-1">
|
|
||||||
<Link to="/organization/admin">
|
|
||||||
<DropdownMenuItem>Organization Admin Console</DropdownMenuItem>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 h-1 border-t border-mineshaft-600" />
|
<div className="mt-1 h-1 border-t border-mineshaft-600" />
|
||||||
<DropdownMenuItem onClick={logOutUser} icon={<FontAwesomeIcon icon={faSignOut} />}>
|
<DropdownMenuItem onClick={logOutUser} icon={<FontAwesomeIcon icon={faSignOut} />}>
|
||||||
Log Out
|
Log Out
|
||||||
|
|||||||
@@ -91,7 +91,8 @@ export const AccessManagementPage = () => {
|
|||||||
<p className="mb-2 mt-1 text-sm text-bunker-300">
|
<p className="mb-2 mt-1 text-sm text-bunker-300">
|
||||||
We've developed an improved privilege management system to better serve your
|
We've developed an improved privilege management system to better serve your
|
||||||
security needs. Upgrade to our new permission-based approach that allows you to
|
security needs. Upgrade to our new permission-based approach that allows you to
|
||||||
explicitly designate who can modify specific access levels, rather than relying on hierarchy comparisons.
|
explicitly designate who can modify specific access levels, rather than relying on
|
||||||
|
hierarchy comparisons.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
colorSchema="primary"
|
colorSchema="primary"
|
||||||
|
|||||||
+16
-413
@@ -1,62 +1,22 @@
|
|||||||
// REFACTOR(akhilmhdh): This file needs to be split into multiple components too complex
|
// REFACTOR(akhilmhdh): This file needs to be split into multiple components too complex
|
||||||
import { ReactNode, useMemo, useState } from "react";
|
import { useState } from "react";
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { faFolderOpen, faStar } from "@fortawesome/free-regular-svg-icons";
|
import { faExclamationCircle, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||||
import {
|
|
||||||
faArrowDownAZ,
|
|
||||||
faArrowRight,
|
|
||||||
faArrowUpZA,
|
|
||||||
faBorderAll,
|
|
||||||
faExclamationCircle,
|
|
||||||
faList,
|
|
||||||
faMagnifyingGlass,
|
|
||||||
faPlus,
|
|
||||||
faSearch,
|
|
||||||
faStar as faSolidStar
|
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
|
||||||
import { createNotification } from "@app/components/notifications";
|
|
||||||
import { OrgPermissionCan } from "@app/components/permissions";
|
import { OrgPermissionCan } from "@app/components/permissions";
|
||||||
import { NewProjectModal } from "@app/components/projects";
|
import { NewProjectModal } from "@app/components/projects";
|
||||||
import {
|
import { Button, PageHeader } from "@app/components/v2";
|
||||||
Button,
|
import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context";
|
||||||
IconButton,
|
|
||||||
Input,
|
|
||||||
PageHeader,
|
|
||||||
Pagination,
|
|
||||||
Skeleton,
|
|
||||||
Tooltip
|
|
||||||
} from "@app/components/v2";
|
|
||||||
import {
|
|
||||||
OrgPermissionActions,
|
|
||||||
OrgPermissionSubjects,
|
|
||||||
useOrganization,
|
|
||||||
useSubscription
|
|
||||||
} from "@app/context";
|
|
||||||
import { getProjectHomePage } from "@app/helpers/project";
|
|
||||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
|
||||||
import { useGetUserWorkspaces } from "@app/hooks/api";
|
|
||||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
|
||||||
// import { fetchUserWsKey } from "@app/hooks/api/keys/queries";
|
|
||||||
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
|
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 { ProjectType } from "@app/hooks/api/workspace/types";
|
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||||
import { usePopUp } from "@app/hooks/usePopUp";
|
import { usePopUp } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
enum ProjectsViewMode {
|
import { AllProjectView } from "./components/AllProjectView";
|
||||||
GRID = "grid",
|
import { MyProjectView } from "./components/MyProjectView";
|
||||||
LIST = "list"
|
import { ProjectListView } from "./components/ProjectListToggle";
|
||||||
}
|
|
||||||
|
|
||||||
enum ProjectOrderBy {
|
|
||||||
Name = "name"
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDescription = (type: ProjectType) => {
|
const formatDescription = (type: ProjectType) => {
|
||||||
if (type === ProjectType.SecretManager)
|
if (type === ProjectType.SecretManager)
|
||||||
@@ -76,295 +36,20 @@ type Props = {
|
|||||||
export const ProductOverviewPage = ({ type }: Props) => {
|
export const ProductOverviewPage = ({ type }: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const [projectListView, setProjectListView] = useState(ProjectListView.MyProjects);
|
||||||
|
|
||||||
const { data: workspaces, isPending: isWorkspaceLoading } = useGetUserWorkspaces({ type });
|
|
||||||
const { currentOrg } = useOrganization();
|
|
||||||
const orgWorkspaces = workspaces || [];
|
|
||||||
const { data: projectFavorites, isPending: isProjectFavoritesLoading } =
|
|
||||||
useGetUserProjectFavorites(currentOrg?.id);
|
|
||||||
const { mutateAsync: updateUserProjectFavorites } = useUpdateUserProjectFavorites();
|
|
||||||
|
|
||||||
const isProjectViewLoading = isWorkspaceLoading || isProjectFavoritesLoading;
|
|
||||||
|
|
||||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
|
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
|
||||||
"addNewWs",
|
"addNewWs",
|
||||||
"upgradePlan"
|
"upgradePlan"
|
||||||
] as const);
|
] as const);
|
||||||
|
|
||||||
const [searchFilter, setSearchFilter] = useState("");
|
|
||||||
const { data: serverDetails } = useFetchServerStatus();
|
const { data: serverDetails } = useFetchServerStatus();
|
||||||
const [projectsViewMode, setProjectsViewMode] = useState<ProjectsViewMode>(
|
|
||||||
(localStorage.getItem("projectsViewMode") as ProjectsViewMode) || ProjectsViewMode.GRID
|
|
||||||
);
|
|
||||||
|
|
||||||
const { subscription } = useSubscription();
|
const { subscription } = useSubscription();
|
||||||
|
|
||||||
const isAddingProjectsAllowed = subscription?.workspaceLimit
|
const isAddingProjectsAllowed = subscription?.workspaceLimit
|
||||||
? subscription.workspacesUsed < subscription.workspaceLimit
|
? subscription.workspacesUsed < subscription.workspaceLimit
|
||||||
: true;
|
: true;
|
||||||
|
|
||||||
const isWorkspaceEmpty = !isProjectViewLoading && orgWorkspaces?.length === 0;
|
|
||||||
|
|
||||||
const {
|
|
||||||
setPage,
|
|
||||||
perPage,
|
|
||||||
setPerPage,
|
|
||||||
page,
|
|
||||||
offset,
|
|
||||||
limit,
|
|
||||||
toggleOrderDirection,
|
|
||||||
orderDirection
|
|
||||||
} = usePagination(ProjectOrderBy.Name, { initPerPage: 24 });
|
|
||||||
|
|
||||||
const filteredWorkspaces = useMemo(
|
|
||||||
() =>
|
|
||||||
orgWorkspaces
|
|
||||||
.filter((ws) => ws?.name?.toLowerCase().includes(searchFilter.toLowerCase()))
|
|
||||||
.sort((a, b) =>
|
|
||||||
orderDirection === OrderByDirection.ASC
|
|
||||||
? a.name.toLowerCase().localeCompare(b.name.toLowerCase())
|
|
||||||
: b.name.toLowerCase().localeCompare(a.name.toLowerCase())
|
|
||||||
),
|
|
||||||
[searchFilter, orderDirection, orgWorkspaces]
|
|
||||||
);
|
|
||||||
|
|
||||||
useResetPageHelper({
|
|
||||||
setPage,
|
|
||||||
offset,
|
|
||||||
totalCount: filteredWorkspaces.length
|
|
||||||
});
|
|
||||||
|
|
||||||
const { workspacesWithFaveProp } = 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))
|
|
||||||
.slice(offset, limit * page);
|
|
||||||
|
|
||||||
return {
|
|
||||||
workspacesWithFaveProp: workspacesWithFav
|
|
||||||
};
|
|
||||||
}, [filteredWorkspaces, projectFavorites, offset, limit, page]);
|
|
||||||
|
|
||||||
const addProjectToFavorites = async (projectId: string) => {
|
|
||||||
try {
|
|
||||||
if (currentOrg?.id) {
|
|
||||||
await updateUserProjectFavorites({
|
|
||||||
orgId: currentOrg?.id,
|
|
||||||
projectFavorites: [...(projectFavorites || []), projectId]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
createNotification({
|
|
||||||
text: "Failed to add project to favorites.",
|
|
||||||
type: "error"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeProjectFromFavorites = async (projectId: string) => {
|
|
||||||
try {
|
|
||||||
if (currentOrg?.id) {
|
|
||||||
await updateUserProjectFavorites({
|
|
||||||
orgId: currentOrg?.id,
|
|
||||||
projectFavorites: [...(projectFavorites || []).filter((entry) => entry !== projectId)]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
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
|
|
||||||
<div
|
|
||||||
onClick={() => {
|
|
||||||
navigate({
|
|
||||||
to: getProjectHomePage(workspace),
|
|
||||||
params: {
|
|
||||||
projectId: workspace.id
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
key={workspace.id}
|
|
||||||
className="flex h-40 min-w-72 cursor-pointer flex-col rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4"
|
|
||||||
>
|
|
||||||
<div className="flex flex-row justify-between">
|
|
||||||
<div className="mt-0 truncate text-lg text-mineshaft-100">{workspace.name}</div>
|
|
||||||
{isFavorite ? (
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={faSolidStar}
|
|
||||||
className="text-sm text-yellow-600 hover:text-mineshaft-400"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
removeProjectFromFavorites(workspace.id);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={faStar}
|
|
||||||
className="text-sm text-mineshaft-400 hover:text-mineshaft-300"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
addProjectToFavorites(workspace.id);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="mb-2.5 mt-1 grow text-sm text-mineshaft-300"
|
|
||||||
style={{
|
|
||||||
overflow: "hidden",
|
|
||||||
display: "-webkit-box",
|
|
||||||
WebkitBoxOrient: "vertical",
|
|
||||||
WebkitLineClamp: 2
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{workspace.description}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex w-full flex-row items-end justify-between place-self-end">
|
|
||||||
{type === ProjectType.SecretManager && (
|
|
||||||
<div className="mt-0 text-xs text-mineshaft-400">
|
|
||||||
{workspace.environments?.length || 0} environments
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button type="button">
|
|
||||||
<div className="group ml-auto w-max cursor-pointer rounded-full border border-mineshaft-600 bg-mineshaft-900 px-4 py-2 text-sm text-mineshaft-300 transition-all hover:border-primary-500/80 hover:bg-primary-800/20 hover:text-mineshaft-200">
|
|
||||||
Explore{" "}
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={faArrowRight}
|
|
||||||
className="pl-1.5 pr-0.5 duration-200 hover:pl-2 hover:pr-0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
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
|
|
||||||
<div
|
|
||||||
onClick={() => {
|
|
||||||
navigate({
|
|
||||||
to: getProjectHomePage(workspace),
|
|
||||||
params: {
|
|
||||||
projectId: workspace.id
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
key={workspace.id}
|
|
||||||
className={`group grid h-14 min-w-72 cursor-pointer grid-cols-6 border-l border-r border-t border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700 ${
|
|
||||||
index === 0 && "rounded-t-md"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center sm:col-span-3 lg:col-span-4">
|
|
||||||
<div className="truncate text-sm text-mineshaft-100">{workspace.name}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-end sm:col-span-3 lg:col-span-2">
|
|
||||||
<div className="text-center text-sm text-mineshaft-300">
|
|
||||||
{workspace.environments?.length || 0} environments
|
|
||||||
</div>
|
|
||||||
{isFavorite ? (
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={faSolidStar}
|
|
||||||
className="ml-6 text-sm text-yellow-600 hover:text-mineshaft-400"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
removeProjectFromFavorites(workspace.id);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={faStar}
|
|
||||||
className="ml-6 text-sm text-mineshaft-400 hover:text-mineshaft-300"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
addProjectToFavorites(workspace.id);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
let projectsComponents: ReactNode;
|
|
||||||
|
|
||||||
if (filteredWorkspaces.length || isProjectViewLoading) {
|
|
||||||
switch (projectsViewMode) {
|
|
||||||
case ProjectsViewMode.GRID:
|
|
||||||
projectsComponents = (
|
|
||||||
<div className="mt-4 grid w-full grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
|
||||||
{isProjectViewLoading &&
|
|
||||||
Array.apply(0, Array(3)).map((_x, i) => (
|
|
||||||
<div
|
|
||||||
key={`workspace-cards-loading-${i + 1}`}
|
|
||||||
className="flex h-40 min-w-72 flex-col justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4"
|
|
||||||
>
|
|
||||||
<div className="mt-0 text-lg text-mineshaft-100">
|
|
||||||
<Skeleton className="w-3/4 bg-mineshaft-600" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-0 pb-6 text-sm text-mineshaft-300">
|
|
||||||
<Skeleton className="w-1/2 bg-mineshaft-600" />
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Skeleton className="w-1/2 bg-mineshaft-600" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{!isProjectViewLoading && (
|
|
||||||
<>
|
|
||||||
{workspacesWithFaveProp.map((workspace) =>
|
|
||||||
renderProjectGridItem(workspace, workspace.isFavorite)
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
break;
|
|
||||||
case ProjectsViewMode.LIST:
|
|
||||||
default:
|
|
||||||
projectsComponents = (
|
|
||||||
<div className="mt-4 w-full rounded-md">
|
|
||||||
{isProjectViewLoading &&
|
|
||||||
Array.apply(0, Array(3)).map((_x, i) => (
|
|
||||||
<div
|
|
||||||
key={`workspace-cards-loading-${i + 1}`}
|
|
||||||
className={`group flex h-12 min-w-72 cursor-pointer flex-row items-center justify-between border border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700 ${
|
|
||||||
i === 0 && "rounded-t-md"
|
|
||||||
} ${i === 2 && "rounded-b-md border-b"}`}
|
|
||||||
>
|
|
||||||
<Skeleton className="w-full bg-mineshaft-600" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{!isProjectViewLoading &&
|
|
||||||
workspacesWithFaveProp.map((workspace, ind) =>
|
|
||||||
renderProjectListItem(workspace, workspace.isFavorite, ind)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else if (orgWorkspaces.length && searchFilter) {
|
|
||||||
projectsComponents = (
|
|
||||||
<div className="mt-4 w-full rounded-md border border-mineshaft-700 bg-mineshaft-800 px-4 py-6 text-base text-mineshaft-300">
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={faSearch}
|
|
||||||
className="mb-4 mt-2 w-full text-center text-5xl text-mineshaft-400"
|
|
||||||
/>
|
|
||||||
<div className="text-center font-light">No projects match search...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-7xl flex-col justify-start bg-bunker-800">
|
<div className="mx-auto flex max-w-7xl flex-col justify-start bg-bunker-800">
|
||||||
<Helmet>
|
<Helmet>
|
||||||
@@ -395,63 +80,7 @@ export const ProductOverviewPage = ({ type }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mb-4 flex flex-col items-start justify-start">
|
<div className="mb-4 flex flex-col items-start justify-start">
|
||||||
<PageHeader title="Projects" description={formatDescription(type)} />
|
<PageHeader title="Projects" description={formatDescription(type)}>
|
||||||
<div className="flex w-full flex-row">
|
|
||||||
<Input
|
|
||||||
className="h-[2.3rem] bg-mineshaft-800 text-sm placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
|
|
||||||
placeholder="Search by project name..."
|
|
||||||
value={searchFilter}
|
|
||||||
onChange={(e) => setSearchFilter(e.target.value)}
|
|
||||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
|
||||||
/>
|
|
||||||
<div className="ml-2 flex rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
|
|
||||||
<Tooltip content="Toggle Sort Direction">
|
|
||||||
<IconButton
|
|
||||||
className="min-w-[2.4rem] border-none hover:bg-mineshaft-600"
|
|
||||||
ariaLabel={`Sort ${
|
|
||||||
orderDirection === OrderByDirection.ASC ? "descending" : "ascending"
|
|
||||||
}`}
|
|
||||||
variant="plain"
|
|
||||||
size="xs"
|
|
||||||
colorSchema="secondary"
|
|
||||||
onClick={toggleOrderDirection}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={orderDirection === OrderByDirection.ASC ? faArrowDownAZ : faArrowUpZA}
|
|
||||||
/>
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<div className="ml-2 flex rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
|
|
||||||
<IconButton
|
|
||||||
variant="outline_bg"
|
|
||||||
onClick={() => {
|
|
||||||
localStorage.setItem("projectsViewMode", ProjectsViewMode.GRID);
|
|
||||||
setProjectsViewMode(ProjectsViewMode.GRID);
|
|
||||||
}}
|
|
||||||
ariaLabel="grid"
|
|
||||||
size="xs"
|
|
||||||
className={`${
|
|
||||||
projectsViewMode === ProjectsViewMode.GRID ? "bg-mineshaft-500" : "bg-transparent"
|
|
||||||
} min-w-[2.4rem] border-none hover:bg-mineshaft-600`}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faBorderAll} />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
variant="outline_bg"
|
|
||||||
onClick={() => {
|
|
||||||
localStorage.setItem("projectsViewMode", ProjectsViewMode.LIST);
|
|
||||||
setProjectsViewMode(ProjectsViewMode.LIST);
|
|
||||||
}}
|
|
||||||
ariaLabel="list"
|
|
||||||
size="xs"
|
|
||||||
className={`${
|
|
||||||
projectsViewMode === ProjectsViewMode.LIST ? "bg-mineshaft-500" : "bg-transparent"
|
|
||||||
} min-w-[2.4rem] border-none hover:bg-mineshaft-600`}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faList} />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
<OrgPermissionCan I={OrgPermissionActions.Create} an={OrgPermissionSubjects.Workspace}>
|
<OrgPermissionCan I={OrgPermissionActions.Create} an={OrgPermissionSubjects.Workspace}>
|
||||||
{(isAllowed) => (
|
{(isAllowed) => (
|
||||||
<Button
|
<Button
|
||||||
@@ -471,40 +100,14 @@ export const ProductOverviewPage = ({ type }: Props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</OrgPermissionCan>
|
</OrgPermissionCan>
|
||||||
</div>
|
</PageHeader>
|
||||||
{projectsComponents}
|
|
||||||
{!isProjectViewLoading && Boolean(filteredWorkspaces.length) && (
|
|
||||||
<Pagination
|
|
||||||
className={
|
|
||||||
projectsViewMode === ProjectsViewMode.GRID
|
|
||||||
? "col-span-full !justify-start border-transparent bg-transparent pl-2"
|
|
||||||
: "rounded-b-md border border-mineshaft-600"
|
|
||||||
}
|
|
||||||
perPage={perPage}
|
|
||||||
perPageList={[12, 24, 48, 96]}
|
|
||||||
count={filteredWorkspaces.length}
|
|
||||||
page={page}
|
|
||||||
onChangePage={setPage}
|
|
||||||
onChangePerPage={setPerPage}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isWorkspaceEmpty && (
|
|
||||||
<div className="mt-4 w-full rounded-md border border-mineshaft-700 bg-mineshaft-800 px-4 py-6 text-base text-mineshaft-300">
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={faFolderOpen}
|
|
||||||
className="mb-4 mt-2 w-full text-center text-5xl text-mineshaft-400"
|
|
||||||
/>
|
|
||||||
<div className="text-center font-light">
|
|
||||||
You are not part of any projects in this organization yet. When you are, they will
|
|
||||||
appear here.
|
|
||||||
</div>
|
|
||||||
<div className="mt-0.5 text-center font-light">
|
|
||||||
Create a new project, or ask other organization members to give you necessary
|
|
||||||
permissions.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{projectListView === ProjectListView.MyProjects && (
|
||||||
|
<MyProjectView type={type} onListViewToggle={setProjectListView} />
|
||||||
|
)}
|
||||||
|
{projectListView === ProjectListView.AllProjects && (
|
||||||
|
<AllProjectView type={type} onListViewToggle={setProjectListView} />
|
||||||
|
)}
|
||||||
<NewProjectModal
|
<NewProjectModal
|
||||||
isOpen={popUp.addNewWs.isOpen}
|
isOpen={popUp.addNewWs.isOpen}
|
||||||
onOpenChange={(isOpen) => handlePopUpToggle("addNewWs", isOpen)}
|
onOpenChange={(isOpen) => handlePopUpToggle("addNewWs", isOpen)}
|
||||||
|
|||||||
+286
@@ -0,0 +1,286 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import {
|
||||||
|
faArrowDownAZ,
|
||||||
|
faBorderAll,
|
||||||
|
faCheck,
|
||||||
|
faFolderOpen,
|
||||||
|
faList,
|
||||||
|
faMagnifyingGlass
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
ModalContent,
|
||||||
|
Pagination,
|
||||||
|
Skeleton,
|
||||||
|
Tooltip
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { getProjectHomePage } from "@app/helpers/project";
|
||||||
|
import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
|
||||||
|
import { useRequestProjectAccess, useSearchProjects } from "@app/hooks/api";
|
||||||
|
import { ProjectType, Workspace } from "@app/hooks/api/workspace/types";
|
||||||
|
|
||||||
|
import { ProjectListToggle, ProjectListView } from "./ProjectListToggle";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
type: ProjectType;
|
||||||
|
onListViewToggle: (value: ProjectListView) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RequestAccessModalProps = {
|
||||||
|
projectId: string;
|
||||||
|
onPopUpToggle: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RequestAccessModal = ({ projectId, onPopUpToggle }: RequestAccessModalProps) => {
|
||||||
|
const form = useForm<{ note: string }>();
|
||||||
|
|
||||||
|
const requestProjectAccess = useRequestProjectAccess();
|
||||||
|
|
||||||
|
const onFormSubmit = ({ note }: { note: string }) => {
|
||||||
|
if (requestProjectAccess.isPending) return;
|
||||||
|
requestProjectAccess.mutate(
|
||||||
|
{
|
||||||
|
comment: note,
|
||||||
|
projectId
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
createNotification({
|
||||||
|
type: "success",
|
||||||
|
title: "Project Access Request Sent",
|
||||||
|
text: "Project admins will receive an email and will grant you access."
|
||||||
|
});
|
||||||
|
onPopUpToggle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
<FormControl label="Note">
|
||||||
|
<Input {...form.register("note")} />
|
||||||
|
</FormControl>
|
||||||
|
<div className="mt-4 flex items-center">
|
||||||
|
<Button className="mr-4" size="sm" type="submit" isLoading={form.formState.isSubmitting}>
|
||||||
|
Submit Request
|
||||||
|
</Button>
|
||||||
|
<Button colorSchema="secondary" variant="plain" onClick={() => onPopUpToggle()}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AllProjectView = ({ type, onListViewToggle }: Props) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchFilter, setSearchFilter] = useState("");
|
||||||
|
const [debouncedSearch] = useDebounce(searchFilter);
|
||||||
|
const {
|
||||||
|
setPage,
|
||||||
|
perPage,
|
||||||
|
setPerPage,
|
||||||
|
page,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
toggleOrderDirection,
|
||||||
|
orderDirection
|
||||||
|
} = usePagination("name", { initPerPage: 50 });
|
||||||
|
const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp([
|
||||||
|
"requestAccessConfirmation"
|
||||||
|
] as const);
|
||||||
|
|
||||||
|
const { data: searchedProjects, isPending: isProjectLoading } = useSearchProjects({
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
type,
|
||||||
|
name: debouncedSearch || undefined,
|
||||||
|
orderDirection
|
||||||
|
});
|
||||||
|
|
||||||
|
useResetPageHelper({
|
||||||
|
setPage,
|
||||||
|
offset,
|
||||||
|
totalCount: searchedProjects?.totalCount || 0
|
||||||
|
});
|
||||||
|
const requestedWorkspaceDetails = popUp.requestAccessConfirmation.data as Workspace;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex w-full flex-row">
|
||||||
|
<ProjectListToggle value={ProjectListView.AllProjects} onChange={onListViewToggle} />
|
||||||
|
<div className="flex-grow" />
|
||||||
|
<Input
|
||||||
|
className="h-[2.3rem] bg-mineshaft-800 text-sm placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
|
||||||
|
containerClassName="max-w-md"
|
||||||
|
placeholder="Search by project name..."
|
||||||
|
value={searchFilter}
|
||||||
|
onChange={(e) => setSearchFilter(e.target.value)}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||||
|
/>
|
||||||
|
<div className="ml-2 flex rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
|
||||||
|
<Tooltip content="Toggle Sort Direction">
|
||||||
|
<IconButton
|
||||||
|
className="min-w-[2.4rem] border-none hover:bg-mineshaft-600"
|
||||||
|
ariaLabel="Sort asc"
|
||||||
|
variant="plain"
|
||||||
|
size="xs"
|
||||||
|
colorSchema="secondary"
|
||||||
|
onClick={toggleOrderDirection}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faArrowDownAZ} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div className="ml-2 flex rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
|
||||||
|
<Tooltip content="Disabled across All Project view.">
|
||||||
|
<IconButton
|
||||||
|
variant="outline_bg"
|
||||||
|
ariaLabel="grid"
|
||||||
|
size="xs"
|
||||||
|
className="min-w-[2.4rem] border-none bg-transparent hover:bg-mineshaft-600"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faBorderAll} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<IconButton
|
||||||
|
variant="outline_bg"
|
||||||
|
ariaLabel="list"
|
||||||
|
size="xs"
|
||||||
|
className="min-w-[2.4rem] border-none bg-mineshaft-500 hover:bg-mineshaft-600"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faList} />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 w-full rounded-md">
|
||||||
|
{isProjectLoading &&
|
||||||
|
Array.apply(0, Array(3)).map((_x, i) => (
|
||||||
|
<div
|
||||||
|
key={`workspace-cards-loading-${i + 1}`}
|
||||||
|
className={twMerge(
|
||||||
|
"flex h-12 min-w-72 cursor-pointer flex-row items-center justify-between border border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700",
|
||||||
|
i === 0 && "rounded-t-md",
|
||||||
|
i === 2 && "rounded-b-md border-b"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Skeleton className="w-full bg-mineshaft-600" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!isProjectLoading &&
|
||||||
|
searchedProjects?.projects?.map((workspace) => (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(evt) => {
|
||||||
|
if (evt.key === "Enter" && workspace.isMember) {
|
||||||
|
navigate({
|
||||||
|
to: getProjectHomePage(workspace),
|
||||||
|
params: {
|
||||||
|
projectId: workspace.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (workspace.isMember) {
|
||||||
|
navigate({
|
||||||
|
to: getProjectHomePage(workspace),
|
||||||
|
params: {
|
||||||
|
projectId: workspace.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
key={workspace.id}
|
||||||
|
className={twMerge(
|
||||||
|
"group flex min-w-72 grid-cols-6 items-center justify-center border-l border-r border-t border-mineshaft-600 bg-mineshaft-800 px-6 py-3 first:rounded-t-md",
|
||||||
|
workspace.isMember ? "cursor-pointer hover:bg-mineshaft-700" : "cursor-default"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="w-full items-center">
|
||||||
|
<div className="flex flex-grow items-center">
|
||||||
|
<div className="flex-grow truncate text-sm text-mineshaft-100">
|
||||||
|
{workspace.name}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{workspace.isMember ? (
|
||||||
|
<div className="flex items-center text-center text-sm text-primary">
|
||||||
|
<FontAwesomeIcon icon={faCheck} className="mr-2" />
|
||||||
|
Joined
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="opacity-0 transition-all group-hover:opacity-100">
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="outline_bg"
|
||||||
|
onClick={() => handlePopUpOpen("requestAccessConfirmation", workspace)}
|
||||||
|
>
|
||||||
|
Request Access
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 max-w-lg overflow-hidden text-ellipsis whitespace-nowrap text-xs text-mineshaft-300">
|
||||||
|
{workspace.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!isProjectLoading && Boolean(searchedProjects?.totalCount) && (
|
||||||
|
<Pagination
|
||||||
|
className="rounded-b-md border border-mineshaft-600"
|
||||||
|
perPage={perPage}
|
||||||
|
perPageList={[12, 24, 48, 96]}
|
||||||
|
count={searchedProjects?.totalCount || 0}
|
||||||
|
page={page}
|
||||||
|
onChangePage={setPage}
|
||||||
|
onChangePerPage={setPerPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isProjectLoading && !searchedProjects?.totalCount && (
|
||||||
|
<div className="mt-4 w-full rounded-md border border-mineshaft-700 bg-mineshaft-800 px-4 py-6 text-base text-mineshaft-300">
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faFolderOpen}
|
||||||
|
className="mb-4 mt-2 w-full text-center text-5xl text-mineshaft-400"
|
||||||
|
/>
|
||||||
|
<div className="text-center font-light">
|
||||||
|
You are not part of any projects in this organization yet. When you are, they will
|
||||||
|
appear here.
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-center font-light">
|
||||||
|
Create a new project, or ask other organization members to give you necessary
|
||||||
|
permissions.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Modal
|
||||||
|
isOpen={popUp.requestAccessConfirmation.isOpen}
|
||||||
|
onOpenChange={(isOpen) => handlePopUpToggle("requestAccessConfirmation", isOpen)}
|
||||||
|
>
|
||||||
|
<ModalContent
|
||||||
|
title="Confirm Access Request"
|
||||||
|
subTitle={`Requesting access to project ${requestedWorkspaceDetails?.name}. You may include a note for the admins.`}
|
||||||
|
>
|
||||||
|
<RequestAccessModal
|
||||||
|
onPopUpToggle={() => handlePopUpToggle("requestAccessConfirmation")}
|
||||||
|
projectId={requestedWorkspaceDetails?.id}
|
||||||
|
/>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+414
@@ -0,0 +1,414 @@
|
|||||||
|
import { ReactNode, useMemo, useState } from "react";
|
||||||
|
import { faFolderOpen, faStar } from "@fortawesome/free-regular-svg-icons";
|
||||||
|
import {
|
||||||
|
faArrowDownAZ,
|
||||||
|
faArrowRight,
|
||||||
|
faArrowUpZA,
|
||||||
|
faBorderAll,
|
||||||
|
faList,
|
||||||
|
faMagnifyingGlass,
|
||||||
|
faSearch,
|
||||||
|
faStar as faSolidStar
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import { IconButton, Input, Pagination, Skeleton, Tooltip } from "@app/components/v2";
|
||||||
|
import { useOrganization } from "@app/context";
|
||||||
|
import { getProjectHomePage } from "@app/helpers/project";
|
||||||
|
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||||
|
import { useGetUserWorkspaces } from "@app/hooks/api";
|
||||||
|
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||||
|
import { useUpdateUserProjectFavorites } from "@app/hooks/api/users/mutation";
|
||||||
|
import { useGetUserProjectFavorites } from "@app/hooks/api/users/queries";
|
||||||
|
import { ProjectType, Workspace } from "@app/hooks/api/workspace/types";
|
||||||
|
|
||||||
|
import { ProjectListToggle, ProjectListView } from "./ProjectListToggle";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
type: ProjectType;
|
||||||
|
onListViewToggle: (value: ProjectListView) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
enum ProjectOrderBy {
|
||||||
|
Name = "name"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ProjectsViewMode {
|
||||||
|
GRID = "grid",
|
||||||
|
LIST = "list"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MyProjectView = ({ type, onListViewToggle }: Props) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { currentOrg } = useOrganization();
|
||||||
|
|
||||||
|
const { data: workspaces = [], isPending: isWorkspaceLoading } = useGetUserWorkspaces({
|
||||||
|
type
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
setPage,
|
||||||
|
perPage,
|
||||||
|
setPerPage,
|
||||||
|
page,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
toggleOrderDirection,
|
||||||
|
orderDirection
|
||||||
|
} = usePagination(ProjectOrderBy.Name, { initPerPage: 24 });
|
||||||
|
const { data: projectFavorites, isPending: isProjectFavoritesLoading } =
|
||||||
|
useGetUserProjectFavorites(currentOrg?.id);
|
||||||
|
|
||||||
|
const [projectsViewMode, setProjectsViewMode] = useState<ProjectsViewMode>(
|
||||||
|
(localStorage.getItem("projectsViewMode") as ProjectsViewMode) || ProjectsViewMode.GRID
|
||||||
|
);
|
||||||
|
const [searchFilter, setSearchFilter] = useState("");
|
||||||
|
|
||||||
|
const isProjectViewLoading = isWorkspaceLoading || isProjectFavoritesLoading;
|
||||||
|
const isWorkspaceEmpty = !isProjectViewLoading && workspaces?.length === 0;
|
||||||
|
const { mutateAsync: updateUserProjectFavorites } = useUpdateUserProjectFavorites();
|
||||||
|
|
||||||
|
const filteredWorkspaces = useMemo(
|
||||||
|
() =>
|
||||||
|
workspaces
|
||||||
|
.filter((ws) => ws?.name?.toLowerCase().includes(searchFilter.toLowerCase()))
|
||||||
|
.sort((a, b) =>
|
||||||
|
orderDirection === OrderByDirection.ASC
|
||||||
|
? a.name.toLowerCase().localeCompare(b.name.toLowerCase())
|
||||||
|
: b.name.toLowerCase().localeCompare(a.name.toLowerCase())
|
||||||
|
),
|
||||||
|
[searchFilter, orderDirection, workspaces]
|
||||||
|
);
|
||||||
|
|
||||||
|
useResetPageHelper({
|
||||||
|
setPage,
|
||||||
|
offset,
|
||||||
|
totalCount: filteredWorkspaces.length
|
||||||
|
});
|
||||||
|
|
||||||
|
const { workspacesWithFaveProp } = 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))
|
||||||
|
.slice(offset, limit * page);
|
||||||
|
|
||||||
|
return {
|
||||||
|
workspacesWithFaveProp: workspacesWithFav
|
||||||
|
};
|
||||||
|
}, [filteredWorkspaces, projectFavorites, offset, limit, page]);
|
||||||
|
|
||||||
|
const addProjectToFavorites = async (projectId: string) => {
|
||||||
|
try {
|
||||||
|
if (currentOrg?.id) {
|
||||||
|
await updateUserProjectFavorites({
|
||||||
|
orgId: currentOrg?.id,
|
||||||
|
projectFavorites: [...(projectFavorites || []), projectId]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to add project to favorites.",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const removeProjectFromFavorites = async (projectId: string) => {
|
||||||
|
try {
|
||||||
|
if (currentOrg?.id) {
|
||||||
|
await updateUserProjectFavorites({
|
||||||
|
orgId: currentOrg?.id,
|
||||||
|
projectFavorites: [...(projectFavorites || []).filter((entry) => entry !== projectId)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
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
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
navigate({
|
||||||
|
to: getProjectHomePage(workspace),
|
||||||
|
params: {
|
||||||
|
projectId: workspace.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
key={workspace.id}
|
||||||
|
className="flex h-40 min-w-72 cursor-pointer flex-col rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex flex-row justify-between">
|
||||||
|
<div className="mt-0 truncate text-lg text-mineshaft-100">{workspace.name}</div>
|
||||||
|
{isFavorite ? (
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faSolidStar}
|
||||||
|
className="text-sm text-yellow-600 hover:text-mineshaft-400"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeProjectFromFavorites(workspace.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faStar}
|
||||||
|
className="text-sm text-mineshaft-400 hover:text-mineshaft-300"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
addProjectToFavorites(workspace.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="mb-2.5 mt-1 grow text-sm text-mineshaft-300"
|
||||||
|
style={{
|
||||||
|
overflow: "hidden",
|
||||||
|
display: "-webkit-box",
|
||||||
|
WebkitBoxOrient: "vertical",
|
||||||
|
WebkitLineClamp: 2
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{workspace.description}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full flex-row items-end justify-between place-self-end">
|
||||||
|
{type === ProjectType.SecretManager && (
|
||||||
|
<div className="mt-0 text-xs text-mineshaft-400">
|
||||||
|
{workspace.environments?.length || 0} environments
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button type="button">
|
||||||
|
<div className="group ml-auto w-max cursor-pointer rounded-full border border-mineshaft-600 bg-mineshaft-900 px-4 py-2 text-sm text-mineshaft-300 transition-all hover:border-primary-500/80 hover:bg-primary-800/20 hover:text-mineshaft-200">
|
||||||
|
Explore{" "}
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faArrowRight}
|
||||||
|
className="pl-1.5 pr-0.5 duration-200 hover:pl-2 hover:pr-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
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
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
navigate({
|
||||||
|
to: getProjectHomePage(workspace),
|
||||||
|
params: {
|
||||||
|
projectId: workspace.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
key={workspace.id}
|
||||||
|
className={`group grid h-14 min-w-72 cursor-pointer grid-cols-6 border-l border-r border-t border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700 ${
|
||||||
|
index === 0 && "rounded-t-md"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center sm:col-span-3 lg:col-span-4">
|
||||||
|
<div className="truncate text-sm text-mineshaft-100">{workspace.name}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end sm:col-span-3 lg:col-span-2">
|
||||||
|
{type === ProjectType.SecretManager && (
|
||||||
|
<div className="text-center text-sm text-mineshaft-300">
|
||||||
|
{workspace.environments?.length || 0} environments
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isFavorite ? (
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faSolidStar}
|
||||||
|
className="ml-6 text-sm text-yellow-600 hover:text-mineshaft-400"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
removeProjectFromFavorites(workspace.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faStar}
|
||||||
|
className="ml-6 text-sm text-mineshaft-400 hover:text-mineshaft-300"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
addProjectToFavorites(workspace.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
let projectsComponents: ReactNode;
|
||||||
|
|
||||||
|
if (filteredWorkspaces.length || isProjectViewLoading) {
|
||||||
|
switch (projectsViewMode) {
|
||||||
|
case ProjectsViewMode.GRID:
|
||||||
|
projectsComponents = (
|
||||||
|
<div className="mt-4 grid w-full grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||||
|
{isProjectViewLoading &&
|
||||||
|
Array.apply(0, Array(3)).map((_x, i) => (
|
||||||
|
<div
|
||||||
|
key={`workspace-cards-loading-${i + 1}`}
|
||||||
|
className="flex h-40 min-w-72 flex-col justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4"
|
||||||
|
>
|
||||||
|
<div className="mt-0 text-lg text-mineshaft-100">
|
||||||
|
<Skeleton className="w-3/4 bg-mineshaft-600" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-0 pb-6 text-sm text-mineshaft-300">
|
||||||
|
<Skeleton className="w-1/2 bg-mineshaft-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Skeleton className="w-1/2 bg-mineshaft-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!isProjectViewLoading && (
|
||||||
|
<>
|
||||||
|
{workspacesWithFaveProp.map((workspace) =>
|
||||||
|
renderProjectGridItem(workspace, workspace.isFavorite)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
break;
|
||||||
|
case ProjectsViewMode.LIST:
|
||||||
|
default:
|
||||||
|
projectsComponents = (
|
||||||
|
<div className="mt-4 w-full rounded-md">
|
||||||
|
{isProjectViewLoading &&
|
||||||
|
Array.apply(0, Array(3)).map((_x, i) => (
|
||||||
|
<div
|
||||||
|
key={`workspace-cards-loading-${i + 1}`}
|
||||||
|
className={`group flex h-12 min-w-72 cursor-pointer flex-row items-center justify-between border border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700 ${
|
||||||
|
i === 0 && "rounded-t-md"
|
||||||
|
} ${i === 2 && "rounded-b-md border-b"}`}
|
||||||
|
>
|
||||||
|
<Skeleton className="w-full bg-mineshaft-600" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!isProjectViewLoading &&
|
||||||
|
workspacesWithFaveProp.map((workspace, ind) =>
|
||||||
|
renderProjectListItem(workspace, workspace.isFavorite, ind)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else if (workspaces.length && searchFilter) {
|
||||||
|
projectsComponents = (
|
||||||
|
<div className="mt-4 w-full rounded-md border border-mineshaft-700 bg-mineshaft-800 px-4 py-6 text-base text-mineshaft-300">
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faSearch}
|
||||||
|
className="mb-4 mt-2 w-full text-center text-5xl text-mineshaft-400"
|
||||||
|
/>
|
||||||
|
<div className="text-center font-light">No projects match search...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex w-full flex-row">
|
||||||
|
<ProjectListToggle value={ProjectListView.MyProjects} onChange={onListViewToggle} />
|
||||||
|
<div className="flex-grow" />
|
||||||
|
<Input
|
||||||
|
className="h-[2.3rem] bg-mineshaft-800 text-sm placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
|
||||||
|
containerClassName="max-w-md"
|
||||||
|
placeholder="Search by project name..."
|
||||||
|
value={searchFilter}
|
||||||
|
onChange={(e) => setSearchFilter(e.target.value)}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||||
|
/>
|
||||||
|
<div className="ml-2 flex rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
|
||||||
|
<Tooltip content="Toggle Sort Direction">
|
||||||
|
<IconButton
|
||||||
|
className="min-w-[2.4rem] border-none hover:bg-mineshaft-600"
|
||||||
|
ariaLabel={`Sort ${
|
||||||
|
orderDirection === OrderByDirection.ASC ? "descending" : "ascending"
|
||||||
|
}`}
|
||||||
|
variant="plain"
|
||||||
|
size="xs"
|
||||||
|
colorSchema="secondary"
|
||||||
|
onClick={toggleOrderDirection}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={orderDirection === OrderByDirection.ASC ? faArrowDownAZ : faArrowUpZA}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div className="ml-2 flex rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
|
||||||
|
<IconButton
|
||||||
|
variant="outline_bg"
|
||||||
|
onClick={() => {
|
||||||
|
localStorage.setItem("projectsViewMode", ProjectsViewMode.GRID);
|
||||||
|
setProjectsViewMode(ProjectsViewMode.GRID);
|
||||||
|
}}
|
||||||
|
ariaLabel="grid"
|
||||||
|
size="xs"
|
||||||
|
className={`${
|
||||||
|
projectsViewMode === ProjectsViewMode.GRID ? "bg-mineshaft-500" : "bg-transparent"
|
||||||
|
} min-w-[2.4rem] border-none hover:bg-mineshaft-600`}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faBorderAll} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
variant="outline_bg"
|
||||||
|
onClick={() => {
|
||||||
|
localStorage.setItem("projectsViewMode", ProjectsViewMode.LIST);
|
||||||
|
setProjectsViewMode(ProjectsViewMode.LIST);
|
||||||
|
}}
|
||||||
|
ariaLabel="list"
|
||||||
|
size="xs"
|
||||||
|
className={`${
|
||||||
|
projectsViewMode === ProjectsViewMode.LIST ? "bg-mineshaft-500" : "bg-transparent"
|
||||||
|
} min-w-[2.4rem] border-none hover:bg-mineshaft-600`}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faList} />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{projectsComponents}
|
||||||
|
{!isProjectViewLoading && Boolean(filteredWorkspaces.length) && (
|
||||||
|
<Pagination
|
||||||
|
className={
|
||||||
|
projectsViewMode === ProjectsViewMode.GRID
|
||||||
|
? "col-span-full !justify-start border-transparent bg-transparent pl-2"
|
||||||
|
: "rounded-b-md border border-mineshaft-600"
|
||||||
|
}
|
||||||
|
perPage={perPage}
|
||||||
|
perPageList={[12, 24, 48, 96]}
|
||||||
|
count={filteredWorkspaces.length}
|
||||||
|
page={page}
|
||||||
|
onChangePage={setPage}
|
||||||
|
onChangePerPage={setPerPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isWorkspaceEmpty && (
|
||||||
|
<div className="mt-4 w-full rounded-md border border-mineshaft-700 bg-mineshaft-800 px-4 py-6 text-base text-mineshaft-300">
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faFolderOpen}
|
||||||
|
className="mb-4 mt-2 w-full text-center text-5xl text-mineshaft-400"
|
||||||
|
/>
|
||||||
|
<div className="text-center font-light">
|
||||||
|
You are not part of any projects in this organization yet. When you are, they will
|
||||||
|
appear here.
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-center font-light">
|
||||||
|
Create a new project, or ask other organization members to give you necessary
|
||||||
|
permissions.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
import { Select, SelectItem } from "@app/components/v2";
|
||||||
|
|
||||||
|
export enum ProjectListView {
|
||||||
|
MyProjects = "my-projects",
|
||||||
|
AllProjects = "all-projects"
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: ProjectListView;
|
||||||
|
onChange: (value: ProjectListView) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProjectListToggle = ({ value, onChange }: Props) => {
|
||||||
|
return (
|
||||||
|
<Select value={value} onValueChange={onChange}>
|
||||||
|
<SelectItem value={ProjectListView.MyProjects}>My Projects</SelectItem>
|
||||||
|
<SelectItem value={ProjectListView.AllProjects}>All Projects</SelectItem>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
};
|
||||||
+69
-11
@@ -1,11 +1,20 @@
|
|||||||
import { useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { createNotification } from "@app/components/notifications";
|
import { createNotification } from "@app/components/notifications";
|
||||||
import { Button, FilterableSelect, FormControl, Modal, ModalContent } from "@app/components/v2";
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertDescription,
|
||||||
|
Button,
|
||||||
|
FilterableSelect,
|
||||||
|
FormControl,
|
||||||
|
Modal,
|
||||||
|
ModalContent
|
||||||
|
} from "@app/components/v2";
|
||||||
import { CreatableSelect } from "@app/components/v2/CreatableSelect";
|
import { CreatableSelect } from "@app/components/v2/CreatableSelect";
|
||||||
import {
|
import {
|
||||||
OrgPermissionActions,
|
OrgPermissionActions,
|
||||||
@@ -48,7 +57,12 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { currentOrg } = useOrganization();
|
const { currentOrg } = useOrganization();
|
||||||
const { currentWorkspace } = useWorkspace();
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
const navigate = useNavigate({ from: "" });
|
||||||
const { permission } = useOrgPermission();
|
const { permission } = useOrgPermission();
|
||||||
|
const requesterEmail = useSearch({
|
||||||
|
strict: false,
|
||||||
|
select: (el) => el?.requesterEmail
|
||||||
|
});
|
||||||
|
|
||||||
const orgId = currentOrg?.id || "";
|
const orgId = currentOrg?.id || "";
|
||||||
const workspaceId = currentWorkspace?.id || "";
|
const workspaceId = currentWorkspace?.id || "";
|
||||||
@@ -62,6 +76,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
control,
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
|
setValue,
|
||||||
watch,
|
watch,
|
||||||
formState: { isSubmitting, errors }
|
formState: { isSubmitting, errors }
|
||||||
} = useForm<TAddMemberForm>({
|
} = useForm<TAddMemberForm>({
|
||||||
@@ -71,6 +86,12 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
|
|
||||||
const { mutateAsync: addMembersToProject } = useAddUsersToOrg();
|
const { mutateAsync: addMembersToProject } = useAddUsersToOrg();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (requesterEmail) {
|
||||||
|
handlePopUpToggle("addMember", true);
|
||||||
|
}
|
||||||
|
}, [requesterEmail]);
|
||||||
|
|
||||||
const onAddMembers = async ({ orgMemberships, projectRoleSlugs }: TAddMemberForm) => {
|
const onAddMembers = async ({ orgMemberships, projectRoleSlugs }: TAddMemberForm) => {
|
||||||
if (!currentWorkspace) return;
|
if (!currentWorkspace) return;
|
||||||
if (!currentOrg?.id) return;
|
if (!currentOrg?.id) return;
|
||||||
@@ -126,12 +147,13 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
reset();
|
reset();
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredOrgUsers = useMemo(() => {
|
const { append } = useFieldArray<TAddMemberForm>({ control, name: "orgMemberships" });
|
||||||
|
const projectInviteList = useMemo(() => {
|
||||||
const wsUserUsernames = new Map();
|
const wsUserUsernames = new Map();
|
||||||
members?.forEach((member) => {
|
members?.forEach((member) => {
|
||||||
wsUserUsernames.set(member.user.username, true);
|
wsUserUsernames.set(member.user.username, true);
|
||||||
});
|
});
|
||||||
return (orgUsers || [])
|
const list = (orgUsers || [])
|
||||||
.filter(({ user: u }) => !wsUserUsernames.has(u.username))
|
.filter(({ user: u }) => !wsUserUsernames.has(u.username))
|
||||||
.map(({ id, inviteEmail, user: { firstName, lastName, email } }) => ({
|
.map(({ id, inviteEmail, user: { firstName, lastName, email } }) => ({
|
||||||
value: id,
|
value: id,
|
||||||
@@ -140,13 +162,31 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
? `${firstName} ${lastName}`
|
? `${firstName} ${lastName}`
|
||||||
: firstName || lastName || email || inviteEmail
|
: firstName || lastName || email || inviteEmail
|
||||||
}));
|
}));
|
||||||
|
const requesterStatus = { isProjectUser: wsUserUsernames.has(requesterEmail), userLabel: "" };
|
||||||
|
if (!requesterStatus.isProjectUser) {
|
||||||
|
const userDetails = orgUsers?.find((el) => el.user.username === requesterEmail);
|
||||||
|
if (userDetails) {
|
||||||
|
const { user } = userDetails;
|
||||||
|
const label =
|
||||||
|
user.firstName && user.lastName
|
||||||
|
? `${user.firstName} ${user.lastName}`
|
||||||
|
: user.firstName || user.lastName || (user.email as string);
|
||||||
|
requesterStatus.userLabel = label;
|
||||||
|
setValue("orgMemberships", [
|
||||||
|
{
|
||||||
|
value: userDetails.id,
|
||||||
|
label
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { list, requesterStatus };
|
||||||
}, [orgUsers, members]);
|
}, [orgUsers, members]);
|
||||||
|
|
||||||
const selectedOrgMemberships = watch("orgMemberships");
|
const selectedOrgMemberships = watch("orgMemberships");
|
||||||
const selectedRoleSlugs = watch("projectRoleSlugs");
|
const selectedRoleSlugs = watch("projectRoleSlugs");
|
||||||
|
|
||||||
const { append } = useFieldArray<TAddMemberForm>({ control, name: "orgMemberships" });
|
|
||||||
|
|
||||||
const canInviteNewMembers = permission.can(
|
const canInviteNewMembers = permission.can(
|
||||||
OrgPermissionActions.Create,
|
OrgPermissionActions.Create,
|
||||||
OrgPermissionSubjects.Member
|
OrgPermissionSubjects.Member
|
||||||
@@ -155,7 +195,13 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={popUp?.addMember?.isOpen}
|
isOpen={popUp?.addMember?.isOpen}
|
||||||
onOpenChange={(isOpen) => handlePopUpToggle("addMember", isOpen)}
|
onOpenChange={(isOpen) => {
|
||||||
|
if (!isOpen)
|
||||||
|
navigate({
|
||||||
|
search: (prev) => ({ ...prev, requesterEmail: "" })
|
||||||
|
});
|
||||||
|
handlePopUpToggle("addMember", isOpen);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ModalContent
|
<ModalContent
|
||||||
bodyClassName="overflow-visible"
|
bodyClassName="overflow-visible"
|
||||||
@@ -185,7 +231,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
noOptionsMessage={() => (
|
noOptionsMessage={() => (
|
||||||
<>
|
<>
|
||||||
<p>
|
<p>
|
||||||
{!filteredOrgUsers.length && (
|
{!projectInviteList.list.length && (
|
||||||
<p>All organization members are already assigned to this project.</p>
|
<p>All organization members are already assigned to this project.</p>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
@@ -221,7 +267,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
placeholder="Add one or more users..."
|
placeholder="Add one or more users..."
|
||||||
isMulti
|
isMulti
|
||||||
name="members"
|
name="members"
|
||||||
options={filteredOrgUsers}
|
options={projectInviteList.list}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
@@ -231,7 +277,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
placeholder="Add one or more users..."
|
placeholder="Add one or more users..."
|
||||||
isMulti
|
isMulti
|
||||||
name="members"
|
name="members"
|
||||||
options={filteredOrgUsers}
|
options={projectInviteList.list}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
@@ -239,7 +285,6 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="projectRoleSlugs"
|
name="projectRoleSlugs"
|
||||||
@@ -263,6 +308,19 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{requesterEmail && projectInviteList.requesterStatus.isProjectUser && (
|
||||||
|
<Alert hideTitle variant="danger">
|
||||||
|
<AlertDescription>Requested user is part of the project.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{requesterEmail && !projectInviteList.requesterStatus.isProjectUser && (
|
||||||
|
<Alert hideTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Assign a role to provide access to requesting user{" "}
|
||||||
|
<b>{projectInviteList.requesterStatus.userLabel}</b>.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-8 flex items-center">
|
<div className="mt-8 flex items-center">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createFileRoute, linkOptions } from "@tanstack/react-router";
|
import { createFileRoute, linkOptions, stripSearchParams } from "@tanstack/react-router";
|
||||||
import { zodValidator } from "@tanstack/zod-adapter";
|
import { zodValidator } from "@tanstack/zod-adapter";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -7,7 +7,8 @@ import { ProjectAccessControlTabs } from "@app/types/project";
|
|||||||
import { AccessControlPage } from "./AccessControlPage";
|
import { AccessControlPage } from "./AccessControlPage";
|
||||||
|
|
||||||
const AccessControlPageQuerySchema = z.object({
|
const AccessControlPageQuerySchema = z.object({
|
||||||
selectedTab: z.nativeEnum(ProjectAccessControlTabs).catch(ProjectAccessControlTabs.Member)
|
selectedTab: z.nativeEnum(ProjectAccessControlTabs).catch(ProjectAccessControlTabs.Member),
|
||||||
|
requesterEmail: z.string().catch("")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const Route = createFileRoute(
|
export const Route = createFileRoute(
|
||||||
@@ -15,6 +16,9 @@ export const Route = createFileRoute(
|
|||||||
)({
|
)({
|
||||||
component: AccessControlPage,
|
component: AccessControlPage,
|
||||||
validateSearch: zodValidator(AccessControlPageQuerySchema),
|
validateSearch: zodValidator(AccessControlPageQuerySchema),
|
||||||
|
search: {
|
||||||
|
middlewares: [stripSearchParams({ requesterEmail: "" })]
|
||||||
|
},
|
||||||
beforeLoad: ({ context, params }) => {
|
beforeLoad: ({ context, params }) => {
|
||||||
return {
|
return {
|
||||||
breadcrumbs: [
|
breadcrumbs: [
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createFileRoute, linkOptions } from "@tanstack/react-router";
|
import { createFileRoute, linkOptions, stripSearchParams } from "@tanstack/react-router";
|
||||||
import { zodValidator } from "@tanstack/zod-adapter";
|
import { zodValidator } from "@tanstack/zod-adapter";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -7,7 +7,8 @@ import { ProjectAccessControlTabs } from "@app/types/project";
|
|||||||
import { AccessControlPage } from "./AccessControlPage";
|
import { AccessControlPage } from "./AccessControlPage";
|
||||||
|
|
||||||
const AccessControlPageQuerySchema = z.object({
|
const AccessControlPageQuerySchema = z.object({
|
||||||
selectedTab: z.nativeEnum(ProjectAccessControlTabs).catch(ProjectAccessControlTabs.Member)
|
selectedTab: z.nativeEnum(ProjectAccessControlTabs).catch(ProjectAccessControlTabs.Member),
|
||||||
|
requesterEmail: z.string().catch("")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const Route = createFileRoute(
|
export const Route = createFileRoute(
|
||||||
@@ -15,6 +16,9 @@ export const Route = createFileRoute(
|
|||||||
)({
|
)({
|
||||||
component: AccessControlPage,
|
component: AccessControlPage,
|
||||||
validateSearch: zodValidator(AccessControlPageQuerySchema),
|
validateSearch: zodValidator(AccessControlPageQuerySchema),
|
||||||
|
search: {
|
||||||
|
middlewares: [stripSearchParams({ requesterEmail: "" })]
|
||||||
|
},
|
||||||
beforeLoad: ({ context, params }) => {
|
beforeLoad: ({ context, params }) => {
|
||||||
return {
|
return {
|
||||||
breadcrumbs: [
|
breadcrumbs: [
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createFileRoute, linkOptions } from "@tanstack/react-router";
|
import { createFileRoute, linkOptions, stripSearchParams } from "@tanstack/react-router";
|
||||||
import { zodValidator } from "@tanstack/zod-adapter";
|
import { zodValidator } from "@tanstack/zod-adapter";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -7,7 +7,8 @@ import { ProjectAccessControlTabs } from "@app/types/project";
|
|||||||
import { AccessControlPage } from "./AccessControlPage";
|
import { AccessControlPage } from "./AccessControlPage";
|
||||||
|
|
||||||
const AccessControlPageQuerySchema = z.object({
|
const AccessControlPageQuerySchema = z.object({
|
||||||
selectedTab: z.nativeEnum(ProjectAccessControlTabs).catch(ProjectAccessControlTabs.Member)
|
selectedTab: z.nativeEnum(ProjectAccessControlTabs).catch(ProjectAccessControlTabs.Member),
|
||||||
|
requesterEmail: z.string().catch("")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const Route = createFileRoute(
|
export const Route = createFileRoute(
|
||||||
@@ -15,6 +16,9 @@ export const Route = createFileRoute(
|
|||||||
)({
|
)({
|
||||||
component: AccessControlPage,
|
component: AccessControlPage,
|
||||||
validateSearch: zodValidator(AccessControlPageQuerySchema),
|
validateSearch: zodValidator(AccessControlPageQuerySchema),
|
||||||
|
search: {
|
||||||
|
middlewares: [stripSearchParams({ requesterEmail: "" })]
|
||||||
|
},
|
||||||
beforeLoad: ({ context, params }) => {
|
beforeLoad: ({ context, params }) => {
|
||||||
return {
|
return {
|
||||||
breadcrumbs: [
|
breadcrumbs: [
|
||||||
|
|||||||
Reference in New Issue
Block a user