From a2367b5333f7f2a92df61acb6a87e8cf89bf6cd8 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Tue, 28 Jul 2026 22:39:03 -0400 Subject: [PATCH 1/5] ENG-2083 Add schema selection panel UI for Obsidian export/import --- .../components/SchemaSelectionModalBody.tsx | 77 +++++ .../src/components/SchemaSelectionPanel.tsx | 307 ++++++++++++++++++ 2 files changed, 384 insertions(+) create mode 100644 apps/obsidian/src/components/SchemaSelectionModalBody.tsx create mode 100644 apps/obsidian/src/components/SchemaSelectionPanel.tsx diff --git a/apps/obsidian/src/components/SchemaSelectionModalBody.tsx b/apps/obsidian/src/components/SchemaSelectionModalBody.tsx new file mode 100644 index 000000000..6a37e440a --- /dev/null +++ b/apps/obsidian/src/components/SchemaSelectionModalBody.tsx @@ -0,0 +1,77 @@ +import { SchemaSelectionPanel } from "~/components/SchemaSelectionPanel"; +import type { ReactNode } from "react"; +import type { + SchemaSelectionSource, + SchemaSelectionState, +} from "~/components/useSchemaSelection"; + +type SchemaSelectionModalBodyProps = { + title: string; + description: string; + source: SchemaSelectionSource; + selection: SchemaSelectionState; + emptyTemplateText: string; + onDependencyViolation?: (message: string) => void; + beforePanel?: ReactNode; + afterPanel?: ReactNode; + footerSecondaryLabel: string; + onFooterSecondaryClick: () => void; + footerPrimaryLabel: string; + onFooterPrimaryClick: () => void; + isFooterPrimaryDisabled?: boolean; + isFooterSecondaryDisabled?: boolean; +}; + +export const SchemaSelectionModalBody = ({ + title, + description, + source, + selection, + emptyTemplateText, + onDependencyViolation, + beforePanel, + afterPanel, + footerSecondaryLabel, + onFooterSecondaryClick, + footerPrimaryLabel, + onFooterPrimaryClick, + isFooterPrimaryDisabled = false, + isFooterSecondaryDisabled = false, +}: SchemaSelectionModalBodyProps) => { + return ( +
+

{title}

+

{description}

+ + {beforePanel} + + + + {afterPanel} + +
+ + +
+
+ ); +}; diff --git a/apps/obsidian/src/components/SchemaSelectionPanel.tsx b/apps/obsidian/src/components/SchemaSelectionPanel.tsx new file mode 100644 index 000000000..fd113dce2 --- /dev/null +++ b/apps/obsidian/src/components/SchemaSelectionPanel.tsx @@ -0,0 +1,307 @@ +import type { + SchemaSelectionSource, + SchemaSelectionState, +} from "~/components/useSchemaSelection"; + +type SchemaSelectionPanelProps = { + source: SchemaSelectionSource; + selection: SchemaSelectionState; + emptyTemplateText: string; + onDependencyViolation?: (message: string) => void; +}; + +export const SchemaSelectionPanel = ({ + source, + selection, + emptyTemplateText, + onDependencyViolation, +}: SchemaSelectionPanelProps) => { + const { + selectedNodeTypeIds, + selectedRelationTypeIds, + selectedRelationIds, + selectedTemplateNames, + requiredNodeTypeIds, + requiredRelationTypeIds, + selectAllNodeTypes, + deselectOptionalNodeTypes, + toggleNodeType, + selectAllRelationTypes, + deselectOptionalRelationTypes, + toggleRelationType, + selectAllRelationTriples, + deselectAllRelationTriples, + toggleRelationTriple, + selectAllTemplates, + deselectAllTemplates, + toggleTemplate, + } = selection; + + const nodeTypeById = new Map( + source.nodeTypes.map((nodeType) => [nodeType.id, nodeType]), + ); + const relationTypeById = new Map( + source.relationTypes.map((relationType) => [relationType.id, relationType]), + ); + const templateToNodeTypeNames = new Map(); + for (const nodeType of source.nodeTypes) { + if (!nodeType.template) continue; + const current = templateToNodeTypeNames.get(nodeType.template) ?? []; + current.push(nodeType.name); + templateToNodeTypeNames.set(nodeType.template, current); + } + for (const [ + templateName, + nodeTypeNames, + ] of templateToNodeTypeNames.entries()) { + templateToNodeTypeNames.set( + templateName, + [...new Set(nodeTypeNames)].sort((left, right) => + left.localeCompare(right), + ), + ); + } + const referencedTemplateNames = new Set(templateToNodeTypeNames.keys()); + + return ( + <> +
+
Selection summary
+
+ {selectedNodeTypeIds.size} node type(s) + {selectedRelationTypeIds.size} relation type(s) + {selectedRelationIds.size} relation triple(s) + {selectedTemplateNames.size} template(s) +
+
+ +
+
+
+

Node types

+
+ + +
+
+
+ {source.nodeTypes.map((nodeType) => { + const isRequired = requiredNodeTypeIds.has(nodeType.id); + return ( + + ); + })} +
+
+ +
+
+

Relation types

+
+ + +
+
+
+ {source.relationTypes.map((relationType) => { + const isRequired = requiredRelationTypeIds.has(relationType.id); + return ( + + ); + })} +
+
+ +
+
+

Relation triples

+
+ + +
+
+
+ {source.relationTriples.map((relation) => { + const sourceName = + nodeTypeById.get(relation.sourceId)?.name ?? relation.sourceId; + const destinationName = + nodeTypeById.get(relation.destinationId)?.name ?? + relation.destinationId; + const relationTypeLabel = + relationTypeById.get(relation.relationshipTypeId)?.label ?? + relation.relationshipTypeId; + + return ( + + ); + })} +
+
+ +
+
+

Templates

+
+ + +
+
+ {source.templateNames.length === 0 ? ( +

{emptyTemplateText}

+ ) : ( +
+ {source.templateNames.map((templateName) => ( + + ))} +
+ )} +
+
+ + ); +}; From 647cdca1135ab71de79b8efc76cf81291fe6ef4e Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 29 Jul 2026 15:42:34 -0400 Subject: [PATCH 2/5] ENG-2083 Move useSchemaSelection here from foundation (UI state belongs with selection panel) --- .../src/components/useSchemaSelection.ts | 242 ++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 apps/obsidian/src/components/useSchemaSelection.ts diff --git a/apps/obsidian/src/components/useSchemaSelection.ts b/apps/obsidian/src/components/useSchemaSelection.ts new file mode 100644 index 000000000..319f6047f --- /dev/null +++ b/apps/obsidian/src/components/useSchemaSelection.ts @@ -0,0 +1,242 @@ +import { useEffect, useMemo, useState } from "react"; +import type { + DiscourseNode, + DiscourseRelation, + DiscourseRelationType, +} from "~/types"; + +export type SchemaSelectionSource = { + nodeTypes: Pick[]; + relationTypes: Pick[]; + relationTriples: Pick< + DiscourseRelation, + "id" | "sourceId" | "destinationId" | "relationshipTypeId" + >[]; + templateNames: string[]; +}; + +type SelectionToggleResult = { + ok: boolean; + reason?: string; +}; + +export type SchemaSelectionState = { + selectedNodeTypeIds: Set; + selectedRelationTypeIds: Set; + selectedRelationIds: Set; + selectedTemplateNames: Set; + requiredNodeTypeIds: Set; + requiredRelationTypeIds: Set; + selectAllNodeTypes: () => void; + deselectOptionalNodeTypes: () => void; + toggleNodeType: ( + nodeTypeId: string, + shouldSelect: boolean, + ) => SelectionToggleResult; + selectAllRelationTypes: () => void; + deselectOptionalRelationTypes: () => void; + toggleRelationType: ( + relationTypeId: string, + shouldSelect: boolean, + ) => SelectionToggleResult; + selectAllRelationTriples: () => void; + deselectAllRelationTriples: () => void; + toggleRelationTriple: (relationId: string, shouldSelect: boolean) => void; + selectAllTemplates: () => void; + deselectAllTemplates: () => void; + toggleTemplate: (templateName: string, shouldSelect: boolean) => void; + asSelectionPayload: () => { + nodeTypeIds: string[]; + relationTypeIds: string[]; + relationIds: string[]; + templateNames: string[]; + }; +}; + +const updateSet = ( + previousSet: Set, + id: string, + shouldSelect: boolean, +): Set => { + const nextSet = new Set(previousSet); + if (shouldSelect) { + nextSet.add(id); + } else { + nextSet.delete(id); + } + return nextSet; +}; + +export const getReferencedTemplateNames = ( + nodeTypes: SchemaSelectionSource["nodeTypes"], +): Set => { + return new Set( + nodeTypes + .map((nodeType) => nodeType.template) + .filter((template): template is string => !!template), + ); +}; + +export const useSchemaSelection = ({ + source, + initialTemplateNames, + resetKey, +}: { + source: SchemaSelectionSource; + /** + * Template names to pre-select on mount and on reset. Defaults to all + * templates in source when not provided. + */ + initialTemplateNames?: string[]; + resetKey: string; +}): SchemaSelectionState => { + const [selectedNodeTypeIds, setSelectedNodeTypeIds] = useState>( + () => new Set(source.nodeTypes.map((nodeType) => nodeType.id)), + ); + const [selectedRelationTypeIds, setSelectedRelationTypeIds] = useState< + Set + >(() => new Set(source.relationTypes.map((relationType) => relationType.id))); + const [selectedRelationIds, setSelectedRelationIds] = useState>( + () => new Set(source.relationTriples.map((relation) => relation.id)), + ); + const [selectedTemplateNames, setSelectedTemplateNames] = useState< + Set + >(() => new Set(initialTemplateNames ?? source.templateNames)); + + // resetKey is the only trigger; source and initialTemplateNames are read + // from the current render's closure when resetKey changes. + useEffect(() => { + setSelectedNodeTypeIds( + new Set(source.nodeTypes.map((nodeType) => nodeType.id)), + ); + setSelectedRelationTypeIds( + new Set(source.relationTypes.map((relationType) => relationType.id)), + ); + setSelectedRelationIds( + new Set(source.relationTriples.map((relation) => relation.id)), + ); + setSelectedTemplateNames( + new Set(initialTemplateNames ?? source.templateNames), + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resetKey]); + + const requiredRelationTypeIds = useMemo(() => { + const requiredIds = new Set(); + for (const relation of source.relationTriples) { + if (selectedRelationIds.has(relation.id)) { + requiredIds.add(relation.relationshipTypeId); + } + } + return requiredIds; + }, [source.relationTriples, selectedRelationIds]); + + const requiredNodeTypeIds = useMemo(() => { + const requiredIds = new Set(); + for (const relation of source.relationTriples) { + if (!selectedRelationIds.has(relation.id)) { + continue; + } + requiredIds.add(relation.sourceId); + requiredIds.add(relation.destinationId); + } + return requiredIds; + }, [source.relationTriples, selectedRelationIds]); + + useEffect(() => { + setSelectedRelationTypeIds((previousSet) => { + const nextSet = new Set(previousSet); + let didChange = false; + for (const relationTypeId of requiredRelationTypeIds) { + if (!nextSet.has(relationTypeId)) { + nextSet.add(relationTypeId); + didChange = true; + } + } + return didChange ? nextSet : previousSet; + }); + }, [requiredRelationTypeIds]); + + useEffect(() => { + setSelectedNodeTypeIds((previousSet) => { + const nextSet = new Set(previousSet); + let didChange = false; + for (const nodeTypeId of requiredNodeTypeIds) { + if (!nextSet.has(nodeTypeId)) { + nextSet.add(nodeTypeId); + didChange = true; + } + } + return didChange ? nextSet : previousSet; + }); + }, [requiredNodeTypeIds]); + + return { + selectedNodeTypeIds, + selectedRelationTypeIds, + selectedRelationIds, + selectedTemplateNames, + requiredNodeTypeIds, + requiredRelationTypeIds, + selectAllNodeTypes: () => + setSelectedNodeTypeIds( + new Set(source.nodeTypes.map((nodeType) => nodeType.id)), + ), + deselectOptionalNodeTypes: () => + setSelectedNodeTypeIds(new Set([...requiredNodeTypeIds])), + toggleNodeType: (nodeTypeId, shouldSelect) => { + if (!shouldSelect && requiredNodeTypeIds.has(nodeTypeId)) { + return { + ok: false, + reason: + "This node type is required by a selected relation triple. Remove the triple first.", + }; + } + setSelectedNodeTypeIds((previousSet) => + updateSet(previousSet, nodeTypeId, shouldSelect), + ); + return { ok: true }; + }, + selectAllRelationTypes: () => + setSelectedRelationTypeIds( + new Set(source.relationTypes.map((relationType) => relationType.id)), + ), + deselectOptionalRelationTypes: () => + setSelectedRelationTypeIds(new Set([...requiredRelationTypeIds])), + toggleRelationType: (relationTypeId, shouldSelect) => { + if (!shouldSelect && requiredRelationTypeIds.has(relationTypeId)) { + return { + ok: false, + reason: + "This relation type is required by a selected relation triple. Remove the triple first.", + }; + } + setSelectedRelationTypeIds((previousSet) => + updateSet(previousSet, relationTypeId, shouldSelect), + ); + return { ok: true }; + }, + selectAllRelationTriples: () => + setSelectedRelationIds( + new Set(source.relationTriples.map((relation) => relation.id)), + ), + deselectAllRelationTriples: () => setSelectedRelationIds(new Set()), + toggleRelationTriple: (relationId, shouldSelect) => + setSelectedRelationIds((previousSet) => + updateSet(previousSet, relationId, shouldSelect), + ), + selectAllTemplates: () => + setSelectedTemplateNames(new Set(source.templateNames)), + deselectAllTemplates: () => setSelectedTemplateNames(new Set()), + toggleTemplate: (templateName, shouldSelect) => + setSelectedTemplateNames((previousSet) => + updateSet(previousSet, templateName, shouldSelect), + ), + asSelectionPayload: () => ({ + nodeTypeIds: [...selectedNodeTypeIds], + relationTypeIds: [...selectedRelationTypeIds], + relationIds: [...selectedRelationIds], + templateNames: [...selectedTemplateNames], + }), + }; +}; From ace9557764ec80ec457205213de4e7e381b2f4e6 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 29 Jul 2026 22:56:03 -0400 Subject: [PATCH 3/5] remove verbose --- apps/obsidian/src/components/SchemaSelectionPanel.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/obsidian/src/components/SchemaSelectionPanel.tsx b/apps/obsidian/src/components/SchemaSelectionPanel.tsx index fd113dce2..dc2c8b729 100644 --- a/apps/obsidian/src/components/SchemaSelectionPanel.tsx +++ b/apps/obsidian/src/components/SchemaSelectionPanel.tsx @@ -292,8 +292,7 @@ export const SchemaSelectionPanel = ({ used by{" "} {(templateToNodeTypeNames.get(templateName) ?? []).join( ", ", - )}{" "} - type + )} )} From 437c3149591fd6a3caa3daf4f6d4261e2b5418c2 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 29 Jul 2026 23:15:28 -0400 Subject: [PATCH 4/5] ENG-2083 Remove emptyTemplateText/beforePanel/afterPanel props; fix templateToNodeTypeNames double-pass - Remove `emptyTemplateText` prop from SchemaSelectionPanel and SchemaSelectionModalBody; hardcode "No template files found." - Remove `beforePanel` and `afterPanel` props from SchemaSelectionModalBody (afterPanel had no callers; beforePanel is now composed as a sibling at the call site) - Collapse templateToNodeTypeNames two-pass sort into a single sorted source array + single map-build pass Co-Authored-By: Claude Sonnet 4.6 --- .../components/SchemaSelectionModalBody.tsx | 12 --------- .../src/components/SchemaSelectionPanel.tsx | 26 ++++++------------- 2 files changed, 8 insertions(+), 30 deletions(-) diff --git a/apps/obsidian/src/components/SchemaSelectionModalBody.tsx b/apps/obsidian/src/components/SchemaSelectionModalBody.tsx index 6a37e440a..f4b977423 100644 --- a/apps/obsidian/src/components/SchemaSelectionModalBody.tsx +++ b/apps/obsidian/src/components/SchemaSelectionModalBody.tsx @@ -1,5 +1,4 @@ import { SchemaSelectionPanel } from "~/components/SchemaSelectionPanel"; -import type { ReactNode } from "react"; import type { SchemaSelectionSource, SchemaSelectionState, @@ -10,10 +9,7 @@ type SchemaSelectionModalBodyProps = { description: string; source: SchemaSelectionSource; selection: SchemaSelectionState; - emptyTemplateText: string; onDependencyViolation?: (message: string) => void; - beforePanel?: ReactNode; - afterPanel?: ReactNode; footerSecondaryLabel: string; onFooterSecondaryClick: () => void; footerPrimaryLabel: string; @@ -27,10 +23,7 @@ export const SchemaSelectionModalBody = ({ description, source, selection, - emptyTemplateText, onDependencyViolation, - beforePanel, - afterPanel, footerSecondaryLabel, onFooterSecondaryClick, footerPrimaryLabel, @@ -43,17 +36,12 @@ export const SchemaSelectionModalBody = ({

{title}

{description}

- {beforePanel} - - {afterPanel} -
{source.templateNames.length === 0 ? ( -

{emptyTemplateText}

+

No template files found.

) : (
{source.templateNames.map((templateName) => ( From 07ccd443e42a8c1ebaef024a29a379faf3f695ea Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 30 Jul 2026 12:50:13 -0400 Subject: [PATCH 5/5] =?UTF-8?q?ENG-2083=20Use=20SchemaSelection=20type=20f?= =?UTF-8?q?rom=20~/types;=20rename=20relationIds=20=E2=86=92=20discourseRe?= =?UTF-8?q?lationIds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- apps/obsidian/src/components/useSchemaSelection.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/apps/obsidian/src/components/useSchemaSelection.ts b/apps/obsidian/src/components/useSchemaSelection.ts index 319f6047f..ebeb59710 100644 --- a/apps/obsidian/src/components/useSchemaSelection.ts +++ b/apps/obsidian/src/components/useSchemaSelection.ts @@ -3,6 +3,7 @@ import type { DiscourseNode, DiscourseRelation, DiscourseRelationType, + SchemaSelection, } from "~/types"; export type SchemaSelectionSource = { @@ -45,12 +46,7 @@ export type SchemaSelectionState = { selectAllTemplates: () => void; deselectAllTemplates: () => void; toggleTemplate: (templateName: string, shouldSelect: boolean) => void; - asSelectionPayload: () => { - nodeTypeIds: string[]; - relationTypeIds: string[]; - relationIds: string[]; - templateNames: string[]; - }; + asSelectionPayload: () => SchemaSelection; }; const updateSet = ( @@ -235,7 +231,7 @@ export const useSchemaSelection = ({ asSelectionPayload: () => ({ nodeTypeIds: [...selectedNodeTypeIds], relationTypeIds: [...selectedRelationTypeIds], - relationIds: [...selectedRelationIds], + discourseRelationIds: [...selectedRelationIds], templateNames: [...selectedTemplateNames], }), };