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
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
|
import { Toast } from "@base-ui/react/toast";
|
||||||
|
import { Toaster } from "@probo/ui/src/v2/Toaster/Toaster";
|
||||||
import { RouterProvider } from "react-router";
|
import { RouterProvider } from "react-router";
|
||||||
|
|
||||||
import { RelayProvider } from "#/lib/relay/RelayProvider";
|
import { RelayProvider } from "#/lib/relay/RelayProvider";
|
||||||
@@ -26,7 +28,10 @@ import { router } from "#/routes";
|
|||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<RelayProvider>
|
<RelayProvider>
|
||||||
<RouterProvider router={router} />
|
<Toast.Provider>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
<Toaster />
|
||||||
|
</Toast.Provider>
|
||||||
</RelayProvider>
|
</RelayProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
|
"common": {
|
||||||
|
"error": "Something went wrong"
|
||||||
|
},
|
||||||
"topBar": {
|
"topBar": {
|
||||||
"tagline": "Compliance Portal",
|
"tagline": "Compliance Portal",
|
||||||
"getAccess": "Get Access",
|
"getAccess": "Get Access",
|
||||||
@@ -24,9 +27,6 @@
|
|||||||
"viewAll": "View all"
|
"viewAll": "View all"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"documents": {
|
|
||||||
"title": "Documents"
|
|
||||||
},
|
|
||||||
"requests": {
|
"requests": {
|
||||||
"title": "Data Requests",
|
"title": "Data Requests",
|
||||||
"newRequest": "New Request"
|
"newRequest": "New Request"
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
|
"common": {
|
||||||
|
"error": "Une erreur est survenue"
|
||||||
|
},
|
||||||
"topBar": {
|
"topBar": {
|
||||||
"tagline": "Portail de conformité",
|
"tagline": "Portail de conformité",
|
||||||
"getAccess": "Obtenir l'accès",
|
"getAccess": "Obtenir l'accès",
|
||||||
@@ -24,9 +27,6 @@
|
|||||||
"viewAll": "Voir tout"
|
"viewAll": "Voir tout"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"documents": {
|
|
||||||
"title": "Documents"
|
|
||||||
},
|
|
||||||
"requests": {
|
"requests": {
|
||||||
"title": "Demandes de données",
|
"title": "Demandes de données",
|
||||||
"newRequest": "Nouvelle demande"
|
"newRequest": "Nouvelle demande"
|
||||||
|
|||||||
@@ -22,12 +22,16 @@ import type { PropsWithChildren } from "react";
|
|||||||
|
|
||||||
import { headerBand } from "./variants";
|
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,
|
// Pure layout shell: the white band + centered content column. Consumers (Hero,
|
||||||
// PageHeader) supply a single content column and own its internal spacing.
|
// PageHeader) supply a single content column and own its internal spacing.
|
||||||
export function HeaderBand({ children }: HeaderBandProps) {
|
export function HeaderBand({ children, flushBottomSpace }: HeaderBandProps) {
|
||||||
const { band, inner } = headerBand();
|
const { band, inner } = headerBand({ flushBottomSpace });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={band()}>
|
<header className={band()}>
|
||||||
|
|||||||
@@ -25,7 +25,19 @@ import { tv } from "tailwind-variants/lite";
|
|||||||
// Hero and the page headers so the band is defined once.
|
// Hero and the page headers so the band is defined once.
|
||||||
export const headerBand = tv({
|
export const headerBand = tv({
|
||||||
slots: {
|
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",
|
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;
|
actions?: ReactNode;
|
||||||
// Optional toolbar (tabs / filters / search) rendered below the title.
|
// Optional toolbar (tabs / filters / search) rendered below the title.
|
||||||
children?: ReactNode;
|
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
|
// 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.
|
// 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();
|
const { content, titleRow, count: countSlot } = pageHeader();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HeaderBand>
|
<HeaderBand flushBottomSpace={flushBottomSpace}>
|
||||||
<div className={content()}>
|
<div className={content()}>
|
||||||
<div className={titleRow()}>
|
<div className={titleRow()}>
|
||||||
<Heading level={1} size={7} weight="medium" highContrast>
|
<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."
|
||||||
|
}
|
||||||
|
}
|
||||||
32
apps/compliance-portal/src/pages/documents/routes.ts
Normal file
32
apps/compliance-portal/src/pages/documents/routes.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// 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 { lazy } from "@probo/react-lazy";
|
||||||
|
import type { AppRoute } from "@probo/routes";
|
||||||
|
|
||||||
|
import { DocumentsPageSkeleton } from "./DocumentsPageSkeleton";
|
||||||
|
|
||||||
|
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 { PageErrorBoundary } from "#/components/errors/PageErrorBoundary";
|
||||||
import { RootErrorBoundary } from "#/components/errors/RootErrorBoundary";
|
import { RootErrorBoundary } from "#/components/errors/RootErrorBoundary";
|
||||||
import { getPathPrefix } from "#/lib/http/pathPrefix";
|
import { getPathPrefix } from "#/lib/http/pathPrefix";
|
||||||
|
import { documentRoutes } from "#/pages/documents/routes";
|
||||||
import { HomePageSkeleton } from "#/pages/HomePageSkeleton";
|
import { HomePageSkeleton } from "#/pages/HomePageSkeleton";
|
||||||
import { MainLayoutSkeleton } from "#/pages/MainLayoutSkeleton";
|
import { MainLayoutSkeleton } from "#/pages/MainLayoutSkeleton";
|
||||||
import { subprocessorRoutes } from "#/pages/subprocessors/routes";
|
import { subprocessorRoutes } from "#/pages/subprocessors/routes";
|
||||||
@@ -48,10 +49,7 @@ const routes = [
|
|||||||
Fallback: HomePageSkeleton,
|
Fallback: HomePageSkeleton,
|
||||||
Component: lazy(() => import("#/pages/HomePageLoader")),
|
Component: lazy(() => import("#/pages/HomePageLoader")),
|
||||||
},
|
},
|
||||||
{
|
...documentRoutes,
|
||||||
path: "documents",
|
|
||||||
Component: lazy(() => import("#/pages/DocumentsPage")),
|
|
||||||
},
|
|
||||||
...subprocessorRoutes,
|
...subprocessorRoutes,
|
||||||
...updateRoutes,
|
...updateRoutes,
|
||||||
{
|
{
|
||||||
|
|||||||
70
packages/ui/src/v2/Tabs/Tabs.stories.tsx
Normal file
70
packages/ui/src/v2/Tabs/Tabs.stories.tsx
Normal file
@@ -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 { useState } from "react";
|
||||||
|
|
||||||
|
import { Tabs } from "./Tabs";
|
||||||
|
import { TabsIndicator } from "./TabsIndicator";
|
||||||
|
import { TabsList } from "./TabsList";
|
||||||
|
import { TabsSkeleton } from "./TabsSkeleton";
|
||||||
|
import { TabsTab } from "./TabsTab";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "v2/Tabs",
|
||||||
|
component: Tabs,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Default() {
|
||||||
|
return (
|
||||||
|
<Tabs defaultValue="all">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTab value="all">All</TabsTab>
|
||||||
|
<TabsTab value="public">Public</TabsTab>
|
||||||
|
<TabsTab value="private">Private</TabsTab>
|
||||||
|
<TabsIndicator />
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Controlled() {
|
||||||
|
const [value, setValue] = useState<string>("all");
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Tabs value={value} onValueChange={next => setValue(next as string)}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTab value="all">All</TabsTab>
|
||||||
|
<TabsTab value="public">Public</TabsTab>
|
||||||
|
<TabsTab value="private">Private</TabsTab>
|
||||||
|
<TabsIndicator />
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
<span className="text-2 text-sand-11">
|
||||||
|
Selected:
|
||||||
|
{" "}
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Skeleton() {
|
||||||
|
return <TabsSkeleton />;
|
||||||
|
}
|
||||||
@@ -18,13 +18,12 @@
|
|||||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { Tabs as BaseTabs } from "@base-ui/react/tabs";
|
||||||
|
|
||||||
import { PageHeader } from "#/components/PageHeader/PageHeader";
|
// Root of the tabs (Radix "Tabs"). Controlled the same way as Base UI's Tabs:
|
||||||
|
// `value` / `onValueChange`, or left uncontrolled. See contrib/claude/ui.md.
|
||||||
|
export type TabsProps = BaseTabs.Root.Props;
|
||||||
|
|
||||||
// Toolbar (All/Public/Private tabs, framework filter, search) and the document
|
export function Tabs(props: TabsProps) {
|
||||||
// count are deferred until the v2 Tabs/Select/TextField components exist.
|
return <BaseTabs.Root {...props} />;
|
||||||
export default function DocumentsPage() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return <PageHeader title={t("documents.title")} />;
|
|
||||||
}
|
}
|
||||||
36
packages/ui/src/v2/Tabs/TabsIndicator.tsx
Normal file
36
packages/ui/src/v2/Tabs/TabsIndicator.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// 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 as BaseTabs } from "@base-ui/react/tabs";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
import { tabsIndicator } from "./variants";
|
||||||
|
|
||||||
|
export type TabsIndicatorProps = Omit<ComponentProps<typeof BaseTabs.Indicator>, "className"> & {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The sliding underline tracking the active tab, positioned from Base UI's
|
||||||
|
// `--active-tab-left` / `--active-tab-width` CSS variables.
|
||||||
|
export function TabsIndicator(props: TabsIndicatorProps) {
|
||||||
|
const { className, ...rest } = props;
|
||||||
|
|
||||||
|
return <BaseTabs.Indicator className={tabsIndicator({ className })} {...rest} />;
|
||||||
|
}
|
||||||
35
packages/ui/src/v2/Tabs/TabsList.tsx
Normal file
35
packages/ui/src/v2/Tabs/TabsList.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// 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 as BaseTabs } from "@base-ui/react/tabs";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
import { tabsList } from "./variants";
|
||||||
|
|
||||||
|
export type TabsListProps = Omit<ComponentProps<typeof BaseTabs.List>, "className"> & {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The tab bar: holds the tab triggers and (optionally) the sliding indicator.
|
||||||
|
export function TabsList(props: TabsListProps) {
|
||||||
|
const { className, ...rest } = props;
|
||||||
|
|
||||||
|
return <BaseTabs.List className={tabsList({ className })} {...rest} />;
|
||||||
|
}
|
||||||
43
packages/ui/src/v2/Tabs/TabsSkeleton.tsx
Normal file
43
packages/ui/src/v2/Tabs/TabsSkeleton.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// 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 { ComponentProps } from "react";
|
||||||
|
|
||||||
|
import { tabsSkeleton } from "./variants";
|
||||||
|
|
||||||
|
export type TabsSkeletonProps = Omit<ComponentProps<"div">, "children"> & {
|
||||||
|
// Number of placeholder tabs to render (defaults to 3).
|
||||||
|
count?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Loading placeholder paired with the tab bar: a row of pulse blocks over the
|
||||||
|
// shared bottom border.
|
||||||
|
export function TabsSkeleton(props: TabsSkeletonProps) {
|
||||||
|
const { count = 3, className, ...rest } = props;
|
||||||
|
const { root, item } = tabsSkeleton();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={root({ className })} aria-hidden {...rest}>
|
||||||
|
{Array.from({ length: count }, (_, index) => (
|
||||||
|
<span key={index} className={item()} style={{ width: `${64 + (index % 3) * 16}px` }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
packages/ui/src/v2/Tabs/TabsTab.tsx
Normal file
35
packages/ui/src/v2/Tabs/TabsTab.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// 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 as BaseTabs } from "@base-ui/react/tabs";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
|
import { tabsTab } from "./variants";
|
||||||
|
|
||||||
|
export type TabsTabProps = Omit<ComponentProps<typeof BaseTabs.Tab>, "className"> & {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A single tab trigger. Selected state is driven by Base UI (`data-active`).
|
||||||
|
export function TabsTab(props: TabsTabProps) {
|
||||||
|
const { className, ...rest } = props;
|
||||||
|
|
||||||
|
return <BaseTabs.Tab className={tabsTab({ className })} {...rest} />;
|
||||||
|
}
|
||||||
54
packages/ui/src/v2/Tabs/variants.ts
Normal file
54
packages/ui/src/v2/Tabs/variants.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// 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";
|
||||||
|
|
||||||
|
// Tabs (Radix "Tabs" over Base UI's Tabs). An underlined tab bar whose active
|
||||||
|
// item is tracked by a sliding indicator positioned from Base UI's
|
||||||
|
// `--active-tab-*` CSS variables.
|
||||||
|
|
||||||
|
export const tabsList = tv({
|
||||||
|
base: "relative flex items-center gap-1 border-b border-sand-a3",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tabsTab = tv({
|
||||||
|
base: [
|
||||||
|
"relative flex h-full cursor-pointer items-center justify-center gap-2 px-2 py-4 text-2 text-sand-a11",
|
||||||
|
"select-none outline-none transition-colors",
|
||||||
|
"hover:text-sand-12",
|
||||||
|
"focus-visible:ring-2 focus-visible:ring-sand-8 focus-visible:ring-offset-1 focus-visible:ring-offset-sand-1",
|
||||||
|
"data-active:font-medium data-active:text-sand-12",
|
||||||
|
"data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tabsIndicator = tv({
|
||||||
|
base: [
|
||||||
|
"absolute bottom-0 left-0 h-[2px] w-(--active-tab-width) rounded-1 bg-sand-12",
|
||||||
|
"translate-x-(--active-tab-left) transition-all duration-200 ease-out",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tabsSkeleton = tv({
|
||||||
|
slots: {
|
||||||
|
root: "flex items-center gap-4 border-b border-sand-a3",
|
||||||
|
item: "my-4 h-5 animate-pulse rounded-1 bg-sand-3",
|
||||||
|
},
|
||||||
|
});
|
||||||
59
packages/ui/src/v2/Toaster/Toaster.tsx
Normal file
59
packages/ui/src/v2/Toaster/Toaster.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
// 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 { Toast } from "@base-ui/react/toast";
|
||||||
|
import { XIcon } from "@phosphor-icons/react";
|
||||||
|
|
||||||
|
import { toaster } from "./variants";
|
||||||
|
|
||||||
|
type ToastType = "success" | "error" | "neutral";
|
||||||
|
|
||||||
|
function resolveType(type: string | undefined): ToastType {
|
||||||
|
return type === "success" || type === "error" ? type : "neutral";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renders the active toasts from the Base UI toast manager. Mount once at the
|
||||||
|
// app root, inside a `<Toast.Provider>`.
|
||||||
|
export function Toaster() {
|
||||||
|
const { toasts } = Toast.useToastManager();
|
||||||
|
const slots = toaster();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Toast.Portal>
|
||||||
|
<Toast.Viewport className={slots.viewport()}>
|
||||||
|
{toasts.map((toast) => {
|
||||||
|
const variant = { type: resolveType(toast.type) };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Toast.Root key={toast.id} toast={toast} className={slots.toast(variant)}>
|
||||||
|
<div className={slots.content()}>
|
||||||
|
<Toast.Title className={slots.title(variant)} />
|
||||||
|
<Toast.Description className={slots.description()} />
|
||||||
|
</div>
|
||||||
|
<Toast.Close className={slots.close()} aria-label="Close">
|
||||||
|
<XIcon />
|
||||||
|
</Toast.Close>
|
||||||
|
</Toast.Root>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Toast.Viewport>
|
||||||
|
</Toast.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
packages/ui/src/v2/Toaster/variants.ts
Normal file
50
packages/ui/src/v2/Toaster/variants.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// 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";
|
||||||
|
|
||||||
|
// Toaster (Radix "Toast" over Base UI's Toast). A bottom-right stack of
|
||||||
|
// dismissible notifications, colored by type.
|
||||||
|
|
||||||
|
export const toaster = tv({
|
||||||
|
slots: {
|
||||||
|
viewport: "fixed right-4 bottom-4 z-50 flex w-full max-w-sm flex-col gap-2 outline-none",
|
||||||
|
toast: [
|
||||||
|
"pointer-events-auto flex items-start gap-3 rounded-3 border border-sand-a6 bg-sand-1 p-3 shadow-3",
|
||||||
|
"transition-all duration-200 ease-out",
|
||||||
|
"data-starting-style:translate-x-4 data-starting-style:opacity-0",
|
||||||
|
"data-ending-style:translate-x-4 data-ending-style:opacity-0",
|
||||||
|
],
|
||||||
|
content: "flex min-w-0 flex-1 flex-col gap-1",
|
||||||
|
title: "text-2 font-medium text-sand-12",
|
||||||
|
description: "text-2 break-words text-sand-11",
|
||||||
|
close: "flex size-5 shrink-0 items-center justify-center rounded-1 text-sand-a10 outline-none transition-colors hover:bg-sand-3 hover:text-sand-12 [&_svg]:size-4",
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
type: {
|
||||||
|
success: { toast: "border-green-6 bg-green-2", title: "text-green-12" },
|
||||||
|
error: { toast: "border-red-6 bg-red-2", title: "text-red-12" },
|
||||||
|
neutral: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
type: "neutral",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -43,6 +43,11 @@ func NewAuditTrustCenterFilter() *AuditFilter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *AuditFilter) WithTrustCenterVisibilities(visibilities ...TrustCenterVisibility) *AuditFilter {
|
||||||
|
f.trustCenterVisibilities = visibilities
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
func (f *AuditFilter) SQLArguments() pgx.NamedArgs {
|
func (f *AuditFilter) SQLArguments() pgx.NamedArgs {
|
||||||
args := pgx.NamedArgs{}
|
args := pgx.NamedArgs{}
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ func (f *DocumentFilter) WithPublished(published *bool) *DocumentFilter {
|
|||||||
return f
|
return f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *DocumentFilter) WithTrustCenterVisibilities(visibilities ...TrustCenterVisibility) *DocumentFilter {
|
||||||
|
f.trustCenterVisibilities = visibilities
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
func (f *DocumentFilter) WithEmployeeIdentityID(identityID *gid.GID, modes ...EmployeeFilterMode) *DocumentFilter {
|
func (f *DocumentFilter) WithEmployeeIdentityID(identityID *gid.GID, modes ...EmployeeFilterMode) *DocumentFilter {
|
||||||
f.employeeIdentityID = identityID
|
f.employeeIdentityID = identityID
|
||||||
f.employeeFilterModes = modes
|
f.employeeFilterModes = modes
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type TrustCenter implements Node {
|
|||||||
after: CursorKey
|
after: CursorKey
|
||||||
last: Int
|
last: Int
|
||||||
before: CursorKey
|
before: CursorKey
|
||||||
|
filter: TrustCenterVisibilityFilter
|
||||||
): DocumentConnection! @goField(forceResolver: true)
|
): DocumentConnection! @goField(forceResolver: true)
|
||||||
|
|
||||||
audits(
|
audits(
|
||||||
@@ -23,6 +24,7 @@ type TrustCenter implements Node {
|
|||||||
after: CursorKey
|
after: CursorKey
|
||||||
last: Int
|
last: Int
|
||||||
before: CursorKey
|
before: CursorKey
|
||||||
|
filter: TrustCenterVisibilityFilter
|
||||||
): AuditConnection! @goField(forceResolver: true)
|
): AuditConnection! @goField(forceResolver: true)
|
||||||
|
|
||||||
subprocessors(
|
subprocessors(
|
||||||
@@ -50,6 +52,7 @@ type TrustCenter implements Node {
|
|||||||
after: CursorKey
|
after: CursorKey
|
||||||
last: Int
|
last: Int
|
||||||
before: CursorKey
|
before: CursorKey
|
||||||
|
filter: TrustCenterVisibilityFilter
|
||||||
): TrustCenterFileConnection! @goField(forceResolver: true)
|
): TrustCenterFileConnection! @goField(forceResolver: true)
|
||||||
|
|
||||||
complianceFrameworks(
|
complianceFrameworks(
|
||||||
@@ -95,6 +98,18 @@ enum DocumentType
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum TrustCenterVisibility
|
||||||
|
@goModel(model: "go.probo.inc/probo/pkg/coredata.TrustCenterVisibility") {
|
||||||
|
PRIVATE
|
||||||
|
@goEnum(value: "go.probo.inc/probo/pkg/coredata.TrustCenterVisibilityPrivate")
|
||||||
|
PUBLIC
|
||||||
|
@goEnum(value: "go.probo.inc/probo/pkg/coredata.TrustCenterVisibilityPublic")
|
||||||
|
}
|
||||||
|
|
||||||
|
input TrustCenterVisibilityFilter {
|
||||||
|
visibility: TrustCenterVisibility
|
||||||
|
}
|
||||||
|
|
||||||
type Document implements Node @nda {
|
type Document implements Node @nda {
|
||||||
id: ID!
|
id: ID!
|
||||||
title: String!
|
title: String!
|
||||||
|
|||||||
@@ -758,7 +758,7 @@ func (r *trustCenterResolver) Organization(ctx context.Context, obj *types.Trust
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Documents is the resolver for the documents field.
|
// Documents is the resolver for the documents field.
|
||||||
func (r *trustCenterResolver) Documents(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.DocumentConnection, error) {
|
func (r *trustCenterResolver) Documents(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey, filter *types.TrustCenterVisibilityFilter) (*types.DocumentConnection, error) {
|
||||||
compliancePage := compliancepage.CompliancePageFromContext(ctx)
|
compliancePage := compliancepage.CompliancePageFromContext(ctx)
|
||||||
scope := coredata.NewScopeFromObjectID(compliancePage.OrganizationID)
|
scope := coredata.NewScopeFromObjectID(compliancePage.OrganizationID)
|
||||||
trustService := r.trust
|
trustService := r.trust
|
||||||
@@ -768,7 +768,12 @@ func (r *trustCenterResolver) Documents(ctx context.Context, obj *types.TrustCen
|
|||||||
}
|
}
|
||||||
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
||||||
|
|
||||||
documentPage, err := trustService.Documents.ListForOrganizationId(ctx, scope, obj.Organization.ID, cursor)
|
documentFilter := coredata.NewDocumentTrustCenterFilter()
|
||||||
|
if filter != nil && filter.Visibility != nil {
|
||||||
|
documentFilter = documentFilter.WithTrustCenterVisibilities(*filter.Visibility)
|
||||||
|
}
|
||||||
|
|
||||||
|
documentPage, err := trustService.Documents.ListForOrganizationId(ctx, scope, obj.Organization.ID, cursor, documentFilter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.logger.ErrorCtx(ctx, "cannot list public documents", log.Error(err))
|
r.logger.ErrorCtx(ctx, "cannot list public documents", log.Error(err))
|
||||||
return nil, gqlutils.Internal(ctx)
|
return nil, gqlutils.Internal(ctx)
|
||||||
@@ -778,7 +783,7 @@ func (r *trustCenterResolver) Documents(ctx context.Context, obj *types.TrustCen
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Audits is the resolver for the audits field.
|
// Audits is the resolver for the audits field.
|
||||||
func (r *trustCenterResolver) Audits(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.AuditConnection, error) {
|
func (r *trustCenterResolver) Audits(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey, filter *types.TrustCenterVisibilityFilter) (*types.AuditConnection, error) {
|
||||||
compliancePage := compliancepage.CompliancePageFromContext(ctx)
|
compliancePage := compliancepage.CompliancePageFromContext(ctx)
|
||||||
scope := coredata.NewScopeFromObjectID(compliancePage.OrganizationID)
|
scope := coredata.NewScopeFromObjectID(compliancePage.OrganizationID)
|
||||||
trustService := r.trust
|
trustService := r.trust
|
||||||
@@ -788,7 +793,12 @@ func (r *trustCenterResolver) Audits(ctx context.Context, obj *types.TrustCenter
|
|||||||
}
|
}
|
||||||
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
||||||
|
|
||||||
auditPage, err := trustService.Audits.ListForOrganizationId(ctx, scope, obj.Organization.ID, cursor)
|
auditFilter := coredata.NewAuditTrustCenterFilter()
|
||||||
|
if filter != nil && filter.Visibility != nil {
|
||||||
|
auditFilter = auditFilter.WithTrustCenterVisibilities(*filter.Visibility)
|
||||||
|
}
|
||||||
|
|
||||||
|
auditPage, err := trustService.Audits.ListForOrganizationId(ctx, scope, obj.Organization.ID, cursor, auditFilter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.logger.ErrorCtx(ctx, "cannot list public audits", log.Error(err))
|
r.logger.ErrorCtx(ctx, "cannot list public audits", log.Error(err))
|
||||||
return nil, gqlutils.Internal(ctx)
|
return nil, gqlutils.Internal(ctx)
|
||||||
@@ -888,7 +898,7 @@ func (r *trustCenterResolver) References(ctx context.Context, obj *types.TrustCe
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TrustCenterFiles is the resolver for the trustCenterFiles field.
|
// TrustCenterFiles is the resolver for the trustCenterFiles field.
|
||||||
func (r *trustCenterResolver) TrustCenterFiles(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.TrustCenterFileConnection, error) {
|
func (r *trustCenterResolver) TrustCenterFiles(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey, filter *types.TrustCenterVisibilityFilter) (*types.TrustCenterFileConnection, error) {
|
||||||
compliancePage := compliancepage.CompliancePageFromContext(ctx)
|
compliancePage := compliancepage.CompliancePageFromContext(ctx)
|
||||||
scope := coredata.NewScopeFromObjectID(compliancePage.OrganizationID)
|
scope := coredata.NewScopeFromObjectID(compliancePage.OrganizationID)
|
||||||
trustService := r.trust
|
trustService := r.trust
|
||||||
@@ -898,14 +908,19 @@ func (r *trustCenterResolver) TrustCenterFiles(ctx context.Context, obj *types.T
|
|||||||
}
|
}
|
||||||
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
||||||
|
|
||||||
filter := coredata.NewTrustCenterFileFilter(
|
visibilities := []coredata.TrustCenterVisibility{
|
||||||
coredata.WithTrustCenterFileVisibilities(
|
coredata.TrustCenterVisibilityPublic,
|
||||||
coredata.TrustCenterVisibilityPublic,
|
coredata.TrustCenterVisibilityPrivate,
|
||||||
coredata.TrustCenterVisibilityPrivate,
|
}
|
||||||
),
|
if filter != nil && filter.Visibility != nil {
|
||||||
|
visibilities = []coredata.TrustCenterVisibility{*filter.Visibility}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileFilter := coredata.NewTrustCenterFileFilter(
|
||||||
|
coredata.WithTrustCenterFileVisibilities(visibilities...),
|
||||||
)
|
)
|
||||||
|
|
||||||
trustCenterFilePage, err := trustService.TrustCenterFiles.ListForOrganizationId(ctx, scope, obj.Organization.ID, cursor, filter)
|
trustCenterFilePage, err := trustService.TrustCenterFiles.ListForOrganizationId(ctx, scope, obj.Organization.ID, cursor, fileFilter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.logger.ErrorCtx(ctx, "cannot list public trust center files", log.Error(err))
|
r.logger.ErrorCtx(ctx, "cannot list public trust center files", log.Error(err))
|
||||||
return nil, gqlutils.Internal(ctx)
|
return nil, gqlutils.Internal(ctx)
|
||||||
|
|||||||
@@ -88,14 +88,17 @@ func (s AuditService) ListForOrganizationId(
|
|||||||
scope coredata.Scoper,
|
scope coredata.Scoper,
|
||||||
organizationID gid.GID,
|
organizationID gid.GID,
|
||||||
cursor *page.Cursor[coredata.AuditOrderField],
|
cursor *page.Cursor[coredata.AuditOrderField],
|
||||||
|
filter *coredata.AuditFilter,
|
||||||
) (*page.Page[*coredata.Audit, coredata.AuditOrderField], error) {
|
) (*page.Page[*coredata.Audit, coredata.AuditOrderField], error) {
|
||||||
var audits coredata.Audits
|
var audits coredata.Audits
|
||||||
|
|
||||||
|
if filter == nil {
|
||||||
|
filter = coredata.NewAuditTrustCenterFilter()
|
||||||
|
}
|
||||||
|
|
||||||
err := s.svc.pg.WithConn(
|
err := s.svc.pg.WithConn(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Querier) error {
|
func(ctx context.Context, conn pg.Querier) error {
|
||||||
filter := coredata.NewAuditTrustCenterFilter()
|
|
||||||
|
|
||||||
err := audits.LoadByOrganizationID(ctx, conn, scope, organizationID, cursor, filter)
|
err := audits.LoadByOrganizationID(ctx, conn, scope, organizationID, cursor, filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot load audits: %w", err)
|
return fmt.Errorf("cannot load audits: %w", err)
|
||||||
|
|||||||
@@ -271,7 +271,7 @@ func (s *Service) fetchDocumentIDs(ctx context.Context, scope coredata.Scoper, o
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
result, err := s.Documents.ListForOrganizationId(ctx, scope, orgID, cursor)
|
result, err := s.Documents.ListForOrganizationId(ctx, scope, orgID, cursor, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cannot list documents: %w", err)
|
return nil, fmt.Errorf("cannot list documents: %w", err)
|
||||||
}
|
}
|
||||||
@@ -345,7 +345,7 @@ func (s *Service) fetchDocumentIDs(ctx context.Context, scope coredata.Scoper, o
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
result, err := s.Audits.ListForOrganizationId(ctx, scope, orgID, cursor)
|
result, err := s.Audits.ListForOrganizationId(ctx, scope, orgID, cursor, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cannot list audits: %w", err)
|
return nil, fmt.Errorf("cannot list audits: %w", err)
|
||||||
}
|
}
|
||||||
@@ -454,7 +454,7 @@ func (s *Service) fetchDocuments(ctx context.Context, scope coredata.Scoper, org
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
result, err := s.Documents.ListForOrganizationId(ctx, scope, orgID, cursor)
|
result, err := s.Documents.ListForOrganizationId(ctx, scope, orgID, cursor, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cannot list documents: %w", err)
|
return nil, fmt.Errorf("cannot list documents: %w", err)
|
||||||
}
|
}
|
||||||
@@ -500,7 +500,7 @@ func (s *Service) fetchAudits(ctx context.Context, scope coredata.Scoper, orgID
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
result, err := s.Audits.ListForOrganizationId(ctx, scope, orgID, cursor)
|
result, err := s.Audits.ListForOrganizationId(ctx, scope, orgID, cursor, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cannot list audits: %w", err)
|
return nil, fmt.Errorf("cannot list audits: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,14 +55,17 @@ func (s *DocumentService) ListForOrganizationId(
|
|||||||
scope coredata.Scoper,
|
scope coredata.Scoper,
|
||||||
organizationID gid.GID,
|
organizationID gid.GID,
|
||||||
cursor *page.Cursor[coredata.DocumentOrderField],
|
cursor *page.Cursor[coredata.DocumentOrderField],
|
||||||
|
filter *coredata.DocumentFilter,
|
||||||
) (*page.Page[*coredata.Document, coredata.DocumentOrderField], error) {
|
) (*page.Page[*coredata.Document, coredata.DocumentOrderField], error) {
|
||||||
var documents coredata.Documents
|
var documents coredata.Documents
|
||||||
|
|
||||||
|
if filter == nil {
|
||||||
|
filter = coredata.NewDocumentTrustCenterFilter()
|
||||||
|
}
|
||||||
|
|
||||||
err := s.svc.pg.WithConn(
|
err := s.svc.pg.WithConn(
|
||||||
ctx,
|
ctx,
|
||||||
func(ctx context.Context, conn pg.Querier) error {
|
func(ctx context.Context, conn pg.Querier) error {
|
||||||
filter := coredata.NewDocumentTrustCenterFilter()
|
|
||||||
|
|
||||||
if err := documents.LoadPublishedByOrganizationID(ctx, conn, scope, organizationID, cursor, filter); err != nil {
|
if err := documents.LoadPublishedByOrganizationID(ctx, conn, scope, organizationID, cursor, filter); err != nil {
|
||||||
return fmt.Errorf("cannot load published documents: %w", err)
|
return fmt.Errorf("cannot load published documents: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user