Skip to content

Commit c8cedb7

Browse files
Relation graph in case manager
1 parent faa4990 commit c8cedb7

104 files changed

Lines changed: 6553 additions & 124 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,6 @@ ui-debug.log
5757
.claude/settings.local.json
5858
.claude/hooks/node_modules/
5959
.claude/hooks/state/
60+
61+
# Local agent glossary (not committed)
62+
CONTEXT.md

biome.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"$schema": "https://biomejs.dev/schemas/2.5.2/schema.json",
2+
"$schema": "https://biomejs.dev/schemas/2.5.9/schema.json",
33
"vcs": {
44
"enabled": true,
55
"clientKind": "git",

bun.lock

Lines changed: 103 additions & 52 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages-licenses.json

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15438,21 +15438,6 @@
1543815438
"homepage": "https://zod.dev",
1543915439
"description": "TypeScript-first schema declaration and validation library with static type inference"
1544015440
},
15441-
{
15442-
"name": "zustand",
15443-
"versions": [
15444-
"4.5.6",
15445-
"5.0.3"
15446-
],
15447-
"paths": [
15448-
"/home/carere/Projects/marble/marble-frontend/node_modules/.pnpm/zustand@4.5.6_@types+react@18.3.11_react@18.3.1/node_modules/zustand",
15449-
"/home/carere/Projects/marble/marble-frontend/node_modules/.pnpm/zustand@5.0.3_@types+react@18.3.11_react@18.3.1_use-sync-external-store@1.4.0_react@18.3.1_/node_modules/zustand"
15450-
],
15451-
"license": "MIT",
15452-
"author": "Paul Henschel",
15453-
"homepage": "https://github.com/pmndrs/zustand",
15454-
"description": "🐻 Bear necessities for state management in React"
15455-
},
1545615441
{
1545715442
"name": "zwitch",
1545815443
"versions": [

packages/app-builder/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
"decode-formdata": "^0.9.0",
9292
"deepsignal": "^1.6.0",
9393
"dinero.js": "2.0.2",
94+
"ego-graph": "0.1.3",
9495
"embla-carousel-react": "^8.6.0",
9596
"firebase": "^12.13.0",
9697
"i18next": "^26.3.0",
@@ -129,7 +130,6 @@
129130
"ui-design-system": "workspace:*",
130131
"ui-icons": "workspace:*",
131132
"uuid": "^14.0.0",
132-
"zod": "^4.1.11",
133-
"zustand": "^5.0.14"
133+
"zod": "^4.1.11"
134134
}
135135
}

packages/app-builder/src/components/Annotations/ClientObjectTagList.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ type ClientObjectTagListProps = {
1414
objectId: string;
1515
annotations?: GroupedAnnotations['tags'];
1616
placeholder?: string;
17+
/** Fires with the next tag id list on edit (optimistic) and again on failure revert. */
18+
onTagIdsChange?: (tagIds: string[]) => void;
1719
};
1820

