From f279112cea9b6f08ac3afbb152487b442b25ce55 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 28 May 2026 00:20:13 -0400 Subject: [PATCH 1/6] ENG-839: Guard incomplete relation types in canvas Prevent crashes and confusing warnings when relation definitions are missing required fields, and guide users to settings to fix relation configuration. Co-authored-by: Cursor --- .../DiscourseRelationTool.tsx | 36 +++++++++ .../DiscourseRelationUtil.tsx | 2 +- .../canvas/overlays/DragHandleOverlay.tsx | 81 +++++++++++++++++++ .../canvas/overlays/RelationTypeDropdown.tsx | 8 ++ .../settings/DiscourseRelationConfigPanel.tsx | 12 ++- 5 files changed, 136 insertions(+), 3 deletions(-) diff --git a/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationTool.tsx b/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationTool.tsx index 59b81387d..ac1110e5a 100644 --- a/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationTool.tsx +++ b/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationTool.tsx @@ -56,6 +56,24 @@ export const createAllReferencedNodeTools = ( override onEnter = () => { this.didTimeout = false; + const selectedRelations = discourseContext.relations[action] || []; + const hasIncompleteSelectedRelation = selectedRelations.some( + (relation) => + !relation.label?.trim?.() || + !relation.complement?.trim?.() || + relation.complement === "?" || + !relation.source?.trim?.() || + relation.source === "?" || + !relation.destination?.trim?.() || + relation.destination === "?", + ); + if (hasIncompleteSelectedRelation) { + this.cancelAndWarn( + "Relation type is incomplete. Set label, complement, source, and destination in settings.", + ); + return; + } + const target = this.editor.getShapeAtPoint( this.editor.inputs.currentPagePoint, // { @@ -341,6 +359,24 @@ export const createAllRelationShapeTools = ( override onEnter = () => { this.didTimeout = false; + const selectedRelations = discourseContext.relations[name] || []; + const hasIncompleteSelectedRelation = selectedRelations.some( + (relation) => + !relation.label?.trim?.() || + !relation.complement?.trim?.() || + relation.complement === "?" || + !relation.source?.trim?.() || + relation.source === "?" || + !relation.destination?.trim?.() || + relation.destination === "?", + ); + if (hasIncompleteSelectedRelation) { + this.cancelAndWarn( + "Relation type is incomplete. Set label, complement, source, and destination in settings.", + ); + return; + } + const target = this.editor.getShapeAtPoint( this.editor.inputs.currentPagePoint, // { diff --git a/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx b/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx index 9cf91f6d4..6dc03cb95 100644 --- a/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx +++ b/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx @@ -181,7 +181,7 @@ export const createAllReferencedNodeUtils = ( if (!possibleTargets.includes(target.type)) { return deleteAndWarn( `Target node must be of type ${possibleTargets - .map((t) => discourseContext.nodes[t].text) + .map((t) => discourseContext.nodes[t]?.text ?? t) .join(", ")}`, ); } diff --git a/apps/roam/src/components/canvas/overlays/DragHandleOverlay.tsx b/apps/roam/src/components/canvas/overlays/DragHandleOverlay.tsx index 37d25869c..9031fdf37 100644 --- a/apps/roam/src/components/canvas/overlays/DragHandleOverlay.tsx +++ b/apps/roam/src/components/canvas/overlays/DragHandleOverlay.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import { TLShapeId, createShapeId, useEditor, useValue } from "tldraw"; +import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; import { DiscourseNodeShape } from "~/components/canvas/DiscourseNodeUtil"; import { BaseDiscourseRelationUtil, @@ -7,6 +8,7 @@ import { getRelationColor, } from "~/components/canvas/DiscourseRelationShape/DiscourseRelationUtil"; import { createOrUpdateArrowBinding } from "~/components/canvas/DiscourseRelationShape/helpers"; +import { getGlobalSettings } from "~/components/settings/utils/accessors"; import { checkConnectionType, getAllRelations, @@ -19,6 +21,7 @@ import { RelationTypeDropdown } from "./RelationTypeDropdown"; const HANDLE_RADIUS = 5; const HANDLE_HIT_AREA = 12; const HANDLE_PADDING = 8; +const DISCOURSE_CONFIG_PAGE_TITLE = "roam/js/discourse-graph"; type HandlePosition = { x: number; @@ -70,6 +73,35 @@ const getEdgeMidpoints = (bounds: { export const DragHandleOverlay = () => { const editor = useEditor(); + const openDiscourseGraphSettings = useCallback(() => { + const uid = getPageUidByPageTitle(DISCOURSE_CONFIG_PAGE_TITLE); + if (!uid) return; + void window.roamAlphaAPI.ui.mainWindow.openPage({ page: { uid } }); + }, []); + + const hasIncompleteRelationTypes = useCallback((): boolean => { + try { + const relations = getGlobalSettings()?.Relations; + if (!relations || typeof relations !== "object") return false; + return Object.values(relations).some((relation) => { + if (!relation || typeof relation !== "object") return true; + const r = relation as Record; + return ( + typeof r.label !== "string" || + r.label.trim().length === 0 || + typeof r.complement !== "string" || + r.complement.trim().length === 0 || + typeof r.source !== "string" || + r.source.trim().length === 0 || + typeof r.destination !== "string" || + r.destination.trim().length === 0 + ); + }); + } catch { + return false; + } + }, []); + // Drag state: track the drag line in viewport coords (no tldraw shapes) const [isDragging, setIsDragging] = useState(false); const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>( @@ -210,12 +242,61 @@ export const DragHandleOverlay = () => { return; } + // Validate that relation types are fully configured + if (hasIncompleteRelationTypes()) { + dispatchToastEvent({ + id: "tldraw-incomplete-relations-configured", + title: "Relation types are incomplete", + description: + "Each relation type must have label, complement, source, and destination set before it can be used on the canvas.", + severity: "warning", + actions: [ + { + type: "primary", + label: "Open settings", + onClick: openDiscourseGraphSettings, + }, + ], + }); + sourceNodeRef.current = null; + return; + } + + // Validate that relation types exist at all + if (getAllRelations().length === 0) { + dispatchToastEvent({ + id: "tldraw-no-relations-configured", + title: "No relation types are configured yet", + description: + "Open Discourse Graph settings to configure Relations before creating canvas relations.", + severity: "warning", + actions: [ + { + type: "primary", + label: "Open settings", + onClick: openDiscourseGraphSettings, + }, + ], + }); + sourceNodeRef.current = null; + return; + } + // Validate that relation types exist between these node types if (!hasValidRelationTypes(selectedNode.type, target.type)) { dispatchToastEvent({ id: "tldraw-no-valid-relation", title: "No relation types are defined between these node types", + description: + "Open Discourse Graph settings to configure Relations and Nodes.", severity: "warning", + actions: [ + { + type: "primary", + label: "Open settings", + onClick: openDiscourseGraphSettings, + }, + ], }); sourceNodeRef.current = null; return; diff --git a/apps/roam/src/components/canvas/overlays/RelationTypeDropdown.tsx b/apps/roam/src/components/canvas/overlays/RelationTypeDropdown.tsx index 758a94033..a47021845 100644 --- a/apps/roam/src/components/canvas/overlays/RelationTypeDropdown.tsx +++ b/apps/roam/src/components/canvas/overlays/RelationTypeDropdown.tsx @@ -47,6 +47,14 @@ export const RelationTypeDropdown = ({ const seenLabels = new Set(); for (const relation of allRelations) { + if ( + !relation.label?.trim?.() || + !relation.complement?.trim?.() || + !relation.source?.trim?.() || + !relation.destination?.trim?.() + ) { + continue; + } const { isDirect: isForward, isReverse } = checkConnectionType( relation, startNodeType, diff --git a/apps/roam/src/components/settings/DiscourseRelationConfigPanel.tsx b/apps/roam/src/components/settings/DiscourseRelationConfigPanel.tsx index 7b767b77c..2cf2a9d50 100644 --- a/apps/roam/src/components/settings/DiscourseRelationConfigPanel.tsx +++ b/apps/roam/src/components/settings/DiscourseRelationConfigPanel.tsx @@ -516,7 +516,15 @@ export const RelationEditPanel = ({ const relationEl = document.getElementById("relation-label"); relationEl?.focus(); } - }, []); + }, [label]); + + const isRelationComplete = + label.trim().length > 0 && + complement.trim().length > 0 && + source.trim().length > 0 && + source !== "?" && + destination.trim().length > 0 && + destination !== "?"; return ( <> @@ -535,7 +543,7 @@ export const RelationEditPanel = ({ icon={"floppy-disk"} text={"Save"} intent={Intent.PRIMARY} - disabled={loading || !hasChanges} + disabled={loading || !hasChanges || !isRelationComplete} className="select-none" onClick={() => { setLoading(true); From f0167d13d5d16ffcc137316e6d192a1acc8233ac Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 28 May 2026 00:23:31 -0400 Subject: [PATCH 2/6] cleanup --- .../DiscourseRelationUtil.tsx | 2 +- .../canvas/overlays/DragHandleOverlay.tsx | 81 ------------------- 2 files changed, 1 insertion(+), 82 deletions(-) diff --git a/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx b/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx index 6dc03cb95..9cf91f6d4 100644 --- a/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx +++ b/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationUtil.tsx @@ -181,7 +181,7 @@ export const createAllReferencedNodeUtils = ( if (!possibleTargets.includes(target.type)) { return deleteAndWarn( `Target node must be of type ${possibleTargets - .map((t) => discourseContext.nodes[t]?.text ?? t) + .map((t) => discourseContext.nodes[t].text) .join(", ")}`, ); } diff --git a/apps/roam/src/components/canvas/overlays/DragHandleOverlay.tsx b/apps/roam/src/components/canvas/overlays/DragHandleOverlay.tsx index 9031fdf37..37d25869c 100644 --- a/apps/roam/src/components/canvas/overlays/DragHandleOverlay.tsx +++ b/apps/roam/src/components/canvas/overlays/DragHandleOverlay.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import { TLShapeId, createShapeId, useEditor, useValue } from "tldraw"; -import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; import { DiscourseNodeShape } from "~/components/canvas/DiscourseNodeUtil"; import { BaseDiscourseRelationUtil, @@ -8,7 +7,6 @@ import { getRelationColor, } from "~/components/canvas/DiscourseRelationShape/DiscourseRelationUtil"; import { createOrUpdateArrowBinding } from "~/components/canvas/DiscourseRelationShape/helpers"; -import { getGlobalSettings } from "~/components/settings/utils/accessors"; import { checkConnectionType, getAllRelations, @@ -21,7 +19,6 @@ import { RelationTypeDropdown } from "./RelationTypeDropdown"; const HANDLE_RADIUS = 5; const HANDLE_HIT_AREA = 12; const HANDLE_PADDING = 8; -const DISCOURSE_CONFIG_PAGE_TITLE = "roam/js/discourse-graph"; type HandlePosition = { x: number; @@ -73,35 +70,6 @@ const getEdgeMidpoints = (bounds: { export const DragHandleOverlay = () => { const editor = useEditor(); - const openDiscourseGraphSettings = useCallback(() => { - const uid = getPageUidByPageTitle(DISCOURSE_CONFIG_PAGE_TITLE); - if (!uid) return; - void window.roamAlphaAPI.ui.mainWindow.openPage({ page: { uid } }); - }, []); - - const hasIncompleteRelationTypes = useCallback((): boolean => { - try { - const relations = getGlobalSettings()?.Relations; - if (!relations || typeof relations !== "object") return false; - return Object.values(relations).some((relation) => { - if (!relation || typeof relation !== "object") return true; - const r = relation as Record; - return ( - typeof r.label !== "string" || - r.label.trim().length === 0 || - typeof r.complement !== "string" || - r.complement.trim().length === 0 || - typeof r.source !== "string" || - r.source.trim().length === 0 || - typeof r.destination !== "string" || - r.destination.trim().length === 0 - ); - }); - } catch { - return false; - } - }, []); - // Drag state: track the drag line in viewport coords (no tldraw shapes) const [isDragging, setIsDragging] = useState(false); const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>( @@ -242,61 +210,12 @@ export const DragHandleOverlay = () => { return; } - // Validate that relation types are fully configured - if (hasIncompleteRelationTypes()) { - dispatchToastEvent({ - id: "tldraw-incomplete-relations-configured", - title: "Relation types are incomplete", - description: - "Each relation type must have label, complement, source, and destination set before it can be used on the canvas.", - severity: "warning", - actions: [ - { - type: "primary", - label: "Open settings", - onClick: openDiscourseGraphSettings, - }, - ], - }); - sourceNodeRef.current = null; - return; - } - - // Validate that relation types exist at all - if (getAllRelations().length === 0) { - dispatchToastEvent({ - id: "tldraw-no-relations-configured", - title: "No relation types are configured yet", - description: - "Open Discourse Graph settings to configure Relations before creating canvas relations.", - severity: "warning", - actions: [ - { - type: "primary", - label: "Open settings", - onClick: openDiscourseGraphSettings, - }, - ], - }); - sourceNodeRef.current = null; - return; - } - // Validate that relation types exist between these node types if (!hasValidRelationTypes(selectedNode.type, target.type)) { dispatchToastEvent({ id: "tldraw-no-valid-relation", title: "No relation types are defined between these node types", - description: - "Open Discourse Graph settings to configure Relations and Nodes.", severity: "warning", - actions: [ - { - type: "primary", - label: "Open settings", - onClick: openDiscourseGraphSettings, - }, - ], }); sourceNodeRef.current = null; return; From 9720281a8003d018cfb0c8c52b8b7d5156b7a7dd Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 28 May 2026 00:26:36 -0400 Subject: [PATCH 3/6] ENG-839: Remove dead validation from referenced node tools Referenced node tools are keyed by action names, not relation labels, so the incomplete-relation check never ran there. Co-authored-by: Cursor --- .../DiscourseRelationTool.tsx | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationTool.tsx b/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationTool.tsx index ac1110e5a..2fe0524d0 100644 --- a/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationTool.tsx +++ b/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationTool.tsx @@ -56,24 +56,6 @@ export const createAllReferencedNodeTools = ( override onEnter = () => { this.didTimeout = false; - const selectedRelations = discourseContext.relations[action] || []; - const hasIncompleteSelectedRelation = selectedRelations.some( - (relation) => - !relation.label?.trim?.() || - !relation.complement?.trim?.() || - relation.complement === "?" || - !relation.source?.trim?.() || - relation.source === "?" || - !relation.destination?.trim?.() || - relation.destination === "?", - ); - if (hasIncompleteSelectedRelation) { - this.cancelAndWarn( - "Relation type is incomplete. Set label, complement, source, and destination in settings.", - ); - return; - } - const target = this.editor.getShapeAtPoint( this.editor.inputs.currentPagePoint, // { From c12eac847bd4a732b31b041e96f3827d8b74023e Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 28 May 2026 00:28:29 -0400 Subject: [PATCH 4/6] ENG-839: Treat placeholder relation fields as incomplete in dropdown Skip relations with "?" source/destination/complement values so the dropdown matches relation tool validation. Co-authored-by: Cursor --- .../src/components/canvas/overlays/RelationTypeDropdown.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/roam/src/components/canvas/overlays/RelationTypeDropdown.tsx b/apps/roam/src/components/canvas/overlays/RelationTypeDropdown.tsx index a47021845..da3a5e64b 100644 --- a/apps/roam/src/components/canvas/overlays/RelationTypeDropdown.tsx +++ b/apps/roam/src/components/canvas/overlays/RelationTypeDropdown.tsx @@ -50,8 +50,11 @@ export const RelationTypeDropdown = ({ if ( !relation.label?.trim?.() || !relation.complement?.trim?.() || + relation.complement === "?" || !relation.source?.trim?.() || - !relation.destination?.trim?.() + relation.source === "?" || + !relation.destination?.trim?.() || + relation.destination === "?" ) { continue; } From c5d5e2055fbdbff81b9f546b1a040850aec609fa Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Tue, 16 Jun 2026 09:49:39 -0400 Subject: [PATCH 5/6] use DRY check --- .../DiscourseRelationTool.tsx | 10 ++-------- .../canvas/overlays/RelationTypeDropdown.tsx | 13 ++----------- .../settings/DiscourseRelationConfigPanel.tsx | 16 ++++++++-------- apps/roam/src/utils/isRelationComplete.ts | 17 +++++++++++++++++ 4 files changed, 29 insertions(+), 27 deletions(-) create mode 100644 apps/roam/src/utils/isRelationComplete.ts diff --git a/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationTool.tsx b/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationTool.tsx index 2fe0524d0..3de61e49e 100644 --- a/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationTool.tsx +++ b/apps/roam/src/components/canvas/DiscourseRelationShape/DiscourseRelationTool.tsx @@ -10,6 +10,7 @@ import { } from "./DiscourseRelationUtil"; import { discourseContext } from "~/components/canvas/Tldraw"; import { dispatchToastEvent } from "~/components/canvas/ToastListener"; +import { isRelationComplete } from "~/utils/isRelationComplete"; export type AddReferencedNodeType = Record; type ReferenceFormatType = { @@ -343,14 +344,7 @@ export const createAllRelationShapeTools = ( const selectedRelations = discourseContext.relations[name] || []; const hasIncompleteSelectedRelation = selectedRelations.some( - (relation) => - !relation.label?.trim?.() || - !relation.complement?.trim?.() || - relation.complement === "?" || - !relation.source?.trim?.() || - relation.source === "?" || - !relation.destination?.trim?.() || - relation.destination === "?", + (relation) => !isRelationComplete(relation), ); if (hasIncompleteSelectedRelation) { this.cancelAndWarn( diff --git a/apps/roam/src/components/canvas/overlays/RelationTypeDropdown.tsx b/apps/roam/src/components/canvas/overlays/RelationTypeDropdown.tsx index da3a5e64b..cba68cb11 100644 --- a/apps/roam/src/components/canvas/overlays/RelationTypeDropdown.tsx +++ b/apps/roam/src/components/canvas/overlays/RelationTypeDropdown.tsx @@ -6,6 +6,7 @@ import { getAllRelations, isDiscourseNodeShape, } from "~/components/canvas/canvasUtils"; +import { isRelationComplete } from "~/utils/isRelationComplete"; type RelationTypeDropdownProps = { sourceId: TLShapeId; @@ -47,17 +48,7 @@ export const RelationTypeDropdown = ({ const seenLabels = new Set(); for (const relation of allRelations) { - if ( - !relation.label?.trim?.() || - !relation.complement?.trim?.() || - relation.complement === "?" || - !relation.source?.trim?.() || - relation.source === "?" || - !relation.destination?.trim?.() || - relation.destination === "?" - ) { - continue; - } + if (!isRelationComplete(relation)) continue; const { isDirect: isForward, isReverse } = checkConnectionType( relation, startNodeType, diff --git a/apps/roam/src/components/settings/DiscourseRelationConfigPanel.tsx b/apps/roam/src/components/settings/DiscourseRelationConfigPanel.tsx index 2cf2a9d50..7ad12d8f5 100644 --- a/apps/roam/src/components/settings/DiscourseRelationConfigPanel.tsx +++ b/apps/roam/src/components/settings/DiscourseRelationConfigPanel.tsx @@ -44,6 +44,7 @@ import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageU import updateBlock from "roamjs-components/writes/updateBlock"; import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid"; import getDiscourseNodes from "~/utils/getDiscourseNodes"; +import { isRelationComplete } from "~/utils/isRelationComplete"; import { getConditionLabels } from "~/utils/conditionToDatalog"; import { formatHexColor } from "./DiscourseNodeCanvasSettings"; import posthog from "posthog-js"; @@ -518,13 +519,12 @@ export const RelationEditPanel = ({ } }, [label]); - const isRelationComplete = - label.trim().length > 0 && - complement.trim().length > 0 && - source.trim().length > 0 && - source !== "?" && - destination.trim().length > 0 && - destination !== "?"; + const isEditingRelationComplete = isRelationComplete({ + label, + complement, + source, + destination, + }); return ( <> @@ -543,7 +543,7 @@ export const RelationEditPanel = ({ icon={"floppy-disk"} text={"Save"} intent={Intent.PRIMARY} - disabled={loading || !hasChanges || !isRelationComplete} + disabled={loading || !hasChanges || !isEditingRelationComplete} className="select-none" onClick={() => { setLoading(true); diff --git a/apps/roam/src/utils/isRelationComplete.ts b/apps/roam/src/utils/isRelationComplete.ts new file mode 100644 index 000000000..1bcd0d4ca --- /dev/null +++ b/apps/roam/src/utils/isRelationComplete.ts @@ -0,0 +1,17 @@ +import type { DiscourseRelation } from "./getDiscourseRelations"; + +const PLACEHOLDER_VALUES = new Set(["?"]); + +const isNonEmptyNonPlaceholder = ( + value: string | null | undefined, +): boolean => { + if (!value) return false; + const trimmed = value.trim(); + return trimmed.length > 0 && !PLACEHOLDER_VALUES.has(trimmed); +}; + +export const isRelationComplete = (relation: DiscourseRelation): boolean => + isNonEmptyNonPlaceholder(relation.label) && + isNonEmptyNonPlaceholder(relation.complement) && + isNonEmptyNonPlaceholder(relation.source) && + isNonEmptyNonPlaceholder(relation.destination); From 6bf61a2319c43ffa9a062384269ac702aafec3b3 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Tue, 16 Jun 2026 12:19:04 -0400 Subject: [PATCH 6/6] address type checks --- apps/roam/src/utils/isRelationComplete.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/roam/src/utils/isRelationComplete.ts b/apps/roam/src/utils/isRelationComplete.ts index 1bcd0d4ca..01fa2d877 100644 --- a/apps/roam/src/utils/isRelationComplete.ts +++ b/apps/roam/src/utils/isRelationComplete.ts @@ -10,7 +10,9 @@ const isNonEmptyNonPlaceholder = ( return trimmed.length > 0 && !PLACEHOLDER_VALUES.has(trimmed); }; -export const isRelationComplete = (relation: DiscourseRelation): boolean => +export const isRelationComplete = ( + relation: Partial, +): boolean => isNonEmptyNonPlaceholder(relation.label) && isNonEmptyNonPlaceholder(relation.complement) && isNonEmptyNonPlaceholder(relation.source) &&