@adatechnology/conversations-ui 0.1.0-rc.27 → 0.1.0-rc.28
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.
- package/dist/flows/index.d.ts +73 -2
- package/dist/flows/index.js +115 -77
- package/package.json +2 -2
- package/src/buildOutput.test.ts +79 -0
- package/src/flows/FlowsWorkspace.tsx +56 -98
- package/src/flows/flowEditorOps.test.ts +241 -0
- package/src/flows/flowEditorOps.ts +177 -0
- package/src/flows/flowGraph.ts +1 -1
- package/src/flows/index.ts +16 -0
- package/src/flows/workspaceContract.test.ts +95 -0
package/dist/flows/index.d.ts
CHANGED
|
@@ -64,7 +64,7 @@ type CollectionChain = {
|
|
|
64
64
|
actionNodeId: string;
|
|
65
65
|
};
|
|
66
66
|
declare function findCollectionChains(graph: FlowGraphData): CollectionChain[];
|
|
67
|
-
declare function slugifyNodeId(label: string, existing:
|
|
67
|
+
declare function slugifyNodeId(label: string, existing: ReadonlySet<string>): string;
|
|
68
68
|
|
|
69
69
|
interface FlowEditorLabels {
|
|
70
70
|
legend: Record<FlowNodeType, string>;
|
|
@@ -383,4 +383,75 @@ interface FlowsWorkspaceProps {
|
|
|
383
383
|
*/
|
|
384
384
|
declare function FlowsWorkspace({ api, rootFlowKey, labels: labelsOverride, actionOptions, renderMediaPicker, livePollIntervalMs, className, }: FlowsWorkspaceProps): react.JSX.Element;
|
|
385
385
|
|
|
386
|
-
|
|
386
|
+
/**
|
|
387
|
+
* Copyright (c) 2026 Ada Technology. MIT License.
|
|
388
|
+
*
|
|
389
|
+
* Operações puras de grafo que o editor de fluxo precisa, e que viviam soltas dentro da página de
|
|
390
|
+
* 973 linhas do financiamento.
|
|
391
|
+
*
|
|
392
|
+
* Puras e separadas do hook de propósito: são a parte que dá para testar sem navegador, sem React e
|
|
393
|
+
* sem estado — e três delas (`removeNodeAndCleanRefs`, `resolveConnection`, `mergedFlowKeysFrom`)
|
|
394
|
+
* decidem o que acontece com o fluxo que alguém desenhou. Errar ali não dá erro; dá aresta apontando
|
|
395
|
+
* para nó que não existe mais, ou fluxo que some do canvas.
|
|
396
|
+
*/
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Id de nó no canvas mesclado: vários fluxos dividem o mesmo espaço, e `boas-vindas` pode existir em
|
|
400
|
+
* dois deles. Sem o prefixo, arrastar um card moveria o homônimo do outro fluxo.
|
|
401
|
+
*/
|
|
402
|
+
declare function namespaceNodeId(flowKey: string, nodeId: string): string;
|
|
403
|
+
declare function parseNamespacedId(value: string): {
|
|
404
|
+
flowKey: string;
|
|
405
|
+
nodeId: string;
|
|
406
|
+
};
|
|
407
|
+
/**
|
|
408
|
+
* Apaga o nó E as referências a ele.
|
|
409
|
+
*
|
|
410
|
+
* A limpeza não é cortesia: uma aresta apontando para nó inexistente faz o motor do bot parar a
|
|
411
|
+
* conversa no meio, e o sintoma aparece para o cliente, não para quem editou.
|
|
412
|
+
*/
|
|
413
|
+
declare function removeNodeAndCleanRefs(nodes: Readonly<Record<string, FlowNodeData>>, removedId: string): Record<string, FlowNodeData>;
|
|
414
|
+
/**
|
|
415
|
+
* O fecho transitivo dos fluxos alcançáveis a partir de um — é o conjunto que o canvas abre junto.
|
|
416
|
+
*
|
|
417
|
+
* BFS e não recursão: fluxo que referencia a si mesmo (menu que volta ao menu) é comum, e recursão
|
|
418
|
+
* ingênua estouraria a pilha no caso mais banal que existe.
|
|
419
|
+
*/
|
|
420
|
+
declare function mergedFlowKeysFrom(rootKey: string, graphs: Readonly<Record<string, FlowGraphData>>): readonly string[];
|
|
421
|
+
type ConnectionRequest = {
|
|
422
|
+
readonly source: string;
|
|
423
|
+
readonly target: string;
|
|
424
|
+
readonly sourceHandle?: string | null | undefined;
|
|
425
|
+
};
|
|
426
|
+
type ResolvedConnection = {
|
|
427
|
+
readonly flowKey: string;
|
|
428
|
+
readonly nodeId: string;
|
|
429
|
+
readonly handle: string;
|
|
430
|
+
/** O que gravar no `next`: id de nó local, ou `flow:<key>` quando atravessa fluxo. */
|
|
431
|
+
readonly targetValue: string;
|
|
432
|
+
};
|
|
433
|
+
/**
|
|
434
|
+
* Traduz um arraste de aresta no valor que vai para o `next` — e recusa o que o motor do bot não
|
|
435
|
+
* sabe executar.
|
|
436
|
+
*
|
|
437
|
+
* A regra que não é óbvia: conectar num nó de OUTRO fluxo só funciona se for o nó inicial dele,
|
|
438
|
+
* porque o motor só sabe pular para o começo de um fluxo, não para um nó do meio. Conectar no meio
|
|
439
|
+
* devolve `undefined` — recusa silenciosa é melhor que gravar um salto que o bot vai ignorar em
|
|
440
|
+
* produção, deixando a conversa parada sem ninguém entender por quê.
|
|
441
|
+
*/
|
|
442
|
+
declare function resolveConnection(params: {
|
|
443
|
+
readonly connection: ConnectionRequest;
|
|
444
|
+
readonly graphs: Readonly<Record<string, FlowGraphData>>;
|
|
445
|
+
}): ResolvedConnection | undefined;
|
|
446
|
+
/** Aplica a conexão resolvida no nó. `next` string para saída única, objeto para ramificação. */
|
|
447
|
+
declare function applyConnection(node: FlowNodeData, resolved: ResolvedConnection): FlowNodeData;
|
|
448
|
+
/**
|
|
449
|
+
* Um fluxo está sujo quando o rascunho difere do publicado.
|
|
450
|
+
*
|
|
451
|
+
* Comparação estrutural por JSON: é grosseira, e é suficiente porque o grafo é dado serializável sem
|
|
452
|
+
* ordem significativa de chave — o servidor devolve o que gravou. Comparar campo a campo daria a
|
|
453
|
+
* mesma resposta com mais código para errar.
|
|
454
|
+
*/
|
|
455
|
+
declare function isGraphDirty(working: FlowGraphData | undefined, published: FlowGraphData | undefined): boolean;
|
|
456
|
+
|
|
457
|
+
export { BUILT_IN_ACTION_KINDS, CONDITION_OPERATORS, CROSS_FLOW_PREFIX, type CollectionChain, type ConnectionRequest, type CreateFlowInput, DEFAULT_FLOW_EDITOR_LABELS, type FlowEditorLabels, FlowGroupFrame, type FlowGroupFrameData, FlowGroupHeader, type FlowGroupHeaderData, type FlowLivePosition, FlowMapCanvas, type FlowMapCanvasProps, FlowMapNode, type FlowMapNodeData, FlowNodeCard, type FlowNodeCardData, FlowNodePanel, type FlowNodePanelProps, FlowPalette, type FlowPaletteActionOption, type FlowPaletteProps, FlowPortalNode, type FlowPortalNodeData, type FlowValidationLabels, FlowWhatsAppPreview, type FlowWhatsAppPreviewProps, FlowsWorkspace, type FlowsWorkspaceApi, type FlowsWorkspaceProps, type GraphIssue, NODE_CARD_WIDTH, type NewNodeSpec, type ResolvedConnection, WHATSAPP_LIMITS, applyConnection, computeAutoLayout, computeFlowMapLayout, crossFlowKey, crossFlowTargetsOf, estimateNodeHeight, findCollectionChains, flowGroupFrameNodeTypes, flowGroupHeaderNodeTypes, flowMapNodeTypes, flowNodeTypes, flowPortalNodeTypes, isCrossFlowTarget, isGraphDirty, mergeFlowEditorLabels, mergedFlowKeysFrom, namespaceNodeId, nodeLabel, parseNamespacedId, removeNodeAndCleanRefs, rendersAsButtons, resolveConnection, slugifyNodeId, targetsOf, validateGraph };
|
package/dist/flows/index.js
CHANGED
|
@@ -1267,6 +1267,87 @@ import {
|
|
|
1267
1267
|
} from "@xyflow/react";
|
|
1268
1268
|
import "@xyflow/react/dist/style.css";
|
|
1269
1269
|
import { Plus as Plus3, Trash2 as Trash22, LayoutGrid, AlertTriangle as AlertTriangle3, AlertCircle as AlertCircle3, Save as Save2, Undo2, Map as MapIcon, Workflow } from "lucide-react";
|
|
1270
|
+
|
|
1271
|
+
// src/flows/flowEditorOps.ts
|
|
1272
|
+
var NAMESPACE_SEPARATOR = "::";
|
|
1273
|
+
function namespaceNodeId(flowKey, nodeId) {
|
|
1274
|
+
return `${flowKey}${NAMESPACE_SEPARATOR}${nodeId}`;
|
|
1275
|
+
}
|
|
1276
|
+
function parseNamespacedId(value) {
|
|
1277
|
+
const index = value.indexOf(NAMESPACE_SEPARATOR);
|
|
1278
|
+
if (index === -1) return { flowKey: "", nodeId: value };
|
|
1279
|
+
return { flowKey: value.slice(0, index), nodeId: value.slice(index + NAMESPACE_SEPARATOR.length) };
|
|
1280
|
+
}
|
|
1281
|
+
function removeNodeAndCleanRefs(nodes, removedId) {
|
|
1282
|
+
const remaining = {};
|
|
1283
|
+
for (const [id, node] of Object.entries(nodes)) {
|
|
1284
|
+
if (id === removedId) continue;
|
|
1285
|
+
remaining[id] = { ...node, next: cleanNext(node.next, removedId) };
|
|
1286
|
+
}
|
|
1287
|
+
return remaining;
|
|
1288
|
+
}
|
|
1289
|
+
function cleanNext(next, removedId) {
|
|
1290
|
+
if (next === void 0) return void 0;
|
|
1291
|
+
if (typeof next === "string") return next === removedId ? "" : next;
|
|
1292
|
+
const byAnswer = {};
|
|
1293
|
+
for (const [answer, target] of Object.entries(next.byAnswer ?? {})) {
|
|
1294
|
+
byAnswer[answer] = target === removedId ? "" : target;
|
|
1295
|
+
}
|
|
1296
|
+
return { byAnswer, default: next.default === removedId ? "" : next.default ?? "" };
|
|
1297
|
+
}
|
|
1298
|
+
function mergedFlowKeysFrom(rootKey, graphs) {
|
|
1299
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1300
|
+
const queue = [rootKey];
|
|
1301
|
+
while (queue.length > 0) {
|
|
1302
|
+
const key = queue.shift();
|
|
1303
|
+
if (visited.has(key) || !graphs[key]) continue;
|
|
1304
|
+
visited.add(key);
|
|
1305
|
+
for (const target of crossFlowTargetsOf(graphs[key])) {
|
|
1306
|
+
if (!visited.has(target) && graphs[target]) queue.push(target);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
return [...visited];
|
|
1310
|
+
}
|
|
1311
|
+
function resolveConnection(params) {
|
|
1312
|
+
const { source, target, sourceHandle } = params.connection;
|
|
1313
|
+
if (!source || !target || source === target) return void 0;
|
|
1314
|
+
const sourceRef = parseNamespacedId(source);
|
|
1315
|
+
const targetRef = parseNamespacedId(target);
|
|
1316
|
+
let targetValue;
|
|
1317
|
+
if (targetRef.flowKey === sourceRef.flowKey) {
|
|
1318
|
+
targetValue = targetRef.nodeId;
|
|
1319
|
+
} else {
|
|
1320
|
+
const targetGraph = params.graphs[targetRef.flowKey];
|
|
1321
|
+
if (!targetGraph || targetGraph.startNodeId !== targetRef.nodeId) return void 0;
|
|
1322
|
+
targetValue = `${CROSS_FLOW_PREFIX}${targetRef.flowKey}`;
|
|
1323
|
+
}
|
|
1324
|
+
return {
|
|
1325
|
+
flowKey: sourceRef.flowKey,
|
|
1326
|
+
nodeId: sourceRef.nodeId,
|
|
1327
|
+
handle: sourceHandle ?? "next",
|
|
1328
|
+
targetValue
|
|
1329
|
+
};
|
|
1330
|
+
}
|
|
1331
|
+
function applyConnection(node, resolved) {
|
|
1332
|
+
const currentNext = typeof node.next === "object" && node.next ? node.next : void 0;
|
|
1333
|
+
if (resolved.handle === "next") return { ...node, next: resolved.targetValue };
|
|
1334
|
+
if (resolved.handle === "__default") {
|
|
1335
|
+
return { ...node, next: { byAnswer: currentNext?.byAnswer ?? {}, default: resolved.targetValue } };
|
|
1336
|
+
}
|
|
1337
|
+
return {
|
|
1338
|
+
...node,
|
|
1339
|
+
next: {
|
|
1340
|
+
byAnswer: { ...currentNext?.byAnswer ?? {}, [resolved.handle]: resolved.targetValue },
|
|
1341
|
+
default: currentNext?.default ?? ""
|
|
1342
|
+
}
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
function isGraphDirty(working, published) {
|
|
1346
|
+
if (!working || !published) return false;
|
|
1347
|
+
return JSON.stringify(working) !== JSON.stringify(published);
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// src/flows/FlowsWorkspace.tsx
|
|
1270
1351
|
import { Fragment as Fragment2, jsx as jsx10, jsxs as jsxs9 } from "react/jsx-runtime";
|
|
1271
1352
|
var RF_NODE_TYPES = {
|
|
1272
1353
|
...flowNodeTypes,
|
|
@@ -1277,7 +1358,6 @@ var RF_NODE_TYPES = {
|
|
|
1277
1358
|
var CHAIN_FRAME_PADDING = 36;
|
|
1278
1359
|
var COLUMN_GAP = 360;
|
|
1279
1360
|
var ROW_GAP = 40;
|
|
1280
|
-
var NS_SEP = "::";
|
|
1281
1361
|
var FLOW_KEY_PATTERN = /^[a-z0-9_]{2,40}$/;
|
|
1282
1362
|
var EDGE_COLOR_LINEAR = "#94a3b8";
|
|
1283
1363
|
var EDGE_COLOR_BRANCH = "#8b5cf6";
|
|
@@ -1289,25 +1369,8 @@ var BACKGROUND_COLOR_DARK2 = "#334155";
|
|
|
1289
1369
|
function portalNodeId(sourceId, target) {
|
|
1290
1370
|
return `__portal__${sourceId}__${target}`;
|
|
1291
1371
|
}
|
|
1292
|
-
function ns(flowKey, nodeId) {
|
|
1293
|
-
return `${flowKey}${NS_SEP}${nodeId}`;
|
|
1294
|
-
}
|
|
1295
|
-
function parseNs(id) {
|
|
1296
|
-
const index = id.indexOf(NS_SEP);
|
|
1297
|
-
return index === -1 ? { flowKey: "", nodeId: id } : { flowKey: id.slice(0, index), nodeId: id.slice(index + NS_SEP.length) };
|
|
1298
|
-
}
|
|
1299
1372
|
function autoMergeAll(rootKey, graphsSource) {
|
|
1300
|
-
|
|
1301
|
-
const queue = [rootKey];
|
|
1302
|
-
while (queue.length > 0) {
|
|
1303
|
-
const key = queue.shift();
|
|
1304
|
-
if (visited.has(key) || !graphsSource[key]) continue;
|
|
1305
|
-
visited.add(key);
|
|
1306
|
-
for (const target of crossFlowTargetsOf(graphsSource[key])) {
|
|
1307
|
-
if (!visited.has(target) && graphsSource[target]) queue.push(target);
|
|
1308
|
-
}
|
|
1309
|
-
}
|
|
1310
|
-
return [...visited].map((key) => ({ key, offset: { x: 0, y: 0 } }));
|
|
1373
|
+
return mergedFlowKeysFrom(rootKey, graphsSource).map((key) => ({ key, offset: { x: 0, y: 0 } }));
|
|
1311
1374
|
}
|
|
1312
1375
|
function computeMergedLayout(openFlows, workingGraphs, primaryFlowKey) {
|
|
1313
1376
|
const openKeys = new Set(openFlows.map((flow) => flow.key));
|
|
@@ -1315,7 +1378,7 @@ function computeMergedLayout(openFlows, workingGraphs, primaryFlowKey) {
|
|
|
1315
1378
|
for (const { key } of openFlows) {
|
|
1316
1379
|
const graph = workingGraphs[key];
|
|
1317
1380
|
if (!graph) continue;
|
|
1318
|
-
for (const node of Object.values(graph.nodes)) nodeByNsId.set(
|
|
1381
|
+
for (const node of Object.values(graph.nodes)) nodeByNsId.set(namespaceNodeId(key, node.id), node);
|
|
1319
1382
|
}
|
|
1320
1383
|
function forwardEdges(flowKey, node) {
|
|
1321
1384
|
const result = [];
|
|
@@ -1323,16 +1386,16 @@ function computeMergedLayout(openFlows, workingGraphs, primaryFlowKey) {
|
|
|
1323
1386
|
if (isCrossFlowTarget(target)) {
|
|
1324
1387
|
const targetFlowKey = crossFlowKey(target);
|
|
1325
1388
|
const targetGraph = openKeys.has(targetFlowKey) ? workingGraphs[targetFlowKey] : void 0;
|
|
1326
|
-
if (targetGraph) result.push(
|
|
1389
|
+
if (targetGraph) result.push(namespaceNodeId(targetFlowKey, targetGraph.startNodeId));
|
|
1327
1390
|
} else if (workingGraphs[flowKey]?.nodes[target]) {
|
|
1328
|
-
result.push(
|
|
1391
|
+
result.push(namespaceNodeId(flowKey, target));
|
|
1329
1392
|
}
|
|
1330
1393
|
}
|
|
1331
1394
|
return result;
|
|
1332
1395
|
}
|
|
1333
1396
|
const rank = /* @__PURE__ */ new Map();
|
|
1334
1397
|
const primaryGraph = workingGraphs[primaryFlowKey];
|
|
1335
|
-
const rootId = primaryGraph ?
|
|
1398
|
+
const rootId = primaryGraph ? namespaceNodeId(primaryFlowKey, primaryGraph.startNodeId) : void 0;
|
|
1336
1399
|
if (rootId && nodeByNsId.has(rootId)) {
|
|
1337
1400
|
rank.set(rootId, 0);
|
|
1338
1401
|
const queue = [rootId];
|
|
@@ -1340,7 +1403,7 @@ function computeMergedLayout(openFlows, workingGraphs, primaryFlowKey) {
|
|
|
1340
1403
|
const id = queue.shift();
|
|
1341
1404
|
const node = nodeByNsId.get(id);
|
|
1342
1405
|
if (!node) continue;
|
|
1343
|
-
for (const nextId of forwardEdges(
|
|
1406
|
+
for (const nextId of forwardEdges(parseNamespacedId(id).flowKey, node)) {
|
|
1344
1407
|
if (!rank.has(nextId)) {
|
|
1345
1408
|
rank.set(nextId, rank.get(id) + 1);
|
|
1346
1409
|
queue.push(nextId);
|
|
@@ -1387,6 +1450,7 @@ function buildAllEdges(openFlows, workingGraphs, rootFlowKey, livePositions) {
|
|
|
1387
1450
|
for (const [id, node] of Object.entries(graph.nodes)) {
|
|
1388
1451
|
const isLive = (liveCounts[id] ?? 0) > 0;
|
|
1389
1452
|
for (const { target, optionId, isDefault } of targetsOf(node)) {
|
|
1453
|
+
if (!target) continue;
|
|
1390
1454
|
const crossFlow = isCrossFlowTarget(target);
|
|
1391
1455
|
let targetFlowKey = flowKey;
|
|
1392
1456
|
let rawTargetId = target;
|
|
@@ -1400,8 +1464,8 @@ function buildAllEdges(openFlows, workingGraphs, rootFlowKey, livePositions) {
|
|
|
1400
1464
|
rawTargetId = portalNodeId(id, target);
|
|
1401
1465
|
}
|
|
1402
1466
|
}
|
|
1403
|
-
const source =
|
|
1404
|
-
const edgeTarget =
|
|
1467
|
+
const source = namespaceNodeId(flowKey, id);
|
|
1468
|
+
const edgeTarget = namespaceNodeId(targetFlowKey, rawTargetId);
|
|
1405
1469
|
if (optionId === void 0 && !isDefault) {
|
|
1406
1470
|
const color = crossFlow ? EDGE_COLOR_CROSS_FLOW : isLive ? EDGE_COLOR_LIVE : EDGE_COLOR_LINEAR;
|
|
1407
1471
|
edges.push({
|
|
@@ -1468,29 +1532,6 @@ function newNodeFromSpec(spec, existingIds) {
|
|
|
1468
1532
|
const id = slugifyNodeId("nova_acao", existingIds);
|
|
1469
1533
|
return { id, type: "action", actionKind: spec.actionKind };
|
|
1470
1534
|
}
|
|
1471
|
-
function removeNodeAndCleanRefs(nodes, nodeId) {
|
|
1472
|
-
const { [nodeId]: _removed, ...rest } = nodes;
|
|
1473
|
-
return Object.fromEntries(
|
|
1474
|
-
Object.entries(rest).map(([id, node]) => {
|
|
1475
|
-
if (!node.next) return [id, node];
|
|
1476
|
-
if (typeof node.next === "string") {
|
|
1477
|
-
return [id, node.next === nodeId ? { ...node, next: void 0 } : node];
|
|
1478
|
-
}
|
|
1479
|
-
return [
|
|
1480
|
-
id,
|
|
1481
|
-
{
|
|
1482
|
-
...node,
|
|
1483
|
-
next: {
|
|
1484
|
-
byAnswer: Object.fromEntries(
|
|
1485
|
-
Object.entries(node.next.byAnswer).map(([key, value]) => [key, value === nodeId ? "" : value])
|
|
1486
|
-
),
|
|
1487
|
-
default: node.next.default === nodeId ? "" : node.next.default
|
|
1488
|
-
}
|
|
1489
|
-
}
|
|
1490
|
-
];
|
|
1491
|
-
})
|
|
1492
|
-
);
|
|
1493
|
-
}
|
|
1494
1535
|
function extractErrorMessage(error) {
|
|
1495
1536
|
if (error instanceof Error) return error.message;
|
|
1496
1537
|
return void 0;
|
|
@@ -1667,7 +1708,7 @@ function FlowsWorkspace({
|
|
|
1667
1708
|
for (const { key: flowKey, offset } of openFlows) {
|
|
1668
1709
|
let resolvePosition2 = function(nodeId) {
|
|
1669
1710
|
if (mergedPositions) {
|
|
1670
|
-
const nsId =
|
|
1711
|
+
const nsId = namespaceNodeId(flowKey, nodeId);
|
|
1671
1712
|
return renderedPositionsRef.current.get(nsId) ?? mergedPositions.get(nsId) ?? { x: 0, y: 0 };
|
|
1672
1713
|
}
|
|
1673
1714
|
const local = graph.nodes[nodeId]?.position ?? fallbackPositions[nodeId] ?? { x: 0, y: 0 };
|
|
@@ -1689,7 +1730,7 @@ function FlowsWorkspace({
|
|
|
1689
1730
|
for (const node of Object.values(graph.nodes)) {
|
|
1690
1731
|
const position = resolvePosition2(node.id);
|
|
1691
1732
|
allNodes.push({
|
|
1692
|
-
id:
|
|
1733
|
+
id: namespaceNodeId(flowKey, node.id),
|
|
1693
1734
|
type: "flowNode",
|
|
1694
1735
|
position,
|
|
1695
1736
|
draggable: true,
|
|
@@ -1709,7 +1750,7 @@ function FlowsWorkspace({
|
|
|
1709
1750
|
const targetFlowKey = crossFlowKey(target);
|
|
1710
1751
|
if (openFlows.some((flow) => flow.key === targetFlowKey)) return;
|
|
1711
1752
|
allNodes.push({
|
|
1712
|
-
id:
|
|
1753
|
+
id: namespaceNodeId(flowKey, portalNodeId(node.id, target)),
|
|
1713
1754
|
type: "flowPortal",
|
|
1714
1755
|
draggable: false,
|
|
1715
1756
|
selectable: false,
|
|
@@ -1729,7 +1770,7 @@ function FlowsWorkspace({
|
|
|
1729
1770
|
const minY = Math.min(...positions.map((point) => point.y));
|
|
1730
1771
|
const maxY = Math.max(...positions.map((point) => point.y)) + estimateNodeHeight(graph.nodes[chain.actionNodeId]);
|
|
1731
1772
|
allNodes.push({
|
|
1732
|
-
id:
|
|
1773
|
+
id: namespaceNodeId(flowKey, `__chain__${chain.actionNodeId}`),
|
|
1733
1774
|
type: "flowGroupFrame",
|
|
1734
1775
|
draggable: false,
|
|
1735
1776
|
selectable: false,
|
|
@@ -1747,7 +1788,7 @@ function FlowsWorkspace({
|
|
|
1747
1788
|
if (!isPrimary) {
|
|
1748
1789
|
const startPosition = resolvePosition2(graph.startNodeId);
|
|
1749
1790
|
allNodes.push({
|
|
1750
|
-
id:
|
|
1791
|
+
id: namespaceNodeId(flowKey, "__group_header__"),
|
|
1751
1792
|
type: "flowGroupHeader",
|
|
1752
1793
|
draggable: false,
|
|
1753
1794
|
selectable: false,
|
|
@@ -1799,10 +1840,11 @@ function FlowsWorkspace({
|
|
|
1799
1840
|
const onNodeDragStop = useCallback(
|
|
1800
1841
|
(_event, node) => {
|
|
1801
1842
|
renderedPositionsRef.current.set(node.id, node.position);
|
|
1802
|
-
const { flowKey, nodeId } =
|
|
1843
|
+
const { flowKey, nodeId } = parseNamespacedId(node.id);
|
|
1803
1844
|
const openFlow = openFlows.find((flow) => flow.key === flowKey);
|
|
1804
1845
|
if (!openFlow) return;
|
|
1805
|
-
const
|
|
1846
|
+
const drawnWithOffset = openFlows.length === 1;
|
|
1847
|
+
const localPosition = drawnWithOffset ? { x: node.position.x - openFlow.offset.x, y: node.position.y - openFlow.offset.y } : node.position;
|
|
1806
1848
|
updateFlow(
|
|
1807
1849
|
flowKey,
|
|
1808
1850
|
(graph) => graph.nodes[nodeId] ? { ...graph, nodes: { ...graph.nodes, [nodeId]: { ...graph.nodes[nodeId], position: localPosition } } } : graph
|
|
@@ -1812,26 +1854,15 @@ function FlowsWorkspace({
|
|
|
1812
1854
|
);
|
|
1813
1855
|
const onConnect = useCallback(
|
|
1814
1856
|
(connection) => {
|
|
1815
|
-
const
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
} else {
|
|
1823
|
-
const targetGraph = workingGraphs[targetRef.flowKey];
|
|
1824
|
-
if (!targetGraph || targetGraph.startNodeId !== targetRef.nodeId) return;
|
|
1825
|
-
targetValue = `${CROSS_FLOW_PREFIX}${targetRef.flowKey}`;
|
|
1826
|
-
}
|
|
1827
|
-
updateFlow(sourceRef.flowKey, (graph) => {
|
|
1828
|
-
const node = graph.nodes[sourceRef.nodeId];
|
|
1857
|
+
const resolved = resolveConnection({
|
|
1858
|
+
connection: { source: connection.source, target: connection.target, sourceHandle: connection.sourceHandle },
|
|
1859
|
+
graphs: workingGraphs
|
|
1860
|
+
});
|
|
1861
|
+
if (!resolved) return;
|
|
1862
|
+
updateFlow(resolved.flowKey, (graph) => {
|
|
1863
|
+
const node = graph.nodes[resolved.nodeId];
|
|
1829
1864
|
if (!node) return graph;
|
|
1830
|
-
|
|
1831
|
-
const currentByAnswer = typeof node.next === "object" && node.next ? node.next.byAnswer : {};
|
|
1832
|
-
const currentDefault = typeof node.next === "object" && node.next ? node.next.default : "";
|
|
1833
|
-
const updatedNode = handle === "next" ? { ...node, next: targetValue } : handle === "__default" ? { ...node, next: { byAnswer: currentByAnswer, default: targetValue } } : { ...node, next: { byAnswer: { ...currentByAnswer, [handle]: targetValue }, default: currentDefault } };
|
|
1834
|
-
return { ...graph, nodes: { ...graph.nodes, [sourceRef.nodeId]: updatedNode } };
|
|
1865
|
+
return { ...graph, nodes: { ...graph.nodes, [resolved.nodeId]: applyConnection(node, resolved) } };
|
|
1835
1866
|
});
|
|
1836
1867
|
},
|
|
1837
1868
|
[workingGraphs, updateFlow]
|
|
@@ -1843,7 +1874,7 @@ function FlowsWorkspace({
|
|
|
1843
1874
|
newNode.position = { x: 0, y: maxY + 170 };
|
|
1844
1875
|
updateFlow(primaryFlowKey, (graph) => ({ ...graph, nodes: { ...graph.nodes, [newNode.id]: newNode } }));
|
|
1845
1876
|
setEditingRef({ flowKey: primaryFlowKey, nodeId: newNode.id });
|
|
1846
|
-
setPendingFocusNodeId(
|
|
1877
|
+
setPendingFocusNodeId(namespaceNodeId(primaryFlowKey, newNode.id));
|
|
1847
1878
|
}
|
|
1848
1879
|
function handleNodePanelChange(updated) {
|
|
1849
1880
|
if (!editingRef) return;
|
|
@@ -1865,7 +1896,7 @@ function FlowsWorkspace({
|
|
|
1865
1896
|
...graph,
|
|
1866
1897
|
nodes: Object.fromEntries(
|
|
1867
1898
|
Object.entries(graph.nodes).map(([id, node]) => {
|
|
1868
|
-
const position = positions2.get(
|
|
1899
|
+
const position = positions2.get(namespaceNodeId(key, id));
|
|
1869
1900
|
return [
|
|
1870
1901
|
id,
|
|
1871
1902
|
position ? { ...node, position: { x: position.x - offset.x, y: position.y - offset.y } } : node
|
|
@@ -2289,6 +2320,7 @@ export {
|
|
|
2289
2320
|
FlowsWorkspace,
|
|
2290
2321
|
NODE_CARD_WIDTH,
|
|
2291
2322
|
WHATSAPP_LIMITS,
|
|
2323
|
+
applyConnection,
|
|
2292
2324
|
computeAutoLayout,
|
|
2293
2325
|
computeFlowMapLayout,
|
|
2294
2326
|
crossFlowKey,
|
|
@@ -2301,9 +2333,15 @@ export {
|
|
|
2301
2333
|
flowNodeTypes,
|
|
2302
2334
|
flowPortalNodeTypes,
|
|
2303
2335
|
isCrossFlowTarget,
|
|
2336
|
+
isGraphDirty,
|
|
2304
2337
|
mergeFlowEditorLabels,
|
|
2338
|
+
mergedFlowKeysFrom,
|
|
2339
|
+
namespaceNodeId,
|
|
2305
2340
|
nodeLabel,
|
|
2341
|
+
parseNamespacedId,
|
|
2342
|
+
removeNodeAndCleanRefs,
|
|
2306
2343
|
rendersAsButtons,
|
|
2344
|
+
resolveConnection,
|
|
2307
2345
|
slugifyNodeId,
|
|
2308
2346
|
targetsOf,
|
|
2309
2347
|
validateGraph
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adatechnology/conversations-ui",
|
|
3
|
-
"version": "0.1.0-rc.
|
|
3
|
+
"version": "0.1.0-rc.28",
|
|
4
4
|
"description": "WhatsApp conversation UI components — parametrizável por endpoint, tema e feature flags",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"@types/react-dom": "^18 || ^19"
|
|
52
52
|
},
|
|
53
53
|
"scripts": {
|
|
54
|
-
"build": "tsup src/index.ts src/flows/index.ts src/channel/index.ts src/preview/index.ts src/styles.css --dts --format esm --external react --external react-dom --external @xyflow/react",
|
|
54
|
+
"build": "tsup src/index.ts src/flows/index.ts src/channel/index.ts src/preview/index.ts src/styles.css --dts --clean --format esm --external react --external react-dom --external @xyflow/react",
|
|
55
55
|
"build:watch": "tsup src/index.ts src/flows/index.ts src/channel/index.ts src/styles.css --watch --dts --format esm --external react --external react-dom --external @xyflow/react",
|
|
56
56
|
"check": "tsc -p tsconfig.json --noEmit",
|
|
57
57
|
"test": "bun test"
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ada Technology. MIT License.
|
|
3
|
+
*
|
|
4
|
+
* O único teste que lê o `dist` em vez do fonte.
|
|
5
|
+
*
|
|
6
|
+
* Existe porque o `notification-ui@rc.1` saiu com 19 testes verdes e não renderizava. Dois defeitos,
|
|
7
|
+
* os dois invisíveis para quem importa o fonte:
|
|
8
|
+
*
|
|
9
|
+
* 1. `jsx: react-jsx` chegava ao tsup por um tsconfig com `extends`, e o esbuild NÃO segue `extends`
|
|
10
|
+
* para essa opção. O bundle saiu com `React.createElement` sem `React` no escopo, e o produto
|
|
11
|
+
* quebrou com `ReferenceError: React is not defined`.
|
|
12
|
+
* 2. `splitting: false` com dois entrypoints duplicou o módulo de contexto. O provider de um bundle
|
|
13
|
+
* não era o mesmo objeto do consumidor no outro, e o hook acusava "usado fora do provider" estando
|
|
14
|
+
* dentro de um.
|
|
15
|
+
*
|
|
16
|
+
* Nenhum teste de fonte pega isso: eles importam `./index`, não `dist`. Rode depois do build.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, expect, it } from 'bun:test'
|
|
20
|
+
|
|
21
|
+
const DIST = `${import.meta.dir}/../dist`
|
|
22
|
+
|
|
23
|
+
async function distText(file: string): Promise<string> {
|
|
24
|
+
const handle = Bun.file(`${DIST}/${file}`)
|
|
25
|
+
expect(await handle.exists(), `${file} não existe — rode \`bun run build\` antes`).toBe(true)
|
|
26
|
+
return handle.text()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('transform de JSX', () => {
|
|
30
|
+
it('usa o runtime automático, e não React.createElement', async () => {
|
|
31
|
+
for (const file of ['index.js', 'flows/index.js', 'preview/index.js']) {
|
|
32
|
+
const content = await distText(file)
|
|
33
|
+
|
|
34
|
+
expect(content, `${file} com createElement`).not.toContain('React.createElement')
|
|
35
|
+
expect(content, `${file} sem jsx-runtime`).toContain('react/jsx-runtime')
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('divisão de código entre entrypoints', () => {
|
|
41
|
+
it('os entrypoints compartilham chunk em vez de duplicar módulo', async () => {
|
|
42
|
+
// Sem `splitting: true`, cada entrypoint carrega a própria cópia dos módulos comuns — e um
|
|
43
|
+
// contexto do React duplicado deixa de ser o mesmo objeto entre provider e consumidor.
|
|
44
|
+
const chunks = [...new Bun.Glob('chunk-*.js').scanSync({ cwd: DIST })]
|
|
45
|
+
|
|
46
|
+
expect(chunks.length, 'nenhum chunk compartilhado gerado').toBeGreaterThan(0)
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('telas compostas chegam ao pacote publicado', () => {
|
|
51
|
+
it('o subpath raiz entrega MessagesWorkspace, no js e nos tipos', async () => {
|
|
52
|
+
expect(await distText('index.js')).toContain('MessagesWorkspace')
|
|
53
|
+
expect(await distText('index.d.ts')).toContain('MessagesWorkspace')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('o subpath /flows entrega FlowsWorkspace, no js e nos tipos', async () => {
|
|
57
|
+
// Export declarado no `index.ts` e ausente do `dist` é o modo de falhar mais barato de cometer e
|
|
58
|
+
// mais caro de descobrir: só aparece no produto, depois de publicar.
|
|
59
|
+
expect(await distText('flows/index.js')).toContain('FlowsWorkspace')
|
|
60
|
+
expect(await distText('flows/index.d.ts')).toContain('FlowsWorkspace')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('o CSS publicado traz as classes que as telas consomem', async () => {
|
|
64
|
+
const css = await distText('styles.css')
|
|
65
|
+
|
|
66
|
+
// A inbox depende destas classes do pacote (o editor de fluxos e a tela de mensagens estilizam por
|
|
67
|
+
// utilitários do host). Publicar sem elas deixa a tela montada e sem layout — e nada falha.
|
|
68
|
+
expect(css).toContain('.cv-workspace')
|
|
69
|
+
expect(css).toContain('.cv-workspace-modal')
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('o bundle do host não paga por @xyflow/react sem pedir', () => {
|
|
74
|
+
it('o subpath raiz não puxa o xyflow', async () => {
|
|
75
|
+
// É o motivo de o editor viver em `/flows`: quem só usa a inbox não carrega a biblioteca de
|
|
76
|
+
// canvas, que é a maior dependência do pacote.
|
|
77
|
+
expect(await distText('index.js')).not.toContain('@xyflow/react')
|
|
78
|
+
})
|
|
79
|
+
})
|