Files
probo/apps/console/src/layouts/MainLayout.tsx
Sacha Al Himdani 165eb267f2 Refactor invitation system
Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
2025-10-14 16:32:22 +02:00

386 lines
10 KiB
TypeScript

import { Link, Navigate, Outlet, useParams } from "react-router";
import {
DropdownSeparator,
IconArrowBoxLeft,
IconBank,
IconBook,
IconCircleQuestionmark,
IconClock,
IconCrossLargeX,
IconFire3,
IconGroup1,
IconInboxEmpty,
IconPageTextLine,
IconSettingsGear2,
IconCheckmark1,
IconStore,
IconTodo,
IconListStack,
IconBox,
IconShield,
IconRotateCw,
IconCircleProgress,
Layout,
SidebarItem,
UserDropdown as UserDropdownRoot,
UserDropdownItem,
Skeleton,
Dropdown,
Button,
DropdownItem,
IconChevronGrabberVertical,
IconPlusLarge,
IconChevronDown,
Avatar,
IconPeopleAdd,
Badge,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { graphql } from "relay-runtime";
import { useLazyLoadQuery, usePaginationFragment } from "react-relay";
import type { MainLayoutQuery as MainLayoutQueryType } from "./__generated__/MainLayoutQuery.graphql";
import type { MainLayout_OrganizationSelector_viewer$key } from "./__generated__/MainLayout_OrganizationSelector_viewer.graphql";
import { Suspense, useState } from "react";
import { useToast } from "@probo/ui";
import { ErrorBoundary } from "react-error-boundary";
import { PageError } from "/components/PageError";
import { buildEndpoint } from "/providers/RelayProviders";
const MainLayoutQuery = graphql`
query MainLayoutQuery($organizationId: ID!) {
viewer {
id
user {
fullName
email
}
...MainLayout_OrganizationSelector_viewer
}
organization: node(id: $organizationId) {
... on Organization {
id
name
logoUrl
}
}
}
`;
const OrganizationSelectorFragment = graphql`
fragment MainLayout_OrganizationSelector_viewer on Viewer
@refetchable(queryName: "MainLayoutOrganizationSelectorPaginationQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 25 }
after: { type: "CursorKey" }
) {
organizations(first: $first, after: $after, orderBy: {field: NAME, direction: ASC})
@connection(key: "MainLayout_OrganizationSelector_organizations") {
edges {
node {
id
name
logoUrl
}
}
pageInfo {
hasNextPage
endCursor
}
}
invitations(first: 1, filter: {onlyPending: true}) {
totalCount
}
}
`;
/**
* Site layout with a header and a sidebar
*/
export function MainLayout() {
const { organizationId } = useParams();
const { __ } = useTranslate();
const prefix = `/organizations/${organizationId}`;
if (!organizationId) {
return <Navigate to="/" />;
}
return (
<Layout
header={
<>
<div className="mr-auto">
<Suspense fallback={<Skeleton className="w-20 h-8" />}>
<OrganizationSelectorWrapper organizationId={organizationId} />
</Suspense>
</div>
<Suspense fallback={<Skeleton className="w-32 h-8" />}>
<UserDropdown organizationId={organizationId} />
</Suspense>
</>
}
sidebar={
<ul className="space-y-[2px]">
<SidebarItem
label={__("Tasks")}
icon={IconInboxEmpty}
to={`${prefix}/tasks`}
/>
<SidebarItem
label={__("Measures")}
icon={IconTodo}
to={`${prefix}/measures`}
/>
<SidebarItem
label={__("Risks")}
icon={IconFire3}
to={`${prefix}/risks`}
/>
<SidebarItem
label={__("Frameworks")}
icon={IconBank}
to={`${prefix}/frameworks`}
/>
<SidebarItem
label={__("People")}
icon={IconGroup1}
to={`${prefix}/people`}
/>
<SidebarItem
label={__("Vendors")}
icon={IconStore}
to={`${prefix}/vendors`}
/>
<SidebarItem
label={__("Documents")}
icon={IconPageTextLine}
to={`${prefix}/documents`}
/>
<SidebarItem
label={__("Assets")}
icon={IconBox}
to={`${prefix}/assets`}
/>
<SidebarItem
label={__("Data")}
icon={IconListStack}
to={`${prefix}/data`}
/>
<SidebarItem
label={__("Audits")}
icon={IconCheckmark1}
to={`${prefix}/audits`}
/>
<SidebarItem
label={__("Nonconformities")}
icon={IconCrossLargeX}
to={`${prefix}/nonconformities`}
/>
<SidebarItem
label={__("Obligations")}
icon={IconBook}
to={`${prefix}/obligations`}
/>
<SidebarItem
label={__("Continual Improvements")}
icon={IconRotateCw}
to={`${prefix}/continual-improvements`}
/>
<SidebarItem
label={__("Processing Activities")}
icon={IconCircleProgress}
to={`${prefix}/processing-activities`}
/>
<SidebarItem
label={__("Snapshots")}
icon={IconClock}
to={`${prefix}/snapshots`}
/>
<SidebarItem
label={__("Trust Center")}
icon={IconShield}
to={`${prefix}/trust-center`}
/>
<SidebarItem
label={__("Settings")}
icon={IconSettingsGear2}
to={`${prefix}/settings`}
/>
</ul>
}
>
<ErrorBoundary FallbackComponent={PageError}>
<Outlet />
</ErrorBoundary>
</Layout>
);
}
function UserDropdown({ organizationId }: { organizationId: string }) {
const { __ } = useTranslate();
const { toast } = useToast();
const user = useLazyLoadQuery<MainLayoutQueryType>(MainLayoutQuery, { organizationId }).viewer
.user;
const handleLogout: React.MouseEventHandler<HTMLAnchorElement> = async (
e
) => {
e.preventDefault();
fetch(buildEndpoint("/api/console/v1/auth/logout"), {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({}),
})
.then(async (res) => {
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || __("Failed to login"));
}
window.location.reload();
})
.catch((e) => {
toast({
title: __("Error"),
description: e.message as string,
variant: "error",
});
});
};
return (
<UserDropdownRoot fullName={user.fullName} email={user.email}>
<UserDropdownItem
to="mailto:support@getprobo.com"
icon={IconCircleQuestionmark}
label={__("Help")}
/>
<DropdownSeparator />
<UserDropdownItem
variant="danger"
to="/logout"
icon={IconArrowBoxLeft}
label="Logout"
onClick={handleLogout}
/>
</UserDropdownRoot>
);
}
function OrganizationSelectorWrapper({ organizationId }: { organizationId: string }) {
const data = useLazyLoadQuery<MainLayoutQueryType>(MainLayoutQuery, { organizationId });
return <OrganizationSelector viewer={data.viewer} currentOrganization={data.organization} />;
}
function OrganizationSelector({
viewer,
currentOrganization
}: {
viewer: MainLayout_OrganizationSelector_viewer$key;
currentOrganization: MainLayoutQueryType["response"]["organization"];
}) {
const [isLoadingMore, setIsLoadingMore] = useState(false);
const { __ } = useTranslate();
const { data, loadNext, hasNext } = usePaginationFragment(
OrganizationSelectorFragment,
viewer
);
const organizations = data.organizations.edges.map((edge) => edge.node);
const pendingInvitationsCount = data.invitations.totalCount;
const handleLoadMore = (e?: React.MouseEvent) => {
e?.preventDefault();
e?.stopPropagation();
if (hasNext && !isLoadingMore) {
setIsLoadingMore(true);
loadNext(25, {
onComplete: () => setIsLoadingMore(false),
});
}
};
return (
<div className="flex items-center gap-1">
<Dropdown
toggle={
<Button
className="-ml-3"
variant="tertiary"
iconAfter={IconChevronGrabberVertical}
>
{currentOrganization?.name || ""}
</Button>
}
>
<div className="max-h-150 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent hover:scrollbar-thumb-gray-400">
{organizations.map((organization) => (
<DropdownItem
asChild
key={organization.id}
>
<Link to={`/organizations/${organization.id}`}>
<Avatar src={organization.logoUrl} name={organization.name} />
{organization.name}
</Link>
</DropdownItem>
))}
{hasNext && (
<div className="px-3 py-1 flex justify-center">
<Button
variant="tertiary"
onClick={handleLoadMore}
onMouseDown={(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
}}
className="mx-auto"
icon={IconChevronDown}
disabled={isLoadingMore}
>
{isLoadingMore ? __("Loading...") : __("Show More")}
</Button>
</div>
)}
</div>
<DropdownSeparator />
{pendingInvitationsCount > 0 && (
<DropdownItem asChild>
<Link to="/">
<IconPeopleAdd size={16} />
<span className="flex-1">{__("Invitations")}</span>
<Badge variant="info" size="sm">
{pendingInvitationsCount}
</Badge>
</Link>
</DropdownItem>
)}
<DropdownItem asChild>
<Link to="/organizations/new">
<IconPlusLarge size={16} />
{__("Add organization")}
</Link>
</DropdownItem>
</Dropdown>
{pendingInvitationsCount > 0 && (
<Link to="/" className="relative" title={__("Invitations")}>
<Button variant="tertiary" icon={IconPeopleAdd} />
<Badge
variant="info"
size="sm"
className="absolute -top-1 -right-1 min-w-[20px] h-5 flex items-center justify-center"
>
{pendingInvitationsCount}
</Badge>
</Link>
)}
</div>
);
}