diff --git a/apps/obsidian/src/components/SchemaSelectionModalBody.tsx b/apps/obsidian/src/components/SchemaSelectionModalBody.tsx new file mode 100644 index 000000000..f4b977423 --- /dev/null +++ b/apps/obsidian/src/components/SchemaSelectionModalBody.tsx @@ -0,0 +1,65 @@ +import { SchemaSelectionPanel } from "~/components/SchemaSelectionPanel"; +import type { + SchemaSelectionSource, + SchemaSelectionState, +} from "~/components/useSchemaSelection"; + +type SchemaSelectionModalBodyProps = { + title: string; + description: string; + source: SchemaSelectionSource; + selection: SchemaSelectionState; + onDependencyViolation?: (message: string) => void; + footerSecondaryLabel: string; + onFooterSecondaryClick: () => void; + footerPrimaryLabel: string; + onFooterPrimaryClick: () => void; + isFooterPrimaryDisabled?: boolean; + isFooterSecondaryDisabled?: boolean; +}; + +export const SchemaSelectionModalBody = ({ + title, + description, + source, + selection, + onDependencyViolation, + footerSecondaryLabel, + onFooterSecondaryClick, + footerPrimaryLabel, + onFooterPrimaryClick, + isFooterPrimaryDisabled = false, + isFooterSecondaryDisabled = false, +}: SchemaSelectionModalBodyProps) => { + return ( +
+

{title}

+

{description}

+ + + +
+ + +
+
+ ); +}; diff --git a/apps/obsidian/src/components/SchemaSelectionPanel.tsx b/apps/obsidian/src/components/SchemaSelectionPanel.tsx new file mode 100644 index 000000000..23848e1da --- /dev/null +++ b/apps/obsidian/src/components/SchemaSelectionPanel.tsx @@ -0,0 +1,296 @@ +import type { + SchemaSelectionSource, + SchemaSelectionState, +} from "~/components/useSchemaSelection"; + +type SchemaSelectionPanelProps = { + source: SchemaSelectionSource; + selection: SchemaSelectionState; + onDependencyViolation?: (message: string) => void; +}; + +export const SchemaSelectionPanel = ({ + source, + selection, + 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 sortedNodeTypes = [...source.nodeTypes].sort((a, b) => + a.name.localeCompare(b.name), + ); + const templateToNodeTypeNames = new Map(); + for (const nodeType of sortedNodeTypes) { + if (!nodeType.template) continue; + const names = templateToNodeTypeNames.get(nodeType.template) ?? []; + names.push(nodeType.name); + templateToNodeTypeNames.set(nodeType.template, names); + } + 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 ? ( +

No template files found.

+ ) : ( +
+ {source.templateNames.map((templateName) => ( + + ))} +
+ )} +
+
+ + ); +}; diff --git a/apps/obsidian/src/components/useSchemaSelection.ts b/apps/obsidian/src/components/useSchemaSelection.ts new file mode 100644 index 000000000..ebeb59710 --- /dev/null +++ b/apps/obsidian/src/components/useSchemaSelection.ts @@ -0,0 +1,238 @@ +import { useEffect, useMemo, useState } from "react"; +import type { + DiscourseNode, + DiscourseRelation, + DiscourseRelationType, + SchemaSelection, +} 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: () => SchemaSelection; +}; + +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], + discourseRelationIds: [...selectedRelationIds], + templateNames: [...selectedTemplateNames], + }), + }; +};