@growthub/cli 0.14.6 → 0.14.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (18) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/SKILL.md +21 -0
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/README.md +1 -1
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/sandbox-run/route.js +5 -0
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/components/WorkspaceLensPanel.jsx +5 -4
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +58 -4
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/OrchestrationGraphCanvas.jsx +31 -0
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/StatusPill.jsx +22 -6
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/ToggleField.jsx +5 -4
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/WorkspaceDataModelCanvas.jsx +457 -0
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +188 -2
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workflows/WorkflowSurface.jsx +67 -3
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-map/page.jsx +14 -0
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-graph-runner.js +48 -0
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/orchestration-node-status.js +55 -0
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-metadata-impact.js +198 -0
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +1 -0
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/skills/governed-workspace-mutation/SKILL.md +11 -0
  18. package/package.json +1 -1
@@ -0,0 +1,457 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Workspace Map — read-only schema/relationship canvas.
5
+ *
6
+ * Renders the workspace data model as a node canvas using the SAME visual
7
+ * grammar as the workflow OrchestrationGraphCanvas (dotted grid, card nodes,
8
+ * connector edges, zoom controls), but the CONTENT is the metadata graph, not
9
+ * an executable workflow.
10
+ *
11
+ * Hard rules (mirrors the kit's governance invariants):
12
+ * - Graph data comes ONLY from buildWorkspaceMetadataStore →
13
+ * buildWorkspaceMetadataGraph. No ad-hoc config parsing here.
14
+ * - No mutations, no PATCH, no localStorage. This surface only reads
15
+ * /api/workspace and navigates to existing surfaces on click.
16
+ * - Secrets never reach the graph (the store strips them); we render only
17
+ * labels, counts, and types.
18
+ */
19
+
20
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
21
+ import Link from "next/link";
22
+ import { useRouter } from "next/navigation";
23
+ import { ArrowLeft, Maximize2, Search, X, ZoomIn, ZoomOut } from "lucide-react";
24
+
25
+ import { buildWorkspaceMetadataStore } from "@/lib/workspace-metadata-store";
26
+ import { buildWorkspaceMetadataGraph } from "@/lib/workspace-metadata-graph";
27
+ import { LucideIcon, OBJECT_TYPE_PRESETS, objectTypeBadge, pluralize } from "./dm-shared.jsx";
28
+
29
+ // Which node types appear on the map, in lane (left→right) order, with the
30
+ // icon + legend swatch for each. Workflow execution detail (nodes, inputs,
31
+ // runs) stays in the Workflow Canvas — the map is the structural overview.
32
+ const LANES = [
33
+ { key: "integration", types: ["integration"], label: "Integrations", icon: "Globe", color: "#0ea5e9" },
34
+ { key: "source", types: ["sourceRecord"], label: "Sources", icon: "Database", color: "#14b8a6" },
35
+ { key: "object", types: ["dataModelObject"], label: "Objects", icon: "Box", color: "#4f6bed" },
36
+ { key: "flow", types: ["workflow", "sandbox"], label: "Workflows", icon: "Zap", color: "#8b5cf6" },
37
+ { key: "dashboard", types: ["dashboard"], label: "Dashboards", icon: "LayoutDashboard", color: "#f97316" },
38
+ ];
39
+
40
+ const RENDERED_TYPES = new Set(LANES.flatMap((lane) => lane.types));
41
+ const NODE_WIDTH = 220;
42
+ const COL_GAP = 60;
43
+ const ROW_GAP = 24;
44
+ const PAD = 28;
45
+
46
+ function nodeHeight(node) {
47
+ // Object cards carry a field strip + stat → taller. Everything else is compact.
48
+ return node.type === "dataModelObject" ? 122 : 80;
49
+ }
50
+
51
+ function nodeIconName(node) {
52
+ if (node.type === "dataModelObject") {
53
+ return node.summary?.objectType
54
+ ? OBJECT_TYPE_PRESETS[node.summary.objectType]?.icon || "Box"
55
+ : "Box";
56
+ }
57
+ const lane = LANES.find((entry) => entry.types.includes(node.type));
58
+ return lane?.icon || "Box";
59
+ }
60
+
61
+ export function WorkspaceDataModelCanvas() {
62
+ const router = useRouter();
63
+ const [workspaceConfig, setWorkspaceConfig] = useState(null);
64
+ const [sourceRecords, setSourceRecords] = useState({});
65
+ const [loading, setLoading] = useState(true);
66
+ const [error, setError] = useState("");
67
+ const [scale, setScale] = useState(1);
68
+ const [selectedId, setSelectedId] = useState("");
69
+ const [query, setQuery] = useState("");
70
+ const canvasRef = useRef(null);
71
+ const dragRef = useRef(null);
72
+ const [isPanning, setIsPanning] = useState(false);
73
+
74
+ const load = useCallback(async () => {
75
+ setLoading(true);
76
+ setError("");
77
+ try {
78
+ const res = await fetch("/api/workspace", { cache: "no-store" });
79
+ const payload = await res.json();
80
+ if (!res.ok) throw new Error(payload.error || "Failed to load workspace");
81
+ setWorkspaceConfig(payload.workspaceConfig || null);
82
+ setSourceRecords(
83
+ payload.workspaceSourceRecords && typeof payload.workspaceSourceRecords === "object"
84
+ ? payload.workspaceSourceRecords
85
+ : {}
86
+ );
87
+ } catch (err) {
88
+ setError(err.message || "Failed to load workspace");
89
+ } finally {
90
+ setLoading(false);
91
+ }
92
+ }, []);
93
+
94
+ useEffect(() => { load(); }, [load]);
95
+
96
+ // Derived graph — never throws on malformed config (the store/graph guard).
97
+ const graph = useMemo(() => {
98
+ if (!workspaceConfig) return { nodes: [], edges: [] };
99
+ try {
100
+ const store = buildWorkspaceMetadataStore({
101
+ workspaceConfig,
102
+ workspaceSourceRecords: sourceRecords,
103
+ });
104
+ return buildWorkspaceMetadataGraph(store);
105
+ } catch {
106
+ return { nodes: [], edges: [] };
107
+ }
108
+ }, [workspaceConfig, sourceRecords]);
109
+
110
+ // Group field labels by the object id they belong to, for the object cards.
111
+ const fieldsByObjectId = useMemo(() => {
112
+ const map = new Map();
113
+ for (const node of graph.nodes) {
114
+ if (node.type !== "field") continue;
115
+ const objectId = node.summary?.objectId;
116
+ if (!objectId) continue;
117
+ if (!map.has(objectId)) map.set(objectId, []);
118
+ map.get(objectId).push(node.label);
119
+ }
120
+ return map;
121
+ }, [graph.nodes]);
122
+
123
+ // Deterministic lane layout — no randomness, stable across renders.
124
+ const { placed, positions, width, height } = useMemo(() => {
125
+ const positionMap = new Map();
126
+ const placedNodes = [];
127
+ let maxBottom = 0;
128
+ LANES.forEach((lane, laneIndex) => {
129
+ const laneNodes = graph.nodes.filter((node) => lane.types.includes(node.type));
130
+ const x = PAD + laneIndex * (NODE_WIDTH + COL_GAP);
131
+ let y = PAD;
132
+ for (const node of laneNodes) {
133
+ const h = nodeHeight(node);
134
+ positionMap.set(node.id, { x, y, w: NODE_WIDTH, h });
135
+ placedNodes.push(node);
136
+ y += h + ROW_GAP;
137
+ }
138
+ maxBottom = Math.max(maxBottom, y);
139
+ });
140
+ return {
141
+ placed: placedNodes,
142
+ positions: positionMap,
143
+ width: PAD + LANES.length * (NODE_WIDTH + COL_GAP),
144
+ height: Math.max(maxBottom, 460),
145
+ };
146
+ }, [graph.nodes]);
147
+
148
+ // Only edges whose BOTH endpoints are rendered on the map. Deduped by id.
149
+ const edges = useMemo(() => {
150
+ const seen = new Set();
151
+ const out = [];
152
+ for (const edge of graph.edges) {
153
+ if (!positions.has(edge.from) || !positions.has(edge.to)) continue;
154
+ if (seen.has(edge.id)) continue;
155
+ seen.add(edge.id);
156
+ out.push(edge);
157
+ }
158
+ return out;
159
+ }, [graph.edges, positions]);
160
+
161
+ const needle = query.trim().toLowerCase();
162
+ const matches = useCallback(
163
+ (node) => {
164
+ if (!needle) return true;
165
+ const s = node.summary || {};
166
+ // Match the fields a customer actually searches by — object type, source
167
+ // id, integration/status, workflow lifecycle, adapter/auth, fetched date —
168
+ // not just label/type.
169
+ const haystack = [
170
+ node.label, node.type,
171
+ s.objectType, s.objectId, s.status, s.lane, s.lifecycleStatus,
172
+ s.adapter, s.authStatus, s.authProvider, s.integrationId,
173
+ s.runLocality, s.fetchedAt, s.sourceAuthority,
174
+ ].filter(Boolean).join(" ").toLowerCase();
175
+ return haystack.includes(needle);
176
+ },
177
+ [needle]
178
+ );
179
+
180
+ // Human-readable label for the open action, by node type.
181
+ function openLabel(node) {
182
+ if (node.type === "workflow" || node.type === "sandbox") return "Open in Workflow Canvas";
183
+ return "Open in Data Model";
184
+ }
185
+
186
+ // Read-only metadata rows for the detail panel — derived from the graph
187
+ // summary only (no secrets, no config parsing).
188
+ function detailRows(node) {
189
+ const s = node.summary || {};
190
+ const rows = [];
191
+ const push = (k, v) => { if (v !== undefined && v !== null && v !== "") rows.push({ k, v: String(v) }); };
192
+ if (node.type === "dataModelObject") {
193
+ push("Type", s.objectType);
194
+ push("Records", Number.isFinite(s.rowCount) ? s.rowCount : undefined);
195
+ push("Backing", s.isLiveBacked ? "live" : s.readOnly ? "read-only" : "manual");
196
+ push("Source authority", s.sourceAuthority);
197
+ } else if (node.type === "sourceRecord") {
198
+ push("Records", Number.isFinite(s.recordCount) ? s.recordCount : 0);
199
+ push("Integration", s.integrationId);
200
+ push("Fetched", s.fetchedAt ? String(s.fetchedAt).slice(0, 10) : undefined);
201
+ } else if (node.type === "workflow") {
202
+ push("Steps", Number.isFinite(s.nodeCount) ? s.nodeCount : 0);
203
+ push("Lifecycle", s.lifecycleStatus);
204
+ push("Requires input", s.requiresInput ? "yes" : undefined);
205
+ } else if (node.type === "sandbox") {
206
+ push("Adapter", s.adapter);
207
+ push("Auth", s.authStatus);
208
+ push("Locality", s.runLocality);
209
+ push("Lifecycle", s.lifecycleStatus);
210
+ } else if (node.type === "integration") {
211
+ push("Status", s.status);
212
+ push("Lane", s.lane);
213
+ } else if (node.type === "dashboard") {
214
+ push("Widgets", Number.isFinite(s.widgetCount) ? s.widgetCount : 0);
215
+ }
216
+ return rows;
217
+ }
218
+
219
+ const selectedNode = useMemo(
220
+ () => placed.find((node) => node.id === selectedId) || null,
221
+ [placed, selectedId]
222
+ );
223
+
224
+ function handleOpen(node) {
225
+ const summary = node.summary || {};
226
+ if (node.type === "dataModelObject") {
227
+ router.push(`/data-model?object=${encodeURIComponent(summary.objectId || "")}`);
228
+ } else if (node.type === "workflow" || node.type === "sandbox") {
229
+ const params = new URLSearchParams();
230
+ if (summary.objectId) params.set("object", summary.objectId);
231
+ if (summary.rowId) params.set("row", summary.rowId);
232
+ // Explicit field for consistency with the CEO/Agent Team handoff pattern;
233
+ // WorkflowSurface still falls back to orchestrationGraph if absent.
234
+ params.set("field", "orchestrationConfig");
235
+ router.push(`/workflows${params.toString() ? `?${params.toString()}` : ""}`);
236
+ } else {
237
+ router.push("/data-model");
238
+ }
239
+ }
240
+
241
+ function zoom(delta) {
242
+ setScale((current) => Math.min(1.4, Math.max(0.6, Math.round((current + delta) * 10) / 10)));
243
+ }
244
+
245
+ const handleCanvasPointerDown = useCallback((event) => {
246
+ if (event.button !== 0) return;
247
+ const target = event.target;
248
+ if (target?.closest?.("button, a, input, label, .wm-detail, .wm-zoom")) return;
249
+ const canvas = canvasRef.current;
250
+ if (!canvas) return;
251
+ dragRef.current = {
252
+ pointerId: event.pointerId,
253
+ startX: event.clientX,
254
+ startY: event.clientY,
255
+ scrollLeft: canvas.scrollLeft,
256
+ scrollTop: canvas.scrollTop,
257
+ };
258
+ setIsPanning(true);
259
+ event.currentTarget.setPointerCapture?.(event.pointerId);
260
+ }, []);
261
+
262
+ const handleCanvasPointerMove = useCallback((event) => {
263
+ const drag = dragRef.current;
264
+ const canvas = canvasRef.current;
265
+ if (!drag || !canvas || drag.pointerId !== event.pointerId) return;
266
+ event.preventDefault();
267
+ canvas.scrollLeft = drag.scrollLeft - (event.clientX - drag.startX);
268
+ canvas.scrollTop = drag.scrollTop - (event.clientY - drag.startY);
269
+ }, []);
270
+
271
+ const endCanvasPan = useCallback((event) => {
272
+ if (dragRef.current?.pointerId !== event.pointerId) return;
273
+ dragRef.current = null;
274
+ setIsPanning(false);
275
+ }, []);
276
+
277
+ const hasNodes = placed.length > 0;
278
+
279
+ return (
280
+ <div className="wm-shell">
281
+ <div className="wm-toolbar">
282
+ <Link className="wm-back-link" href="/workspace-lens" aria-label="Back to Workspace Lens">
283
+ <ArrowLeft size={16} aria-hidden="true" />
284
+ </Link>
285
+ <div>
286
+ <h1>Workspace Map</h1>
287
+ <span className="wm-sub">
288
+ {hasNodes ? `${pluralize(placed.length, "node")} · ${pluralize(edges.length, "link")}` : "Read-only view of your workspace data model"}
289
+ </span>
290
+ </div>
291
+ <label className="dm-toolbar-search" style={{ marginLeft: 16 }}>
292
+ <Search size={13} aria-hidden="true" />
293
+ <input
294
+ value={query}
295
+ placeholder="Search objects, sources, workflows"
296
+ onChange={(event) => setQuery(event.target.value)}
297
+ aria-label="Search the workspace map"
298
+ />
299
+ </label>
300
+ <div className="wm-legend">
301
+ {LANES.map((lane) => (
302
+ <span key={lane.key} className="wm-legend-item">
303
+ <span className="wm-legend-swatch" style={{ background: lane.color }} />
304
+ {lane.label}
305
+ </span>
306
+ ))}
307
+ </div>
308
+ </div>
309
+
310
+ <div
311
+ ref={canvasRef}
312
+ className={`wm-canvas${isPanning ? " is-panning" : ""}`}
313
+ onPointerDown={handleCanvasPointerDown}
314
+ onPointerMove={handleCanvasPointerMove}
315
+ onPointerUp={endCanvasPan}
316
+ onPointerCancel={endCanvasPan}
317
+ onPointerLeave={endCanvasPan}
318
+ >
319
+ {loading && <div className="wm-empty"><span>Loading workspace map…</span></div>}
320
+ {!loading && error && <div className="wm-empty"><strong>Could not load the map</strong><span>{error}</span></div>}
321
+ {!loading && !error && !hasNodes && (
322
+ <div className="wm-empty">
323
+ <strong>Nothing to map yet</strong>
324
+ <span>Add objects, link a data source, or publish a workflow and they will appear here.</span>
325
+ </div>
326
+ )}
327
+ {!loading && !error && hasNodes && (
328
+ <>
329
+ {/* Sizer reserves the SCALED footprint so .wm-canvas (overflow:auto)
330
+ scrolls fully when zoomed in on dense workspaces. The inner
331
+ layer holds the unscaled coordinate system and is transformed. */}
332
+ <div className="wm-canvas-inner" style={{ width: width * scale, height: height * scale }}>
333
+ <div className="wm-canvas-scale" style={{ width, height, transform: `scale(${scale})` }}>
334
+ <svg className="wm-edge" width={width} height={height} style={{ left: 0, top: 0 }}>
335
+ <defs>
336
+ <marker id="wm-arrow" markerWidth="7" markerHeight="7" refX="6" refY="3" orient="auto" markerUnits="userSpaceOnUse">
337
+ <path className="wm-arrow-head" d="M0,0 L6,3 L0,6 Z" />
338
+ </marker>
339
+ </defs>
340
+ {edges.map((edge) => {
341
+ const a = positions.get(edge.from);
342
+ const b = positions.get(edge.to);
343
+ if (!a || !b) return null;
344
+ const overlapsX = Math.max(a.x, b.x) < Math.min(a.x + a.w, b.x + b.w);
345
+ const [top, bottom] = a.y <= b.y ? [a, b] : [b, a];
346
+ const [left, right] = a.x <= b.x ? [a, b] : [b, a];
347
+ const cls = ["backedBySourceRecord", "boundToIntegration", "belongsToIntegration"].includes(edge.relation)
348
+ ? "is-source"
349
+ : "";
350
+ const d = overlapsX
351
+ ? `M ${top.x + top.w / 2} ${top.y + top.h} L ${bottom.x + bottom.w / 2} ${bottom.y}`
352
+ : [
353
+ `M ${left.x + left.w} ${left.y + left.h / 2}`,
354
+ `H ${(left.x + left.w + right.x) / 2}`,
355
+ `V ${right.y + right.h / 2}`,
356
+ `H ${right.x}`,
357
+ ].join(" ");
358
+ return <path key={edge.id} className={cls} d={d} markerEnd="url(#wm-arrow)" />;
359
+ })}
360
+ </svg>
361
+ {placed.map((node) => {
362
+ const pos = positions.get(node.id);
363
+ const dim = needle && !matches(node);
364
+ const isObject = node.type === "dataModelObject";
365
+ const badge = isObject ? objectTypeBadge(node.summary?.objectType) : null;
366
+ const fields = isObject ? (fieldsByObjectId.get(node.summary?.objectId) || []).slice(0, 5) : [];
367
+ const rowCount = node.summary?.rowCount;
368
+ const recordCount = node.summary?.recordCount;
369
+ return (
370
+ <button
371
+ type="button"
372
+ key={node.id}
373
+ className={`wm-node${node.id === selectedId ? " is-selected" : ""}`}
374
+ style={{ left: pos.x, top: pos.y, width: pos.w, opacity: dim ? 0.32 : 1 }}
375
+ onClick={() => setSelectedId(node.id)}
376
+ aria-pressed={node.id === selectedId}
377
+ title={`Inspect ${node.label}`}
378
+ >
379
+ <span className="wm-node-head">
380
+ <span className="wm-node-icon"><LucideIcon name={nodeIconName(node)} size={14} /></span>
381
+ <span className="wm-node-title">{node.label || node.type}</span>
382
+ {badge && <span className={`dm-badge ${badge.cls}`}>{badge.label}</span>}
383
+ </span>
384
+ <span className="wm-node-body">
385
+ {isObject && (
386
+ <span className="wm-node-stat">
387
+ {Number.isFinite(rowCount) ? pluralize(rowCount, "record") : "—"}
388
+ {node.summary?.isLiveBacked ? " · live" : node.summary?.readOnly ? " · read-only" : " · manual"}
389
+ </span>
390
+ )}
391
+ {node.type === "sourceRecord" && (
392
+ <span className="wm-node-stat">
393
+ {Number.isFinite(recordCount) ? pluralize(recordCount, "record") : "no records"}
394
+ {node.summary?.fetchedAt ? ` · fetched ${String(node.summary.fetchedAt).slice(0, 10)}` : ""}
395
+ </span>
396
+ )}
397
+ {node.type === "workflow" && (
398
+ <span className="wm-node-stat">
399
+ {pluralize(node.summary?.nodeCount || 0, "step")}
400
+ {node.summary?.lifecycleStatus ? ` · ${node.summary.lifecycleStatus}` : ""}
401
+ </span>
402
+ )}
403
+ {node.type === "sandbox" && (
404
+ <span className="wm-node-stat">
405
+ {node.summary?.adapter || "sandbox"}
406
+ {node.summary?.authStatus ? ` · ${node.summary.authStatus}` : ""}
407
+ </span>
408
+ )}
409
+ {node.type === "integration" && (
410
+ <span className="wm-node-stat">{node.summary?.status || node.summary?.lane || "integration"}</span>
411
+ )}
412
+ {node.type === "dashboard" && (
413
+ <span className="wm-node-stat">{pluralize(node.summary?.widgetCount || 0, "widget")}</span>
414
+ )}
415
+ {fields.length > 0 && (
416
+ <span className="wm-node-fields">
417
+ {fields.map((field, index) => (
418
+ <span key={`${node.id}-f-${index}`} className="wm-node-field">{field}</span>
419
+ ))}
420
+ </span>
421
+ )}
422
+ </span>
423
+ </button>
424
+ );
425
+ })}
426
+ </div>
427
+ </div>
428
+ <div className="wm-zoom" role="group" aria-label="Zoom controls">
429
+ <button type="button" onClick={() => zoom(-0.1)} aria-label="Zoom out"><ZoomOut size={15} /></button>
430
+ <button type="button" onClick={() => setScale(1)} aria-label="Reset zoom"><Maximize2 size={14} /></button>
431
+ <button type="button" onClick={() => zoom(0.1)} aria-label="Zoom in"><ZoomIn size={15} /></button>
432
+ </div>
433
+ {selectedNode && (
434
+ <aside className="wm-detail" aria-label={`${selectedNode.label} detail`}>
435
+ <div className="wm-detail-head">
436
+ <span className="wm-node-icon"><LucideIcon name={nodeIconName(selectedNode)} size={14} /></span>
437
+ <span className="wm-detail-title">{selectedNode.label || selectedNode.type}</span>
438
+ <button type="button" className="wm-detail-close" aria-label="Close detail" onClick={() => setSelectedId("")}><X size={14} /></button>
439
+ </div>
440
+ <dl className="wm-detail-meta">
441
+ {detailRows(selectedNode).map((row) => (
442
+ <div key={row.k} className="wm-detail-row"><dt>{row.k}</dt><dd>{row.v}</dd></div>
443
+ ))}
444
+ </dl>
445
+ <button type="button" className="dm-btn-primary-sm wm-detail-cta" onClick={() => handleOpen(selectedNode)}>
446
+ {openLabel(selectedNode)}
447
+ </button>
448
+ </aside>
449
+ )}
450
+ </>
451
+ )}
452
+ </div>
453
+ </div>
454
+ );
455
+ }
456
+
457
+ export default WorkspaceDataModelCanvas;