Data as document: replace snapshot with publish workflow

Mirror the SOA-to-document migration for the data list. Remove data
from the snapshot system and add a publish workflow that generates a
ProseMirror document for the full organization data inventory.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-04-16 13:50:30 +02:00
parent 472ca703b5
commit 55563226cb
37 changed files with 2021 additions and 467 deletions

View File

@@ -21,11 +21,18 @@ import { graphql } from "relay-runtime";
/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */
export const dataQuery = graphql`
query DatumGraphListQuery($organizationId: ID!, $snapshotId: ID = null) {
query DatumGraphListQuery($organizationId: ID!) {
node(id: $organizationId) {
... on Organization {
canCreateDatum: permission(action: "core:datum:create")
...DataPageFragment @arguments(snapshotId: $snapshotId)
canPublishData: permission(action: "core:datum:publish")
dataListDocument {
id
defaultApprovers {
id
}
}
...DataPageFragment
}
}
}
@@ -36,7 +43,6 @@ export const datumNodeQuery = graphql`
node(id: $dataId) {
... on Datum {
id
snapshotId
name
dataClassification
owner {

View File

@@ -21,8 +21,10 @@ import {
Badge,
Button,
DropdownItem,
IconPageTextLine,
IconPlusLarge,
IconTrashCan,
IconUpload,
PageHeader,
Tbody,
Td,
@@ -36,7 +38,7 @@ import {
usePaginationFragment,
usePreloadedQuery,
} from "react-relay";
import { useParams } from "react-router";
import { Link, useNavigate } from "react-router";
import type { DataListQuery } from "#/__generated__/core/DataListQuery.graphql";
import type {
@@ -44,7 +46,6 @@ import type {
DataPageFragment$key,
} from "#/__generated__/core/DataPageFragment.graphql";
import type { DatumGraphListQuery } from "#/__generated__/core/DatumGraphListQuery.graphql";
import { SnapshotBanner } from "#/components/SnapshotBanner";
import { SortableTable } from "#/components/SortableTable";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import type { NodeOf } from "#/types";
@@ -52,6 +53,7 @@ import type { NodeOf } from "#/types";
import { dataQuery, useDeleteDatum } from "../../../hooks/graph/DatumGraph";
import { CreateDatumDialog } from "./dialogs/CreateDatumDialog";
import { PublishDataListDialog } from "./dialogs/PublishDataListDialog";
const paginatedDataFragment = graphql`
fragment DataPageFragment on Organization
@@ -62,7 +64,6 @@ const paginatedDataFragment = graphql`
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
snapshotId: { type: "ID", defaultValue: null }
) {
data(
first: $first
@@ -70,8 +71,7 @@ const paginatedDataFragment = graphql`
last: $last
before: $before
orderBy: $order
filter: { snapshotId: $snapshotId }
) @connection(key: "DataPage_data", filters: ["filter"]) {
) @connection(key: "DataPage_data") {
__id
edges {
node {
@@ -107,8 +107,7 @@ type Props = {
export default function DataPage(props: Props) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const navigate = useNavigate();
const { node: data } = usePreloadedQuery<DatumGraphListQuery>(
dataQuery,
@@ -122,6 +121,7 @@ export default function DataPage(props: Props) {
const dataEntries = pagination.data.data.edges.map(edge => edge.node);
const connectionId = pagination.data.data.__id;
const defaultApproverIds = (data.dataListDocument?.defaultApprovers ?? []).map(a => a.id);
const refetch = ({
order,
@@ -129,7 +129,6 @@ export default function DataPage(props: Props) {
order: { direction: string; field: string };
}) => {
pagination.refetch({
snapshotId,
order: {
direction: order.direction as "ASC" | "DESC",
field: order.field as "CREATED_AT" | "DATA_CLASSIFICATION" | "NAME",
@@ -140,29 +139,52 @@ export default function DataPage(props: Props) {
usePageTitle(__("Data"));
const hasAnyAction
= !isSnapshotMode
&& dataEntries.some(({ canDelete, canUpdate }) => canUpdate || canDelete);
= dataEntries.some(({ canDelete, canUpdate }) => canUpdate || canDelete);
return (
<div className="space-y-6">
{isSnapshotMode && snapshotId && (
<SnapshotBanner snapshotId={snapshotId} />
)}
<PageHeader
title={__("Data")}
description={__(
"Manage your organization's data assets and their classifications.",
)}
>
{!snapshotId && data.canCreateDatum && (
<CreateDatumDialog
connection={connectionId}
organizationId={organizationId}
onCreated={() => pagination.refetch({ snapshotId })}
>
<Button icon={IconPlusLarge}>{__("Add data")}</Button>
</CreateDatumDialog>
)}
<div className="flex gap-2">
{data.dataListDocument?.id && (
<Button variant="secondary" asChild>
<Link
to={`/organizations/${organizationId}/documents/${data.dataListDocument.id}`}
>
<IconPageTextLine size={16} />
{__("Document")}
</Link>
</Button>
)}
{data.canPublishData && (
<PublishDataListDialog
organizationId={organizationId}
defaultApproverIds={defaultApproverIds}
onPublished={(documentId) => {
void navigate(
`/organizations/${organizationId}/documents/${documentId}`,
);
}}
>
<Button variant="secondary" icon={IconUpload}>
{__("Publish")}
</Button>
</PublishDataListDialog>
)}
{data.canCreateDatum && (
<CreateDatumDialog
connection={connectionId}
organizationId={organizationId}
onCreated={() => pagination.refetch({})}
>
<Button icon={IconPlusLarge}>{__("Add data")}</Button>
</CreateDatumDialog>
)}
</div>
</PageHeader>
<SortableTable {...pagination} refetch={refetch} pageSize={10}>
<Thead>
@@ -180,7 +202,6 @@ export default function DataPage(props: Props) {
key={entry.id}
entry={entry}
connectionId={connectionId}
snapshotId={snapshotId}
hasAnyAction={hasAnyAction}
/>
))}
@@ -193,21 +214,17 @@ export default function DataPage(props: Props) {
function DataRow({
entry,
connectionId,
snapshotId,
hasAnyAction,
}: {
entry: DataEntry;
connectionId: string;
snapshotId?: string;
hasAnyAction: boolean;
}) {
const organizationId = useOrganizationId();
const { __ } = useTranslate();
const deleteDatum = useDeleteDatum(entry, connectionId);
const vendors = entry.vendors?.edges.map(edge => edge.node) ?? [];
const detailUrl = snapshotId
? `/organizations/${organizationId}/snapshots/${snapshotId}/data/${entry.id}`
: `/organizations/${organizationId}/data/${entry.id}`;
const detailUrl = `/organizations/${organizationId}/data/${entry.id}`;
return (
<Tr to={detailUrl}>

View File

@@ -12,7 +12,6 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { validateSnapshotConsistency } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
@@ -29,14 +28,12 @@ import {
type PreloadedQuery,
usePreloadedQuery,
} from "react-relay";
import { useParams } from "react-router";
import { z } from "zod";
import type { DatumGraphNodeQuery } from "#/__generated__/core/DatumGraphNodeQuery.graphql";
import { ControlledField } from "#/components/form/ControlledField";
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
import { VendorsMultiSelectField } from "#/components/form/VendorsMultiSelectField";
import { SnapshotBanner } from "#/components/SnapshotBanner";
import {
datumNodeQuery,
useDeleteDatum,
@@ -57,9 +54,6 @@ type Props = {
};
export default function DatumDetailsPage(props: Props) {
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const queryData = usePreloadedQuery<DatumGraphNodeQuery>(
datumNodeQuery,
props.queryRef,
@@ -67,16 +61,12 @@ export default function DatumDetailsPage(props: Props) {
const datumEntry = queryData.node;
validateSnapshotConsistency(datumEntry, snapshotId);
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const deleteDatum = useDeleteDatum(
datumEntry,
ConnectionHandler.getConnectionID(organizationId, "DataPage_data", {
filter: { snapshotId: snapshotId || null },
}),
ConnectionHandler.getConnectionID(organizationId, "DataPage_data"),
);
const vendors = datumEntry?.vendors?.edges.map(edge => edge.node) ?? [];
@@ -110,27 +100,18 @@ export default function DatumDetailsPage(props: Props) {
}
});
const breadcrumbDataUrl = isSnapshotMode
? `/organizations/${organizationId}/snapshots/${snapshotId}/data`
: `/organizations/${organizationId}/data`;
const breadcrumbItems = [
{
label: __("Data"),
to: breadcrumbDataUrl,
to: `/organizations/${organizationId}/data`,
},
{
label: datumEntry?.name || "",
},
];
const disabled = !isSnapshotMode && datumEntry.canUpdate;
return (
<div className="space-y-6">
{isSnapshotMode && snapshotId && (
<SnapshotBanner snapshotId={snapshotId} />
)}
<Breadcrumb items={breadcrumbItems} />
<div className="flex justify-between items-start">
@@ -138,7 +119,7 @@ export default function DatumDetailsPage(props: Props) {
<div className="text-2xl">{datumEntry?.name}</div>
<Badge variant="info">{datumEntry?.dataClassification}</Badge>
</div>
{!isSnapshotMode && datumEntry.canDelete && (
{datumEntry.canDelete && (
<ActionDropdown variant="secondary">
<DropdownItem
variant="danger"
@@ -156,7 +137,7 @@ export default function DatumDetailsPage(props: Props) {
label={__("Name")}
{...register("name")}
type="text"
disabled={!disabled}
disabled={!datumEntry.canUpdate}
/>
<ControlledField
@@ -164,7 +145,7 @@ export default function DatumDetailsPage(props: Props) {
name="dataClassification"
type="select"
label={__("Classification")}
disabled={!disabled}
disabled={!datumEntry.canUpdate}
>
<Option value="PUBLIC">{__("Public")}</Option>
<Option value="INTERNAL">{__("Internal")}</Option>
@@ -177,7 +158,7 @@ export default function DatumDetailsPage(props: Props) {
control={control}
name="ownerId"
label={__("Owner")}
disabled={!disabled}
disabled={!datumEntry.canUpdate}
/>
<VendorsMultiSelectField
@@ -185,19 +166,17 @@ export default function DatumDetailsPage(props: Props) {
control={control}
name="vendorIds"
label={__("Vendors")}
disabled={!disabled}
disabled={!datumEntry.canUpdate}
selectedVendors={vendors}
/>
{!isSnapshotMode && (
<div className="flex justify-end">
{formState.isDirty && datumEntry.canUpdate && (
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? __("Updating...") : __("Update")}
</Button>
)}
</div>
)}
<div className="flex justify-end">
{formState.isDirty && datumEntry.canUpdate && (
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? __("Updating...") : __("Update")}
</Button>
)}
</div>
</form>
</div>
);

View File

@@ -0,0 +1,159 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { formatError, type GraphQLError } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
IconSend,
IconUpload,
useDialogRef,
useToast,
} from "@probo/ui";
import type { ReactNode } from "react";
import { useMemo } from "react";
import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import { z } from "zod";
import type { PublishDataListDialogMutation } from "#/__generated__/core/PublishDataListDialogMutation.graphql";
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
const publishMutation = graphql`
mutation PublishDataListDialogMutation(
$input: PublishDataListInput!
) {
publishDataList(input: $input) {
documentEdge {
node {
id
}
}
}
}
`;
type Props = {
children: ReactNode;
organizationId: string;
defaultApproverIds?: string[];
onPublished?: (documentId: string) => void;
};
export function PublishDataListDialog({
children,
organizationId,
defaultApproverIds,
onPublished,
}: Props) {
const { __ } = useTranslate();
const { toast } = useToast();
const dialogRef = useDialogRef();
const schema = useMemo(() => z.object({
approverIds: z.array(z.string()),
}), []);
const {
control,
handleSubmit,
reset,
watch,
} = useFormWithSchema(schema, {
defaultValues: {
approverIds: defaultApproverIds ?? [],
},
});
const [publish, isPublishing]
= useMutation<PublishDataListDialogMutation>(publishMutation);
const approverIds = watch("approverIds");
const hasApprovers = approverIds.length > 0;
const onSubmit = (data: z.infer<typeof schema>) => {
publish({
variables: {
input: {
organizationId,
approverIds: data.approverIds.length > 0 ? data.approverIds : undefined,
},
},
onCompleted(response) {
const documentId = response.publishDataList?.documentEdge?.node?.id;
if (documentId) {
toast({
title: __("Success"),
description: hasApprovers
? __("Approval requested successfully.")
: __("Data list published successfully."),
variant: "success",
});
dialogRef.current?.close();
reset();
onPublished?.(documentId);
}
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to publish data list"),
error as GraphQLError,
),
variant: "error",
});
},
});
};
return (
<Dialog
className="max-w-xl"
ref={dialogRef}
trigger={children}
title={__("Publish Data List")}
>
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
<DialogContent padded>
<div className="space-y-4">
<p className="text-sm text-txt-secondary">
{__("Select approvers to request approval before publishing, or publish directly without approvers.")}
</p>
<PeopleMultiSelectField
name="approverIds"
label={__("Approvers")}
control={control}
organizationId={organizationId}
placeholder={__("Add approvers...")}
/>
</div>
</DialogContent>
<DialogFooter>
<Button
type="submit"
icon={hasApprovers ? IconSend : IconUpload}
disabled={isPublishing}
>
{hasApprovers ? __("Request approval") : __("Publish")}
</Button>
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -85,7 +85,7 @@ export default function SnapshotFormDialog(props: Props) {
defaultValues: {
name: "",
description: "",
type: "DATA",
type: "RISKS",
},
});

View File

@@ -32,22 +32,8 @@ export const dataRoutes = [
path: "data",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<DatumGraphListQuery>(coreEnvironment, dataQuery, {
organizationId: organizationId,
snapshotId: null,
}),
),
Component: withQueryRef(
lazy(() => import("#/pages/organizations/data/DataPage")),
),
},
{
path: "snapshots/:snapshotId/data",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId, snapshotId }) =>
loadQuery<DatumGraphListQuery>(coreEnvironment, dataQuery, {
organizationId,
snapshotId,
}),
),
Component: withQueryRef(
@@ -66,16 +52,4 @@ export const dataRoutes = [
lazy(() => import("../pages/organizations/data/DatumDetailsPage")),
),
},
{
path: "snapshots/:snapshotId/data/:dataId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ dataId }) =>
loadQuery<DatumGraphNodeQuery>(coreEnvironment, datumNodeQuery, {
dataId,
}),
),
Component: withQueryRef(
lazy(() => import("../pages/organizations/data/DatumDetailsPage")),
),
},
] satisfies AppRoute[];