Add documents page to the compliance portal
Build the Trust Center documents page: a unified list of published documents, uploaded files, and audit reports, grouped into category sections. An All/Public/Private tab bar filters the list by trust center visibility. Expose that filter over the trust v1 API by adding a TrustCenterVisibility enum and a shared TrustCenterVisibilityFilter input, wiring it through the documents, audits, and trustCenterFiles connections down to the existing coredata SQL filters. "All" keeps the default public+private slice; the other tabs pin a single visibility. Access controls are display-only for now (auth is handled separately): authorized or public entries open their exported PDF via the export mutations, requested entries show a pending state, and everything else shows an inert Get Access affordance. Add the v2 Tabs and Toaster kit components (Base UI headless) needed by the page and mount a toast provider at the app root for mutation feedback. Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
@@ -18,6 +18,8 @@
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { Toast } from "@base-ui/react/toast";
|
||||
import { Toaster } from "@probo/ui/src/v2/Toaster/Toaster";
|
||||
import { RouterProvider } from "react-router";
|
||||
|
||||
import { RelayProvider } from "#/lib/relay/RelayProvider";
|
||||
@@ -26,7 +28,10 @@ import { router } from "#/routes";
|
||||
export function App() {
|
||||
return (
|
||||
<RelayProvider>
|
||||
<RouterProvider router={router} />
|
||||
<Toast.Provider>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster />
|
||||
</Toast.Provider>
|
||||
</RelayProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"common": {
|
||||
"error": "Something went wrong"
|
||||
},
|
||||
"topBar": {
|
||||
"tagline": "Compliance Portal",
|
||||
"getAccess": "Get Access",
|
||||
@@ -24,9 +27,6 @@
|
||||
"viewAll": "View all"
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
"title": "Documents"
|
||||
},
|
||||
"requests": {
|
||||
"title": "Data Requests",
|
||||
"newRequest": "New Request"
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"common": {
|
||||
"error": "Une erreur est survenue"
|
||||
},
|
||||
"topBar": {
|
||||
"tagline": "Portail de conformité",
|
||||
"getAccess": "Obtenir l'accès",
|
||||
@@ -24,9 +27,6 @@
|
||||
"viewAll": "Voir tout"
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
"title": "Documents"
|
||||
},
|
||||
"requests": {
|
||||
"title": "Demandes de données",
|
||||
"newRequest": "Nouvelle demande"
|
||||
|
||||
@@ -22,12 +22,16 @@ import type { PropsWithChildren } from "react";
|
||||
|
||||
import { headerBand } from "./variants";
|
||||
|
||||
export type HeaderBandProps = PropsWithChildren;
|
||||
export type HeaderBandProps = PropsWithChildren<{
|
||||
// Drop the band's bottom padding so trailing content (e.g. a tab bar) aligns
|
||||
// with the band's bottom border.
|
||||
flushBottomSpace?: boolean;
|
||||
}>;
|
||||
|
||||
// Pure layout shell: the white band + centered content column. Consumers (Hero,
|
||||
// PageHeader) supply a single content column and own its internal spacing.
|
||||
export function HeaderBand({ children }: HeaderBandProps) {
|
||||
const { band, inner } = headerBand();
|
||||
export function HeaderBand({ children, flushBottomSpace }: HeaderBandProps) {
|
||||
const { band, inner } = headerBand({ flushBottomSpace });
|
||||
|
||||
return (
|
||||
<header className={band()}>
|
||||
|
||||
@@ -25,7 +25,19 @@ import { tv } from "tailwind-variants/lite";
|
||||
// Hero and the page headers so the band is defined once.
|
||||
export const headerBand = tv({
|
||||
slots: {
|
||||
band: "flex w-full flex-col items-center border-b border-sand-a3 bg-sand-1 px-8 py-8",
|
||||
band: "flex w-full flex-col items-center border-b border-sand-a3 bg-sand-1 px-8",
|
||||
inner: "w-full max-w-5xl",
|
||||
},
|
||||
variants: {
|
||||
// Drops the bottom padding and pulls the content down 1px so a trailing
|
||||
// toolbar's own bottom border (e.g. an underlined tab bar) overlaps — and
|
||||
// paints over — the band's bottom border instead of stacking below it.
|
||||
flushBottomSpace: {
|
||||
true: { band: "pt-8", inner: "-mb-px" },
|
||||
false: { band: "py-8" },
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
flushBottomSpace: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -33,15 +33,18 @@ export interface PageHeaderProps {
|
||||
actions?: ReactNode;
|
||||
// Optional toolbar (tabs / filters / search) rendered below the title.
|
||||
children?: ReactNode;
|
||||
// Sit the toolbar flush on the band's bottom border (e.g. an underlined tab
|
||||
// bar whose divider doubles as the header divider).
|
||||
flushBottomSpace?: boolean;
|
||||
}
|
||||
|
||||
// Page header for the Trust Center nav pages: a size-7 title in the shared white
|
||||
// band, with an optional count, inline actions, and a toolbar slot below.
|
||||
export function PageHeader({ title, count, actions, children }: PageHeaderProps) {
|
||||
export function PageHeader({ title, count, actions, children, flushBottomSpace }: PageHeaderProps) {
|
||||
const { content, titleRow, count: countSlot } = pageHeader();
|
||||
|
||||
return (
|
||||
<HeaderBand>
|
||||
<HeaderBand flushBottomSpace={flushBottomSpace}>
|
||||
<div className={content()}>
|
||||
<div className={titleRow()}>
|
||||
<Heading level={1} size={7} weight="medium" highContrast>
|
||||
|
||||
179
apps/compliance-portal/src/pages/documents/DocumentsPage.tsx
Normal file
179
apps/compliance-portal/src/pages/documents/DocumentsPage.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { useEffect, useRef, useTransition } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { PreloadedQuery } from "react-relay";
|
||||
import { graphql, usePreloadedQuery, useRefetchableFragment } from "react-relay";
|
||||
|
||||
import { ListErrorBoundary } from "#/components/errors/ListErrorBoundary";
|
||||
import { PageHeader } from "#/components/PageHeader/PageHeader";
|
||||
|
||||
import type { DocumentsPage_query$key } from "./__generated__/DocumentsPage_query.graphql";
|
||||
import type { DocumentsPageQuery } from "./__generated__/DocumentsPageQuery.graphql";
|
||||
import type { DocumentsPageRefetchQuery } from "./__generated__/DocumentsPageRefetchQuery.graphql";
|
||||
import { AuditReportListItem } from "./_components/AuditReportListItem";
|
||||
import { DocumentListItem } from "./_components/DocumentListItem";
|
||||
import { DocumentSection } from "./_components/DocumentSection";
|
||||
import { DocumentsEmpty } from "./_components/DocumentsEmpty";
|
||||
import { DocumentsToolbar } from "./_components/DocumentsToolbar";
|
||||
import { TrustCenterFileListItem } from "./_components/TrustCenterFileListItem";
|
||||
import { groupByField } from "./_lib/groupByField";
|
||||
import { toQueryVariables } from "./_lib/toQueryVariables";
|
||||
import { useDocumentTab } from "./_lib/useDocumentTab";
|
||||
|
||||
export const documentsPageQuery = graphql`
|
||||
query DocumentsPageQuery($visibility: TrustCenterVisibility) {
|
||||
...DocumentsPage_query @arguments(visibility: $visibility)
|
||||
}
|
||||
`;
|
||||
|
||||
const documentsPageFragment = graphql`
|
||||
fragment DocumentsPage_query on Query
|
||||
@refetchable(queryName: "DocumentsPageRefetchQuery")
|
||||
@argumentDefinitions(visibility: { type: "TrustCenterVisibility" }) {
|
||||
currentTrustCenter @required(action: THROW) {
|
||||
documents(first: 250, filter: { visibility: $visibility }) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
documentType
|
||||
...DocumentListItem_document
|
||||
}
|
||||
}
|
||||
}
|
||||
audits(first: 250, filter: { visibility: $visibility }) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
reportFile {
|
||||
id
|
||||
}
|
||||
...AuditReportListItem_audit
|
||||
}
|
||||
}
|
||||
}
|
||||
trustCenterFiles(first: 250, filter: { visibility: $visibility }) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
category
|
||||
...TrustCenterFileListItem_file
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface DocumentsPageProps {
|
||||
queryRef: PreloadedQuery<DocumentsPageQuery>;
|
||||
}
|
||||
|
||||
// Trust Center documents page: a unified list of published documents, uploaded
|
||||
// files, and audit reports, grouped into category sections. The All/Public/
|
||||
// Private tabs are backed by a server-side visibility filter.
|
||||
export function DocumentsPage({ queryRef }: DocumentsPageProps) {
|
||||
const { t } = useTranslation("documents");
|
||||
const root = usePreloadedQuery<DocumentsPageQuery>(documentsPageQuery, queryRef);
|
||||
const [data, refetch] = useRefetchableFragment<DocumentsPageRefetchQuery, DocumentsPage_query$key>(
|
||||
documentsPageFragment,
|
||||
root,
|
||||
);
|
||||
|
||||
const { tab } = useDocumentTab();
|
||||
const [isRefetching, startTransition] = useTransition();
|
||||
|
||||
// The initial query already loaded with the URL's tab; only refetch on
|
||||
// subsequent tab changes, inside a transition so the toolbar and current
|
||||
// results stay mounted (dimmed via `isRefetching`) while the slice loads.
|
||||
const isFirstRender = useRef(true);
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
return;
|
||||
}
|
||||
startTransition(() => {
|
||||
refetch(toQueryVariables(tab), { fetchPolicy: "store-or-network" });
|
||||
});
|
||||
}, [refetch, tab]);
|
||||
|
||||
const { currentTrustCenter } = data;
|
||||
const documentNodes = currentTrustCenter.documents.edges.map(edge => edge.node);
|
||||
const fileNodes = currentTrustCenter.trustCenterFiles.edges.map(edge => edge.node);
|
||||
const auditNodes = currentTrustCenter.audits.edges
|
||||
.map(edge => edge.node)
|
||||
.filter(node => node.reportFile != null);
|
||||
|
||||
const total = documentNodes.length + fileNodes.length + auditNodes.length;
|
||||
|
||||
const documentGroups = groupByField(documentNodes, node => node.documentType)
|
||||
.sort((a, b) => t(`types.${a.key}`).localeCompare(t(`types.${b.key}`)));
|
||||
const fileGroups = groupByField(fileNodes, node => node.category)
|
||||
.sort((a, b) => a.key.localeCompare(b.key));
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={t("title")} count={total} flushBottomSpace>
|
||||
<DocumentsToolbar />
|
||||
</PageHeader>
|
||||
<div className="flex w-full flex-col items-center px-8 py-8">
|
||||
<div
|
||||
aria-busy={isRefetching}
|
||||
className={`flex w-full max-w-5xl flex-col gap-8 transition-opacity duration-150 ${isRefetching ? "opacity-60" : ""}`}
|
||||
>
|
||||
<ListErrorBoundary
|
||||
onRetry={done => startTransition(() => {
|
||||
refetch(toQueryVariables(tab), { fetchPolicy: "network-only", onComplete: done });
|
||||
})}
|
||||
>
|
||||
{total === 0
|
||||
? <DocumentsEmpty />
|
||||
: (
|
||||
<>
|
||||
{auditNodes.length > 0 && (
|
||||
<DocumentSection title={t("sections.reports")}>
|
||||
{auditNodes.map(node => (
|
||||
<AuditReportListItem key={node.id} auditKey={node} />
|
||||
))}
|
||||
</DocumentSection>
|
||||
)}
|
||||
{documentGroups.map(group => (
|
||||
<DocumentSection key={`type:${group.key}`} title={t(`types.${group.key}`)}>
|
||||
{group.nodes.map(node => (
|
||||
<DocumentListItem key={node.id} documentKey={node} />
|
||||
))}
|
||||
</DocumentSection>
|
||||
))}
|
||||
{fileGroups.map(group => (
|
||||
<DocumentSection key={`category:${group.key}`} title={group.key}>
|
||||
{group.nodes.map(node => (
|
||||
<TrustCenterFileListItem key={node.id} fileKey={node} />
|
||||
))}
|
||||
</DocumentSection>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</ListErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useQueryLoader } from "react-relay";
|
||||
|
||||
import type { DocumentsPageQuery } from "./__generated__/DocumentsPageQuery.graphql";
|
||||
import { toQueryVariables } from "./_lib/toQueryVariables";
|
||||
import { useDocumentTab } from "./_lib/useDocumentTab";
|
||||
import { DocumentsPage, documentsPageQuery } from "./DocumentsPage";
|
||||
import { DocumentsPageSkeleton } from "./DocumentsPageSkeleton";
|
||||
|
||||
export default function DocumentsPageLoader() {
|
||||
const { tab } = useDocumentTab();
|
||||
const [queryRef, loadQuery] = useQueryLoader<DocumentsPageQuery>(documentsPageQuery);
|
||||
|
||||
// Seed the first fetch with the URL's tab; later changes are handled by the
|
||||
// page's refetch.
|
||||
const initialVariables = useRef(toQueryVariables(tab));
|
||||
|
||||
useEffect(() => {
|
||||
loadQuery(initialVariables.current);
|
||||
}, [loadQuery]);
|
||||
|
||||
if (!queryRef) {
|
||||
return <DocumentsPageSkeleton />;
|
||||
}
|
||||
|
||||
return <DocumentsPage queryRef={queryRef} />;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { TabsSkeleton } from "@probo/ui/src/v2/Tabs/TabsSkeleton";
|
||||
import { HeadingSkeleton } from "@probo/ui/src/v2/typography/HeadingSkeleton";
|
||||
import { TextSkeleton } from "@probo/ui/src/v2/typography/TextSkeleton";
|
||||
|
||||
import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
|
||||
|
||||
const SECTION_PLACEHOLDERS = ["a", "b"];
|
||||
const ROW_PLACEHOLDERS = ["x", "y", "z"];
|
||||
|
||||
export function DocumentsPageSkeleton() {
|
||||
return (
|
||||
<>
|
||||
<HeaderBand flushBottomSpace>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<HeadingSkeleton size={7} className="w-64" />
|
||||
<TabsSkeleton />
|
||||
</div>
|
||||
</HeaderBand>
|
||||
<div className="flex w-full flex-col items-center px-8 py-8">
|
||||
<div className="flex w-full max-w-5xl flex-col gap-8">
|
||||
{SECTION_PLACEHOLDERS.map(section => (
|
||||
<div key={section} className="flex flex-col gap-3">
|
||||
<TextSkeleton size={3} className="w-40" />
|
||||
<div className="overflow-hidden rounded-4 border border-sand-a4 bg-sand-1">
|
||||
{ROW_PLACEHOLDERS.map(row => (
|
||||
<div key={row} className="h-16 animate-pulse border-b border-sand-a3 bg-sand-2 last:border-b-0" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { Text } from "@probo/ui/src/v2/typography/Text";
|
||||
import { graphql, useFragment } from "react-relay";
|
||||
|
||||
import { useMutation } from "#/lib/relay/useMutation";
|
||||
|
||||
import { openExportedFile } from "../_lib/openExportedFile";
|
||||
|
||||
import type { AuditReportListItem_audit$key } from "./__generated__/AuditReportListItem_audit.graphql";
|
||||
import type { AuditReportListItemExportMutation } from "./__generated__/AuditReportListItemExportMutation.graphql";
|
||||
import { DocumentAccessAction } from "./DocumentAccessAction";
|
||||
import { documentListItem } from "./variants";
|
||||
|
||||
const auditReportListItemFragment = graphql`
|
||||
fragment AuditReportListItem_audit on Audit @throwOnFieldError {
|
||||
framework {
|
||||
name
|
||||
}
|
||||
reportFile {
|
||||
id
|
||||
fileName
|
||||
isUserAuthorized
|
||||
access {
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const exportReportMutation = graphql`
|
||||
mutation AuditReportListItemExportMutation($input: ExportReportPDFInput!) {
|
||||
exportReportPDF(input: $input) {
|
||||
data
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface AuditReportListItemProps {
|
||||
auditKey: AuditReportListItem_audit$key;
|
||||
}
|
||||
|
||||
// A single audit report row: the framework name, the report file name, and an
|
||||
// access action that opens the exported report when the viewer is authorized.
|
||||
// Renders nothing when the audit has no report file.
|
||||
export function AuditReportListItem({ auditKey }: AuditReportListItemProps) {
|
||||
const audit = useFragment(auditReportListItemFragment, auditKey);
|
||||
const [exportReport, isExporting] = useMutation<AuditReportListItemExportMutation>(exportReportMutation);
|
||||
const { root, content } = documentListItem();
|
||||
|
||||
const report = audit.reportFile;
|
||||
if (report == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleView = () => {
|
||||
exportReport({
|
||||
variables: { input: { reportId: report.id } },
|
||||
onCompleted: response => openExportedFile(response.exportReportPDF.data),
|
||||
}).catch(() => {
|
||||
// The mutation failure is already surfaced through a toast.
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={root()}>
|
||||
<div className={content()}>
|
||||
<Text size={2} weight="medium" color="neutral" highContrast className="truncate">
|
||||
{audit.framework.name}
|
||||
</Text>
|
||||
<Text size={1} color="gold" className="truncate">
|
||||
{report.fileName}
|
||||
</Text>
|
||||
</div>
|
||||
<DocumentAccessAction
|
||||
isAuthorized={report.isUserAuthorized}
|
||||
requested={report.access?.status === "REQUESTED"}
|
||||
onView={handleView}
|
||||
isViewing={isExporting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { ArrowSquareOutIcon, ClockIcon, LockSimpleIcon } from "@phosphor-icons/react";
|
||||
import { Button } from "@probo/ui/src/v2/Button/Button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface DocumentAccessActionProps {
|
||||
// Whether the viewer may open the document (public or granted access).
|
||||
isAuthorized: boolean;
|
||||
// Whether an access request is already pending for this document.
|
||||
requested: boolean;
|
||||
// Opens the document; only invoked when authorized.
|
||||
onView: () => void;
|
||||
// Whether the export/open is in flight.
|
||||
isViewing: boolean;
|
||||
}
|
||||
|
||||
// Trailing access control for a document entry: "View" when authorized, a
|
||||
// pending label when access was requested, otherwise a (currently inert) "Get
|
||||
// Access" call to action.
|
||||
export function DocumentAccessAction({ isAuthorized, requested, onView, isViewing }: DocumentAccessActionProps) {
|
||||
const { t } = useTranslation("documents");
|
||||
|
||||
if (isAuthorized) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
highContrast
|
||||
iconStart={<ArrowSquareOutIcon />}
|
||||
loading={isViewing}
|
||||
onClick={onView}
|
||||
>
|
||||
{t("actions.view")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (requested) {
|
||||
return (
|
||||
<Button variant="ghost" color="neutral" disabled iconStart={<ClockIcon />}>
|
||||
{t("actions.requested")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="ghost" color="neutral" highContrast iconStart={<LockSimpleIcon />}>
|
||||
{t("actions.getAccess")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { Text } from "@probo/ui/src/v2/typography/Text";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { graphql, useFragment } from "react-relay";
|
||||
|
||||
import { useMutation } from "#/lib/relay/useMutation";
|
||||
|
||||
import { openExportedFile } from "../_lib/openExportedFile";
|
||||
|
||||
import type { DocumentListItem_document$key } from "./__generated__/DocumentListItem_document.graphql";
|
||||
import type { DocumentListItemExportMutation } from "./__generated__/DocumentListItemExportMutation.graphql";
|
||||
import { DocumentAccessAction } from "./DocumentAccessAction";
|
||||
import { documentListItem } from "./variants";
|
||||
|
||||
const documentListItemFragment = graphql`
|
||||
fragment DocumentListItem_document on Document @throwOnFieldError {
|
||||
id
|
||||
title
|
||||
documentType
|
||||
isUserAuthorized
|
||||
access {
|
||||
status
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const exportDocumentMutation = graphql`
|
||||
mutation DocumentListItemExportMutation($input: ExportDocumentPDFInput!) {
|
||||
exportDocumentPDF(input: $input) {
|
||||
data
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface DocumentListItemProps {
|
||||
documentKey: DocumentListItem_document$key;
|
||||
}
|
||||
|
||||
// A single Probo document row: title, its document type, and an access action
|
||||
// that opens the exported PDF when the viewer is authorized.
|
||||
export function DocumentListItem({ documentKey }: DocumentListItemProps) {
|
||||
const { t } = useTranslation("documents");
|
||||
const document = useFragment(documentListItemFragment, documentKey);
|
||||
const [exportDocument, isExporting] = useMutation<DocumentListItemExportMutation>(exportDocumentMutation);
|
||||
const { root, content } = documentListItem();
|
||||
|
||||
const handleView = () => {
|
||||
exportDocument({
|
||||
variables: { input: { documentId: document.id } },
|
||||
onCompleted: response => openExportedFile(response.exportDocumentPDF.data),
|
||||
}).catch(() => {
|
||||
// The mutation failure is already surfaced through a toast.
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={root()}>
|
||||
<div className={content()}>
|
||||
<Text size={2} weight="medium" color="neutral" highContrast className="truncate">
|
||||
{document.title}
|
||||
</Text>
|
||||
<Text size={1} color="gold" className="truncate">
|
||||
{t(`types.${document.documentType}`)}
|
||||
</Text>
|
||||
</div>
|
||||
<DocumentAccessAction
|
||||
isAuthorized={document.isUserAuthorized}
|
||||
requested={document.access?.status === "REQUESTED"}
|
||||
onView={handleView}
|
||||
isViewing={isExporting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { Text } from "@probo/ui/src/v2/typography/Text";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { documentSection } from "./variants";
|
||||
|
||||
interface DocumentSectionProps {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
// The list rows (document / file / report list items).
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// One category group: a localized header above a bordered list of rows.
|
||||
export function DocumentSection({ title, description, children }: DocumentSectionProps) {
|
||||
const { root, header, list } = documentSection();
|
||||
|
||||
return (
|
||||
<section className={root()}>
|
||||
<div className={header()}>
|
||||
<Text size={3} weight="medium" color="neutral" highContrast role="heading" aria-level={2}>
|
||||
{title}
|
||||
</Text>
|
||||
{description != null && (
|
||||
<Text size={2} color="neutral">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<div className={list()}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { FileTextIcon } from "@phosphor-icons/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { EmptyState } from "#/components/EmptyState/EmptyState";
|
||||
|
||||
import { useDocumentTab } from "../_lib/useDocumentTab";
|
||||
|
||||
// Empty state for the documents list. When a Public/Private tab is active it
|
||||
// notes the filter; otherwise it states the trust center publishes no documents.
|
||||
export function DocumentsEmpty() {
|
||||
const { t } = useTranslation("documents");
|
||||
const { tab } = useDocumentTab();
|
||||
const filtered = tab !== "all";
|
||||
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<FileTextIcon />}
|
||||
title={filtered ? t("empty.filteredTitle") : t("empty.title")}
|
||||
description={filtered ? t("empty.filteredDescription") : t("empty.description")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { Tabs } from "@probo/ui/src/v2/Tabs/Tabs";
|
||||
import { TabsIndicator } from "@probo/ui/src/v2/Tabs/TabsIndicator";
|
||||
import { TabsList } from "@probo/ui/src/v2/Tabs/TabsList";
|
||||
import { TabsTab } from "@probo/ui/src/v2/Tabs/TabsTab";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { DocumentTab } from "../_lib/useDocumentTab";
|
||||
import { DOCUMENT_TABS, useDocumentTab } from "../_lib/useDocumentTab";
|
||||
|
||||
// Access filter for the documents page: All / Public / Private tabs. Writes the
|
||||
// active tab to the URL; the page reacts and refetches the matching slice.
|
||||
export function DocumentsToolbar() {
|
||||
const { t } = useTranslation("documents");
|
||||
const { tab, setTab } = useDocumentTab();
|
||||
|
||||
return (
|
||||
<Tabs value={tab} onValueChange={value => setTab(value as DocumentTab)}>
|
||||
<TabsList>
|
||||
{DOCUMENT_TABS.map(value => (
|
||||
<TabsTab key={value} value={value}>
|
||||
{t(`tabs.${value}`)}
|
||||
</TabsTab>
|
||||
))}
|
||||
<TabsIndicator />
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { Text } from "@probo/ui/src/v2/typography/Text";
|
||||
import { graphql, useFragment } from "react-relay";
|
||||
|
||||
import { useMutation } from "#/lib/relay/useMutation";
|
||||
|
||||
import { openExportedFile } from "../_lib/openExportedFile";
|
||||
|
||||
import type { TrustCenterFileListItem_file$key } from "./__generated__/TrustCenterFileListItem_file.graphql";
|
||||
import type { TrustCenterFileListItemExportMutation } from "./__generated__/TrustCenterFileListItemExportMutation.graphql";
|
||||
import { DocumentAccessAction } from "./DocumentAccessAction";
|
||||
import { documentListItem } from "./variants";
|
||||
|
||||
const trustCenterFileListItemFragment = graphql`
|
||||
fragment TrustCenterFileListItem_file on TrustCenterFile @throwOnFieldError {
|
||||
id
|
||||
name
|
||||
category
|
||||
isUserAuthorized
|
||||
access {
|
||||
status
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const exportTrustCenterFileMutation = graphql`
|
||||
mutation TrustCenterFileListItemExportMutation($input: ExportTrustCenterFileInput!) {
|
||||
exportTrustCenterFile(input: $input) {
|
||||
data
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface TrustCenterFileListItemProps {
|
||||
fileKey: TrustCenterFileListItem_file$key;
|
||||
}
|
||||
|
||||
// A single uploaded trust-center file row: name, its category, and an access
|
||||
// action that opens the exported file when the viewer is authorized.
|
||||
export function TrustCenterFileListItem({ fileKey }: TrustCenterFileListItemProps) {
|
||||
const file = useFragment(trustCenterFileListItemFragment, fileKey);
|
||||
const [exportFile, isExporting] = useMutation<TrustCenterFileListItemExportMutation>(exportTrustCenterFileMutation);
|
||||
const { root, content } = documentListItem();
|
||||
|
||||
const handleView = () => {
|
||||
exportFile({
|
||||
variables: { input: { trustCenterFileId: file.id } },
|
||||
onCompleted: response => openExportedFile(response.exportTrustCenterFile.data),
|
||||
}).catch(() => {
|
||||
// The mutation failure is already surfaced through a toast.
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={root()}>
|
||||
<div className={content()}>
|
||||
<Text size={2} weight="medium" color="neutral" highContrast className="truncate">
|
||||
{file.name}
|
||||
</Text>
|
||||
<Text size={1} color="gold" className="truncate">
|
||||
{file.category}
|
||||
</Text>
|
||||
</div>
|
||||
<DocumentAccessAction
|
||||
isAuthorized={file.isUserAuthorized}
|
||||
requested={file.access?.status === "REQUESTED"}
|
||||
onView={handleView}
|
||||
isViewing={isExporting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { tv } from "tailwind-variants/lite";
|
||||
|
||||
// Document list: category sections, each a titled block above a bordered list.
|
||||
// Entries share a common layout (title + accent metadata + trailing access
|
||||
// action).
|
||||
|
||||
export const documentSection = tv({
|
||||
slots: {
|
||||
root: "flex flex-col gap-3",
|
||||
header: "flex flex-col gap-0.5",
|
||||
list: "overflow-hidden rounded-4 border border-sand-a4 bg-sand-1",
|
||||
},
|
||||
});
|
||||
|
||||
export const documentListItem = tv({
|
||||
slots: {
|
||||
root: "flex items-center gap-4 border-b border-sand-a3 px-4 py-3 last:border-b-0",
|
||||
content: "flex min-w-0 flex-1 flex-col gap-0.5",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
export interface FieldGroup<T> {
|
||||
key: string;
|
||||
nodes: T[];
|
||||
}
|
||||
|
||||
// Groups nodes by a derived string key, preserving first-seen order of the
|
||||
// keys. Presentational only — the server already applied the visibility filter.
|
||||
export function groupByField<T>(
|
||||
nodes: readonly T[],
|
||||
getKey: (node: T) => string,
|
||||
): FieldGroup<T>[] {
|
||||
const groups = new Map<string, T[]>();
|
||||
|
||||
for (const node of nodes) {
|
||||
const key = getKey(node);
|
||||
const existing = groups.get(key);
|
||||
if (existing) {
|
||||
existing.push(node);
|
||||
} else {
|
||||
groups.set(key, [node]);
|
||||
}
|
||||
}
|
||||
|
||||
return [...groups.entries()].map(([key, groupNodes]) => ({ key, nodes: groupNodes }));
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
// Opens a base64 data URI (returned by the export mutations) in a new browser
|
||||
// tab. Decodes to a Blob so the object URL carries the right MIME type and the
|
||||
// browser previews the PDF inline instead of navigating to a huge data: URL.
|
||||
export function openExportedFile(dataUri: string): void {
|
||||
const commaIndex = dataUri.indexOf(",");
|
||||
const base64 = commaIndex === -1 ? dataUri : dataUri.slice(commaIndex + 1);
|
||||
const mimeMatch = dataUri.match(/^data:([^;]+);/);
|
||||
const mimeType = mimeMatch?.[1] ?? "application/octet-stream";
|
||||
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
|
||||
const blob = new Blob([bytes], { type: mimeType });
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
window.open(objectUrl, "_blank", "noopener,noreferrer");
|
||||
|
||||
// Give the new tab time to claim the URL before releasing it.
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import type { DocumentsPageQuery$variables } from "../__generated__/DocumentsPageQuery.graphql";
|
||||
|
||||
import type { DocumentTab } from "./useDocumentTab";
|
||||
|
||||
// Maps the active tab to the typed GraphQL visibility variable. "All" omits the
|
||||
// filter (both public and private); the other tabs pin a single visibility so
|
||||
// the server returns just that slice.
|
||||
export function toQueryVariables(tab: DocumentTab): DocumentsPageQuery$variables {
|
||||
switch (tab) {
|
||||
case "public":
|
||||
return { visibility: "PUBLIC" };
|
||||
case "private":
|
||||
return { visibility: "PRIVATE" };
|
||||
default:
|
||||
return { visibility: null };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
|
||||
export type DocumentTab = "all" | "public" | "private";
|
||||
|
||||
export const DOCUMENT_TABS: readonly DocumentTab[] = ["all", "public", "private"];
|
||||
|
||||
interface DocumentTabState {
|
||||
tab: DocumentTab;
|
||||
setTab: (value: DocumentTab) => void;
|
||||
}
|
||||
|
||||
// Active documents tab (All / Public / Private), persisted in the URL so it is
|
||||
// shareable and survives reloads. Pure URL state — no local state or effects —
|
||||
// so the loader, page, and toolbar can all read it without racing.
|
||||
export function useDocumentTab(): DocumentTabState {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const raw = searchParams.get("tab");
|
||||
const tab: DocumentTab = raw === "public" || raw === "private" ? raw : "all";
|
||||
|
||||
const setTab = useCallback((value: DocumentTab) => {
|
||||
setSearchParams((previous) => {
|
||||
const next = new URLSearchParams(previous);
|
||||
if (value === "all") {
|
||||
next.delete("tab");
|
||||
} else {
|
||||
next.set("tab", value);
|
||||
}
|
||||
return next;
|
||||
}, { replace: true });
|
||||
}, [setSearchParams]);
|
||||
|
||||
return { tab, setTab };
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"title": "Documents",
|
||||
"tabs": {
|
||||
"all": "All",
|
||||
"public": "Public",
|
||||
"private": "Private"
|
||||
},
|
||||
"sections": {
|
||||
"reports": "Compliance reports"
|
||||
},
|
||||
"types": {
|
||||
"OTHER": "Other",
|
||||
"GOVERNANCE": "Governance",
|
||||
"POLICY": "Policies",
|
||||
"PROCEDURE": "Procedures",
|
||||
"PLAN": "Plans",
|
||||
"REGISTER": "Registers",
|
||||
"RECORD": "Records",
|
||||
"REPORT": "Reports",
|
||||
"TEMPLATE": "Templates",
|
||||
"STATEMENT_OF_APPLICABILITY": "Statement of Applicability"
|
||||
},
|
||||
"actions": {
|
||||
"view": "View",
|
||||
"getAccess": "Get Access",
|
||||
"requested": "Access requested"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No documents available",
|
||||
"description": "This trust center has not published any documents yet.",
|
||||
"filteredTitle": "No documents match this filter",
|
||||
"filteredDescription": "Try a different tab to see available documents."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"title": "Documents",
|
||||
"tabs": {
|
||||
"all": "Tous",
|
||||
"public": "Public",
|
||||
"private": "Privé"
|
||||
},
|
||||
"sections": {
|
||||
"reports": "Rapports de conformité"
|
||||
},
|
||||
"types": {
|
||||
"OTHER": "Autres",
|
||||
"GOVERNANCE": "Gouvernance",
|
||||
"POLICY": "Politiques",
|
||||
"PROCEDURE": "Procédures",
|
||||
"PLAN": "Plans",
|
||||
"REGISTER": "Registres",
|
||||
"RECORD": "Enregistrements",
|
||||
"REPORT": "Rapports",
|
||||
"TEMPLATE": "Modèles",
|
||||
"STATEMENT_OF_APPLICABILITY": "Déclaration d'applicabilité"
|
||||
},
|
||||
"actions": {
|
||||
"view": "Consulter",
|
||||
"getAccess": "Obtenir l'accès",
|
||||
"requested": "Accès demandé"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Aucun document disponible",
|
||||
"description": "Ce trust center n'a pas encore publié de documents.",
|
||||
"filteredTitle": "Aucun document ne correspond à ce filtre",
|
||||
"filteredDescription": "Essayez un autre onglet pour voir les documents disponibles."
|
||||
}
|
||||
}
|
||||
@@ -18,13 +18,15 @@
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { lazy } from "@probo/react-lazy";
|
||||
import type { AppRoute } from "@probo/routes";
|
||||
|
||||
import { PageHeader } from "#/components/PageHeader/PageHeader";
|
||||
import { DocumentsPageSkeleton } from "./DocumentsPageSkeleton";
|
||||
|
||||
// Toolbar (All/Public/Private tabs, framework filter, search) and the document
|
||||
// count are deferred until the v2 Tabs/Select/TextField components exist.
|
||||
export default function DocumentsPage() {
|
||||
const { t } = useTranslation();
|
||||
return <PageHeader title={t("documents.title")} />;
|
||||
}
|
||||
export const documentRoutes = [
|
||||
{
|
||||
path: "documents",
|
||||
Fallback: DocumentsPageSkeleton,
|
||||
Component: lazy(() => import("./DocumentsPageLoader")),
|
||||
},
|
||||
] satisfies AppRoute[];
|
||||
@@ -25,6 +25,7 @@ import { createBrowserRouter } from "react-router";
|
||||
import { PageErrorBoundary } from "#/components/errors/PageErrorBoundary";
|
||||
import { RootErrorBoundary } from "#/components/errors/RootErrorBoundary";
|
||||
import { getPathPrefix } from "#/lib/http/pathPrefix";
|
||||
import { documentRoutes } from "#/pages/documents/routes";
|
||||
import { HomePageSkeleton } from "#/pages/HomePageSkeleton";
|
||||
import { MainLayoutSkeleton } from "#/pages/MainLayoutSkeleton";
|
||||
import { subprocessorRoutes } from "#/pages/subprocessors/routes";
|
||||
@@ -48,10 +49,7 @@ const routes = [
|
||||
Fallback: HomePageSkeleton,
|
||||
Component: lazy(() => import("#/pages/HomePageLoader")),
|
||||
},
|
||||
{
|
||||
path: "documents",
|
||||
Component: lazy(() => import("#/pages/DocumentsPage")),
|
||||
},
|
||||
...documentRoutes,
|
||||
...subprocessorRoutes,
|
||||
...updateRoutes,
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user