1921
export function ClientObjectTagList({
@@ -22,6 +24,7 @@ export function ClientObjectTagList({
2224
objectId,
2325
annotations,
2426
placeholder,
27+
onTagIdsChange,
2528
}: ClientObjectTagListProps) {
2629
const { t } = useTranslation(['common']);
2730
const queryClient = useQueryClient();
@@ -43,6 +46,7 @@ export function ClientObjectTagList({
4346
if (addedTags.length === 0 && removedAnnotations.length === 0) return;
4447

4548
setPendingTagIds(next);
49+
onTagIdsChange?.(next);
4650

4751
createAnnotationMutation
4852
.mutateAsync({
@@ -56,6 +60,7 @@ export function ClientObjectTagList({
5660
if (!result.success) {
5761
toast.error(t('common:errors.unknown'));
5862
setPendingTagIds(null);
63+
onTagIdsChange?.(previous);
5964
return;
6065
}
6166
await queryClient.invalidateQueries({ queryKey: ['annotations', tableName, objectId] });
@@ -64,6 +69,7 @@ export function ClientObjectTagList({
6469
.catch(() => {
6570
toast.error(t('common:errors.unknown'));
6671
setPendingTagIds(null);
72+
onTagIdsChange?.(previous);
6773
});
6874
};
6975

packages/app-builder/src/components/CaseManager/ClientsPage.tsx

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { DocumentsList } from '@app-builder/components/ClientDetail/DocumentsLis
44
import { DataFields } from '@app-builder/components/Data/DataVisualisation/DataFields';
55
import { DataExplorerPanel } from '@app-builder/components/DataModelExplorer/DataExplorerPanel';
66
import { DataModel, DataModelObject } from '@app-builder/models';
7-
import { CaseDetail, PivotObject } from '@app-builder/models/cases';
7+
import { CaseDetail, getPivotObjectKey, PivotObject } from '@app-builder/models/cases';
88
import { useGetAnnotationsQuery } from '@app-builder/queries/data/get-annotations';
99
import { useOrganizationDetails } from '@app-builder/services/organization/organization-detail';
1010
import { clientDetailLinkParams } from '@app-builder/utils/routes/client-detail-url';
@@ -14,15 +14,21 @@ import type { Client360Table } from 'marble-api';
1414
import { type FeatureAccessLevelDto } from 'marble-api/generated/feature-access-api';
1515
import { useState } from 'react';
1616
import { useTranslation } from 'react-i18next';
17-
import { Button, Card, CtaV2ClassName, Popover, Tag } from 'ui-design-system';
17+
import { Button, Card, CtaV2ClassName, cn, Popover, Tag, Typo } from 'ui-design-system';
1818
import { Icon } from 'ui-icons';
1919
import { DataModelExplorerProvider } from '../DataModelExplorer/Provider';
20+
import { pageLayoutGutter } from '../Page/page-layout';
2021
import { ClientCommentsListCard } from './ClientComments';
2122
import { ClientRelatedAlertCasesCard } from './ClientRelatedAlertCasesCard';
23+
import { isGraphEligiblePivot } from './graph-pivots';
2224
import { CommentContext } from './hooks/comment-context';
25+
import { MainLinksGraph, mainLinksGraphMinHeight } from './MainLinksGraph';
2326
import { NavigationOptions } from './NavigationOptions';
2427
import { UserScoreBadge } from './UserScore/UserScoreBadge';
2528

29+
/** Fills the viewport so the embedded graph gets its height without the page scrolling. */
30+
const clientColumnMinHeight = 'min-h-[calc(100dvh-12rem)]';
31+
2632
export type CaseManagerClientsPageProps = {
2733
caseDetail: CaseDetail;
2834
dataModel: DataModel;
@@ -52,10 +58,11 @@ export function CaseManagerClientsPage({
5258
const metadata = client360Tables.find((t) => t.name === pivotObject.pivotObjectName);
5359
const entityName = metadata?.alias || metadata?.name || pivotObject.pivotObjectName;
5460
const clientName = metadata ? (pivotObject.pivotObjectData.data[metadata.caption_field] as string) : '';
61+
const showMainLinks = isGraphEligiblePivot(pivotObject, dataModel);
5562

5663
return (
57-
<div className="grid grid-cols-1 lg:grid-cols-2 gap-lg">
58-
<div className="flex flex-col gap-sm">
64+
<div className={cn('grid grid-cols-1 lg:grid-cols-2', pageLayoutGutter.gap)}>
65+
<div className={cn('flex flex-col gap-sm', showMainLinks && ingestedInfo && clientColumnMinHeight)}>
5966
<div className="flex justify-between items-center">
6067
<span className="font-medium">{clientName}</span>
6168
<div className="flex items-center gap-sm">
@@ -107,6 +114,27 @@ export function CaseManagerClientsPage({
107114
) : null}
108115
</div>
109116
</Card>
117+
{showMainLinks && ingestedInfo ? (
118+
<div className={cn('flex flex-1 flex-col gap-sm', mainLinksGraphMinHeight)}>
119+
<div className="flex shrink-0 justify-between items-center">
120+
<Typo variant="title2">{t('cases:manager.clients.main_links_title')}</Typo>
121+
<Link
122+
from="/cases/s/$caseId/clients/$pivotValue"
123+
to="/cases/s/$caseId/links/$pivotValue"
124+
params={{ pivotValue: getPivotObjectKey(pivotObject) }}
125+
className={CtaV2ClassName({ appearance: 'link', variant: 'primary' })}
126+
>
127+
<Icon icon="eye" className="size-4" />
128+
{t('common:see_all')}
129+
</Link>
130+
</div>
131+
<MainLinksGraph
132+
objectType={ingestedInfo.objectType}
133+
objectId={ingestedInfo.objectId}
134+
dataModel={dataModel}
135+
/>
136+
</div>
137+
) : null}
110138
</div>
111139
<div className="flex flex-col gap-lg">
112140
{ingestedInfo ? (
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { GraphSessionProvider, useGraphSession } from '@app-builder/components/Graph/contexts/GraphSessionContext';
2+
import { SessionGraphCanvas } from '@app-builder/components/Graph/SessionGraphCanvas';
3+
import { Card, cn } from 'ui-design-system';
4+
5+
interface CaseManagerLinksPageProps {
6+
objectType: string;
7+
objectId: string;
8+
}
9+
10+
export function CaseManagerLinksPage({ objectType, objectId }: CaseManagerLinksPageProps) {
11+
return (
12+
<GraphSessionProvider
13+
key={`${objectType}:${objectId}`}
14+
initialRecord={{ recordType: objectType, recordId: objectId }}
15+
>
16+
<LinksGraphBody />
17+
</GraphSessionProvider>
18+
);
19+
}
20+
21+
function LinksGraphBody() {
22+
const { isGeneratingGraph } = useGraphSession();
23+
24+
return (
25+
<div className="flex min-h-0 flex-1 flex-col">
26+
<SessionGraphCanvas
27+
placeholder={<Card className={cn('min-h-0 flex-1', isGeneratingGraph && 'animate-pulse bg-grey-background')} />}
28+
/>
29+
</div>
30+
);
31+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { CustomerGraphProvider } from '@app-builder/components/Graph/contexts/CustomerGraphProvider';
2+
import { GraphIndexProvider } from '@app-builder/components/Graph/contexts/GraphIndexContext';
3+
import { graphEdgeTypes, graphNodeTypes } from '@app-builder/components/Graph/GraphComponents';
4+
import {
5+
GraphMeasuredLayout,
6+
graphFitViewOptions,
7+
useLaidOutGraph,
8+
} from '@app-builder/components/Graph/lib/use-laid-out-graph';
9+
import { type DataModel } from '@app-builder/models/data-model';
10+
import { type GraphData } from '@app-builder/models/graph';
11+
import { useGetGenerateGraphQuery } from '@app-builder/queries/graph/generate-graph';
12+
import { ReactFlow, ReactFlowProvider } from '@xyflow/react';
13+
import { useTranslation } from 'react-i18next';
14+
import { Card, cn, Typo } from 'ui-design-system';
15+
import '@xyflow/react/dist/style.css';
16+
17+
const MAIN_LINKS_DEGREES = 1;
18+
/** Floor for the embedded graph, so it stays readable however short its column is. */
19+
export const mainLinksGraphMinHeight = 'min-h-[450px]';
20+
const graphFrameClassName = cn('flex flex-1 flex-col overflow-hidden', mainLinksGraphMinHeight);
21+
22+
interface MainLinksGraphProps {
23+
objectType: string;
24+
objectId: string;
25+
dataModel: DataModel;
26+
}
27+
28+
export function MainLinksGraph({ objectType, objectId, dataModel }: MainLinksGraphProps) {
29+
const { t } = useTranslation(['cases']);
30+
const query = useGetGenerateGraphQuery({
31+
recordType: objectType,
32+
recordId: objectId,
33+
degrees: MAIN_LINKS_DEGREES,
34+
skip_same_field_relations: true,
35+
});
36+
37+
if (query.isPending) {
38+
return <Card className={cn(graphFrameClassName, 'animate-pulse bg-grey-background')} />;
39+
}
40+
41+
if (query.isError) {
42+
return <Card className={graphFrameClassName} />;
43+
}
44+
45+
if (!query.data || query.data.edges.length === 0) {
46+
return (
47+
<Card className={cn(graphFrameClassName, 'items-center justify-center')}>
48+
<Typo className="text-grey-primary">{t('cases:manager.clients.main_links_empty')}</Typo>
49+
</Card>
50+
);
51+
}
52+
53+
return (
54+
<Card className={cn(graphFrameClassName, 'p-0')}>
55+
<CustomerGraphProvider clusterThreshold={5} layoutMode="polarPetal">
56+
<ReactFlowProvider>
57+
<div className="relative min-h-0 flex-1">
58+
<MainLinksGraphCanvas data={query.data} dataModel={dataModel} />
59+
</div>
60+
</ReactFlowProvider>
61+
</CustomerGraphProvider>
62+
</Card>
63+
);
64+
}
65+
66+
function MainLinksGraphCanvas({ data, dataModel }: { data: GraphData; dataModel: DataModel }) {
67+
const { nodes, edges, onNodesChange, onEdgesChange, autoLayoutElements, graphIndex } = useLaidOutGraph({
68+
data,
69+
dataModel,
70+
});
71+
72+
return (
73+
<GraphIndexProvider index={graphIndex}>
74+
<ReactFlow
75+
className="h-full min-h-0"
76+
nodeTypes={graphNodeTypes}
77+
edgeTypes={graphEdgeTypes}
78+
nodes={nodes}
79+
edges={edges}
80+
onNodesChange={onNodesChange}
81+
onEdgesChange={onEdgesChange}
82+
fitView
83+
fitViewOptions={graphFitViewOptions}
84+
maxZoom={5}
85+
minZoom={0.1}
86+
nodesDraggable={false}
87+
nodesConnectable={false}
88+
elementsSelectable={false}
89+
zoomOnScroll={false}
90+
proOptions={{ hideAttribution: true }}
91+
>
92+
<GraphMeasuredLayout layoutElements={autoLayoutElements} />
93+
</ReactFlow>
94+
</GraphIndexProvider>
95+
);
96+
}

0 commit comments

Comments
 (0)