Add PageTemplate with skeletons and apply to Framework list

Signed-off-by: Émile Ré <nemile.re@gmail.com>
This commit is contained in:
Émile Ré
2025-03-21 13:42:39 +04:00
parent cee114089a
commit 51e4d7488f
21 changed files with 323 additions and 201 deletions

View File

@@ -12,6 +12,7 @@ import ConsoleLayout from "./layouts/ConsoleLayout";
import AuthLayout from "./layouts/AuthLayout";
import { RelayEnvironment } from "./RelayEnvironment";
import VisitorErrorBoundary from "./components/VisitorErrorBoundary";
import { FrameworkListPageSkeleton } from "./pages/FrameworkListPage";
posthog.init(process.env.POSTHOG_KEY!, {
api_host: process.env.POSTHOG_HOST,
@@ -145,7 +146,7 @@ function App() {
<Route
path="frameworks"
element={
<Suspense>
<Suspense fallback={<FrameworkListPageSkeleton />}>
<ErrorBoundaryWithLocation>
<FrameworkListPage />
</ErrorBoundaryWithLocation>

View File

@@ -0,0 +1,26 @@
import { FC, ReactNode } from "react";
import { Helmet } from "react-helmet-async";
export interface PageContainerProps {
children: ReactNode;
title: string;
}
export const PageContainer: FC<PageContainerProps> = ({ children, title }) => {
return (
<>
<Helmet>{`${title} - Probo Console`}</Helmet>
<div className="container">{children}</div>
</>
);
};
export const PageContainerSkeleton: FC<{
title?: string;
children: ReactNode;
}> = ({ children, title }) =>
title ? (
<PageContainer title={title}>{children}</PageContainer>
) : (
<div className="container">{children}</div>
);

View File

@@ -0,0 +1,108 @@
import { cn } from "@/lib/utils";
import { FC, ReactNode } from "react";
export interface PageHeaderProps {
title: ReactNode;
actions?: ReactNode;
description?: ReactNode;
}
export function PageHeaderShell({
className,
title,
actions,
description,
}: PageHeaderProps & { className?: string }) {
return (
<div className={cn("space-y-6", className)}>
{actions ? (
<div className="flex items-center justify-between">
{title}
{actions}
</div>
) : (
title
)}
{description}
</div>
);
}
export function PageHeader({
className,
title,
actions,
description,
}: PageHeaderProps & { className?: string }) {
return (
<PageHeaderShell
className={className}
title={<PageHeading>{title}</PageHeading>}
description={<PageDescription>{description}</PageDescription>}
actions={actions}
/>
);
}
export function PageHeading({
children,
className,
}: {
className?: string;
children: ReactNode;
}) {
return <h1 className={cn("text-3xl font-medium", className)}>{children}</h1>;
}
const PageHeadingSkeleton: FC = () => {
return (
<div className="py-[3px]">
<div className="bg-muted animate rounded-lg w-60 h-7.5" />
</div>
);
};
export function PageDescription({
children,
className,
}: {
className?: string;
children: ReactNode;
}) {
return (
<p className={cn("text-md text-muted-foreground", className)}>{children}</p>
);
}
export function PageDescriptionSkeleton() {
return (
<div className="py-[5px]">
<div className="bg-muted animate rounded-md w-100 h-4.5" />
</div>
);
}
export const PageHeaderSkeleton: FC<{
className?: string;
title?: string;
actions?: ReactNode;
description?: ReactNode;
withDescription?: boolean;
}> = ({ className, title, actions, description, withDescription = false }) => {
return (
<PageHeaderShell
className={className}
title={
title ? <PageHeading>{title}</PageHeading> : <PageHeadingSkeleton />
}
description={
description ? (
<PageDescription>{description}</PageDescription>
) : withDescription ? (
<PageDescriptionSkeleton />
) : null
}
actions={actions}
/>
);
};

View File

@@ -0,0 +1,47 @@
import { FC, ReactNode } from "react";
import {
PageContainer,
PageContainerProps,
PageContainerSkeleton,
} from "./PageContainer";
import { PageHeader, PageHeaderProps, PageHeaderSkeleton } from "./PageHeader";
export const PageTemplate: FC<PageContainerProps & PageHeaderProps> = ({
actions,
title,
description,
children,
}) => {
return (
<PageContainer title={title}>
<PageHeader
className="mb-17"
title={title}
description={description}
actions={actions}
/>
{children}
</PageContainer>
);
};
export const PageTemplateSkeleton: FC<{
title?: string;
description?: ReactNode;
withDescription?: boolean;
actions?: ReactNode;
children: ReactNode;
}> = ({ actions, children, description, withDescription = false, title }) => {
return (
<PageContainerSkeleton title={title}>
<PageHeaderSkeleton
className="mb-17"
title={title}
description={description}
withDescription={withDescription}
actions={actions}
/>
{children}
</PageContainerSkeleton>
);
};

View File

@@ -75,7 +75,7 @@ import type { ControlOverviewPageAssignTaskMutation as ControlOverviewPageAssign
import type { ControlOverviewPageUnassignTaskMutation as ControlOverviewPageUnassignTaskMutationType } from "./__generated__/ControlOverviewPageUnassignTaskMutation.graphql";
import type { ControlOverviewPageOrganizationQuery$data } from "./__generated__/ControlOverviewPageOrganizationQuery.graphql";
import type { ControlOverviewPageUpdateControlStateMutation as ControlOverviewPageUpdateControlStateMutationType } from "./__generated__/ControlOverviewPageUpdateControlStateMutation.graphql";
import { PageHeader } from "./PageHeader";
import { PageHeader } from "@/components/PageHeader";
// Function to format ISO8601 duration to human-readable format
const formatDuration = (isoDuration: string): string => {

View File

@@ -21,7 +21,7 @@ import {
CreateControlPageCreateControlMutation,
ControlImportance,
} from "./__generated__/CreateControlPageCreateControlMutation.graphql";
import { PageHeader } from "./PageHeader";
import { PageHeader } from "@/components/PageHeader";
const createControlMutation = graphql`
mutation CreateControlPageCreateControlMutation(

View File

@@ -10,7 +10,7 @@ import { useToast } from "@/hooks/use-toast";
import { HelpCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { CreatePeoplePageCreatePeopleMutation } from "./__generated__/CreatePeoplePageCreatePeopleMutation.graphql";
import { PageHeader } from "./PageHeader";
import { PageHeader } from "@/components/PageHeader";
const createPeopleMutation = graphql`
mutation CreatePeoplePageCreatePeopleMutation(

View File

@@ -21,7 +21,7 @@ import PeopleSelector from "@/components/PeopleSelector";
import { Suspense } from "react";
import type { CreatePolicyPageMutation } from "./__generated__/CreatePolicyPageMutation.graphql";
import type { CreatePolicyPageQuery as CreatePolicyPageQueryType } from "./__generated__/CreatePolicyPageQuery.graphql";
import { PageHeader } from "./PageHeader";
import { PageHeader } from "@/components/PageHeader";
const CreatePolicyQuery = graphql`
query CreatePolicyPageQuery($organizationId: ID!) {

View File

@@ -10,7 +10,6 @@ import {
import { Card, CardContent } from "@/components/ui/card";
import { Link, useParams } from "react-router";
import type { FrameworkListPageQuery as FrameworkListPageQueryType } from "./__generated__/FrameworkListPageQuery.graphql";
import { Helmet } from "react-helmet-async";
import { Button } from "@/components/ui/button";
import { Plus, Upload } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
@@ -24,7 +23,7 @@ import {
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { FrameworkListPageImportFrameworkMutation as FrameworkListPageImportFrameworkMutationType } from "./__generated__/FrameworkListPageImportFrameworkMutation.graphql";
import { PageHeader } from "./PageHeader";
import { PageTemplate, PageTemplateSkeleton } from "@/components/PageTemplate";
const FrameworkListPageQuery = graphql`
query FrameworkListPageQuery($organizationId: ID!) {
@@ -191,111 +190,107 @@ function FrameworkListPageContent({
};
return (
<>
<Helmet>
<title>Frameworks - Probo</title>
</Helmet>
<div className="container">
<PageHeader
className="mb-17"
title="Frameworks"
description="Manage your compliance frameworks"
actions={
<div className="flex gap-4">
<Dialog
open={isImportDialogOpen}
onOpenChange={setIsImportDialogOpen}
>
<DialogTrigger asChild>
<Button variant="outline">
<Upload className="mr-2 h-4 w-4" />
Import Framework
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Import Framework</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="framework-file">
Upload Framework File
</Label>
<Input
id="framework-file"
type="file"
ref={fileInputRef}
onChange={handleFileChange}
disabled={isUploading}
accept=".json"
/>
<p className="text-sm text-muted-foreground">
Upload a JSON file containing your framework definition.
</p>
</div>
</div>
</DialogContent>
</Dialog>
<Button asChild>
<Link to={`/organizations/${organizationId}/frameworks/create`}>
<Plus className="mr-2 h-4 w-4" />
Create Framework
</Link>
<PageTemplate
title="Frameworks"
description="Manage your compliance frameworks"
actions={
<div className="flex gap-4">
<Dialog
open={isImportDialogOpen}
onOpenChange={setIsImportDialogOpen}
>
<DialogTrigger asChild>
<Button variant="outline">
<Upload className="mr-2 h-4 w-4" />
Import Framework
</Button>
</div>
}
/>
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-2">
{frameworks.map((framework) => {
const validatedControls = framework.controls.edges.filter(
(edge) => edge?.node?.state === "IMPLEMENTED"
).length;
const totalControls = framework.controls.edges.length;
return (
<Link
key={framework.id}
to={`/organizations/${organizationId}/frameworks/${framework.id}`}
>
<FrameworkCard
title={framework.name}
description={framework.description}
icon={
<div className="flex size-full items-center justify-center rounded-full bg-blue-100">
<span className="text-lg font-semibold text-blue-900">
{framework.name.split(" ")[0]}
</span>
</div>
}
status={
validatedControls === totalControls
? "Compliant"
: undefined
}
progress={
validatedControls === totalControls
? "All controls validated"
: `${validatedControls}/${totalControls} Controls validated`
}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Import Framework</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="framework-file">Upload Framework File</Label>
<Input
id="framework-file"
type="file"
ref={fileInputRef}
onChange={handleFileChange}
disabled={isUploading}
accept=".json"
/>
</Link>
);
})}
</div>
<p className="text-sm text-muted-foreground">
Upload a JSON file containing your framework definition.
</p>
</div>
</div>
</DialogContent>
</Dialog>
<Button asChild>
<Link to={`/organizations/${organizationId}/frameworks/create`}>
<Plus className="mr-2 h-4 w-4" />
Create Framework
</Link>
</Button>
</div>
}
>
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-2">
{frameworks.map((framework) => {
const validatedControls = framework.controls.edges.filter(
(edge) => edge?.node?.state === "IMPLEMENTED"
).length;
const totalControls = framework.controls.edges.length;
return (
<Link
key={framework.id}
to={`/organizations/${organizationId}/frameworks/${framework.id}`}
>
<FrameworkCard
title={framework.name}
description={framework.description}
icon={
<div className="flex size-full items-center justify-center rounded-full bg-blue-100">
<span className="text-lg font-semibold text-blue-900">
{framework.name.split(" ")[0]}
</span>
</div>
}
status={
validatedControls === totalControls
? "Compliant"
: undefined
}
progress={
validatedControls === totalControls
? "All controls validated"
: `${validatedControls}/${totalControls} Controls validated`
}
/>
</Link>
);
})}
</div>
</div>
</>
</PageTemplate>
);
}
function FrameworkListPageFallback() {
export function FrameworkListPageSkeleton() {
return (
<div className="space-y-6">
<div>
<div className="h-8 w-48 bg-muted animate-pulse rounded" />
<div className="h-4 w-96 bg-muted animate-pulse rounded mt-1" />
</div>
<PageTemplateSkeleton
title="Frameworks"
description="Manage your compliance frameworks"
actions={
<div className="flex gap-4 w-1/3">
<div className="bg-muted animate-pulse h-9 w-1/2 rounded-lg" />
<div className="bg-muted animate-pulse h-9 w-1/2 rounded-lg" />
</div>
}
>
<div className="grid gap-6 md:grid-cols-2">
{[1, 2].map((i) => (
<Card key={i} className="bg-card/50">
@@ -310,7 +305,7 @@ function FrameworkListPageFallback() {
</Card>
))}
</div>
</div>
</PageTemplateSkeleton>
);
}
@@ -326,13 +321,8 @@ export default function FrameworkListPage() {
}, [loadQuery, organizationId]);
return (
<>
<Helmet>
<title>Frameworks - Probo Console</title>
</Helmet>
<Suspense fallback={<FrameworkListPageFallback />}>
{queryRef && <FrameworkListPageContent queryRef={queryRef} />}
</Suspense>
</>
<Suspense fallback={<FrameworkListPageSkeleton />}>
{queryRef && <FrameworkListPageContent queryRef={queryRef} />}
</Suspense>
);
}

View File

@@ -11,7 +11,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import type { FrameworkOverviewPageQuery as FrameworkOverviewPageQueryType } from "./__generated__/FrameworkOverviewPageQuery.graphql";
import { Helmet } from "react-helmet-async";
import { PageHeader } from "./PageHeader";
import { PageHeader } from "@/components/PageHeader";
const FrameworkOverviewPageQuery = graphql`
query FrameworkOverviewPageQuery($frameworkId: ID!) {

View File

@@ -1,53 +0,0 @@
import { cn } from "@/lib/utils";
import { ReactNode } from "react";
export function PageHeader({
className,
children,
title,
actions,
description,
}: {
className?: string;
children?: ReactNode;
title: string;
actions?: ReactNode;
description?: string;
}) {
return (
<div className={cn("space-y-6", className)}>
{actions ? (
<div className="flex items-center justify-between">
<PageHeading title={title} />
{actions}
</div>
) : (
<PageHeading title={title} />
)}
{description && <PageDescription>{description}</PageDescription>}
{children}
</div>
);
}
export function PageHeading({
title,
className,
}: {
className?: string;
title: string;
}) {
return <h1 className={cn("text-3xl font-medium", className)}>{title}</h1>;
}
export function PageDescription({
children,
className,
}: {
className?: string;
children: ReactNode;
}) {
return (
<p className={cn("text-lg text-muted-foreground", className)}>{children}</p>
);
}

View File

@@ -18,7 +18,7 @@ import type { PeopleListPageQuery as PeopleListPageQueryType } from "./__generat
import type { PeopleListPageDeletePeopleMutation } from "./__generated__/PeopleListPageDeletePeopleMutation.graphql";
import { PeopleListPagePaginationQuery } from "./__generated__/PeopleListPagePaginationQuery.graphql";
import { PeopleListPage_peoples$key } from "./__generated__/PeopleListPage_peoples.graphql";
import { PageHeader } from "./PageHeader";
import { PageHeader } from "@/components/PageHeader";
const ITEMS_PER_PAGE = 25;

View File

@@ -18,7 +18,7 @@ import type { PeopleOverviewPageQuery as PeopleOverviewPageQueryType } from "./_
import { useParams } from "react-router";
import { Helmet } from "react-helmet-async";
import { cn } from "@/lib/utils";
import { PageHeader } from "./PageHeader";
import { PageHeader } from "@/components/PageHeader";
const peopleOverviewPageQuery = graphql`
query PeopleOverviewPageQuery($peopleId: ID!) {

View File

@@ -34,7 +34,7 @@ import {
import { Badge } from "@/components/ui/badge";
import { format } from "date-fns";
import type { PolicyListPageQuery as PolicyListPageQueryType } from "./__generated__/PolicyListPageQuery.graphql";
import { PageHeader } from "./PageHeader";
import { PageHeader } from "@/components/PageHeader";
const PolicyListPageQuery = graphql`
query PolicyListPageQuery($organizationId: ID!) {

View File

@@ -18,7 +18,7 @@ import type { PolicyOverviewPageDeleteMutation } from "./__generated__/PolicyOve
import { Helmet } from "react-helmet-async";
import "../styles/policy-content.css";
import { useToast } from "@/hooks/use-toast";
import { PageHeader } from "./PageHeader";
import { PageHeader } from "@/components/PageHeader";
const PolicyOverviewPageQuery = graphql`
query PolicyOverviewPageQuery($policyId: ID!) {
@@ -157,6 +157,32 @@ function PolicyOverviewPageContent({
<PageHeader
className="mb-17"
title={policy.name ?? ""}
description={
<div className="flex items-center gap-2 text-muted-foreground">
<Badge
variant={policy.status === "ACTIVE" ? "default" : "outline"}
>
{policy.status === "ACTIVE" ? "Active" : "Draft"}
</Badge>
{policy.owner && (
<div className="flex items-center gap-1">
<User className="h-3 w-3" />
<Link
to={`/organizations/${organizationId}/people/${policy.owner.id}`}
className="hover:underline"
>
{policy.owner.fullName}
</Link>
</div>
)}
{policy.reviewDate && (
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
<span>Review: {formatDate(policy.reviewDate)}</span>
</div>
)}
</div>
}
actions={
<div className="flex gap-2">
<Button variant="outline" size="sm" asChild>
@@ -188,30 +214,7 @@ function PolicyOverviewPageContent({
</Button>
</div>
}
>
<div className="flex items-center gap-2 text-muted-foreground">
<Badge variant={policy.status === "ACTIVE" ? "default" : "outline"}>
{policy.status === "ACTIVE" ? "Active" : "Draft"}
</Badge>
{policy.owner && (
<div className="flex items-center gap-1">
<User className="h-3 w-3" />
<Link
to={`/organizations/${organizationId}/people/${policy.owner.id}`}
className="hover:underline"
>
{policy.owner.fullName}
</Link>
</div>
)}
{policy.reviewDate && (
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
<span>Review: {formatDate(policy.reviewDate)}</span>
</div>
)}
</div>
</PageHeader>
/>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">

View File

@@ -39,7 +39,7 @@ import type { SettingsPageQuery as SettingsPageQueryType } from "./__generated__
import type { SettingsPageUpdateOrganizationMutation as SettingsPageUpdateOrganizationMutationType } from "./__generated__/SettingsPageUpdateOrganizationMutation.graphql";
import type { SettingsPageInviteUserMutation as SettingsPageInviteUserMutationType } from "./__generated__/SettingsPageInviteUserMutation.graphql";
import type { SettingsPageRemoveUserMutation as SettingsPageRemoveUserMutationType } from "./__generated__/SettingsPageRemoveUserMutation.graphql";
import { PageHeader } from "./PageHeader";
import { PageHeader } from "@/components/PageHeader";
const settingsPageQuery = graphql`
query SettingsPageQuery($organizationID: ID!) {

View File

@@ -28,7 +28,7 @@ import type {
ControlState,
ControlImportance,
} from "./__generated__/UpdateControlPageUpdateControlMutation.graphql";
import { PageHeader } from "./PageHeader";
import { PageHeader } from "@/components/PageHeader";
const updateControlMutation = graphql`
mutation UpdateControlPageUpdateControlMutation($input: UpdateControlInput!) {

View File

@@ -18,7 +18,7 @@ import { HelpCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { UpdateFrameworkPageUpdateFrameworkMutation } from "./__generated__/UpdateFrameworkPageUpdateFrameworkMutation.graphql";
import { UpdateFrameworkPageQuery as UpdateFrameworkPageQueryType } from "./__generated__/UpdateFrameworkPageQuery.graphql";
import { PageHeader } from "./PageHeader";
import { PageHeader } from "@/components/PageHeader";
const updateFrameworkMutation = graphql`
mutation UpdateFrameworkPageUpdateFrameworkMutation(

View File

@@ -20,7 +20,7 @@ import PolicyEditor from "@/components/PolicyEditor";
import PeopleSelector from "@/components/PeopleSelector";
import type { UpdatePolicyPageQuery as UpdatePolicyPageQueryType } from "./__generated__/UpdatePolicyPageQuery.graphql";
import type { UpdatePolicyPageMutation as UpdatePolicyPageMutationType } from "./__generated__/UpdatePolicyPageMutation.graphql";
import { PageHeader } from "./PageHeader";
import { PageHeader } from "@/components/PageHeader";
const UpdatePolicyPageQuery = graphql`
query UpdatePolicyPageQuery($policyId: ID!, $organizationId: ID!) {

View File

@@ -22,7 +22,7 @@ import { VendorListPageDeleteVendorMutation } from "./__generated__/VendorListPa
import { VendorListPagePaginationQuery } from "./__generated__/VendorListPagePaginationQuery.graphql";
import { VendorListPage_vendors$key } from "./__generated__/VendorListPage_vendors.graphql";
import { useToast } from "@/hooks/use-toast";
import { PageHeader } from "./PageHeader";
import { PageHeader } from "@/components/PageHeader";
const ITEMS_PER_PAGE = 25;

View File

@@ -18,7 +18,7 @@ import type { VendorOverviewPageQuery as VendorOverviewPageQueryType } from "./_
import { useParams } from "react-router";
import { Helmet } from "react-helmet-async";
import { cn } from "@/lib/utils";
import { PageHeader } from "./PageHeader";
import { PageHeader } from "@/components/PageHeader";
const vendorOverviewPageQuery = graphql`
query VendorOverviewPageQuery($vendorId: ID!) {