diff --git a/apps/roam/src/components/settings/DiscourseNodeIndex.tsx b/apps/roam/src/components/settings/DiscourseNodeIndex.tsx index 709a0fc00..a0d77ae4d 100644 --- a/apps/roam/src/components/settings/DiscourseNodeIndex.tsx +++ b/apps/roam/src/components/settings/DiscourseNodeIndex.tsx @@ -1,61 +1,9 @@ -import React, { useEffect } from "react"; -import { Spinner } from "@blueprintjs/core"; -import ExtensionApiContextProvider from "roamjs-components/components/ExtensionApiContext"; -import type { OnloadArgs } from "roamjs-components/types/native"; +import React from "react"; import type { DiscourseNode } from "~/utils/getDiscourseNodes"; -import QueryBuilder from "~/components/QueryBuilder"; -import parseQuery, { DEFAULT_RETURN_NODE } from "~/utils/parseQuery"; -import createBlock from "roamjs-components/writes/createBlock"; +import DiscourseNodeQueryBuilder from "./components/DiscourseNodeQueryBuilder"; -const NodeIndex = ({ - parentUid, - node, - onloadArgs, -}: { - parentUid: string; - node: DiscourseNode; - onloadArgs: OnloadArgs; -}) => { - const initialQueryArgs = React.useMemo( - () => parseQuery(parentUid), - [parentUid], - ); - const [showQuery, setShowQuery] = React.useState( - !!initialQueryArgs.conditions.length, - ); - useEffect(() => { - if (!showQuery) { - createBlock({ - parentUid: initialQueryArgs.conditionsNodesUid, - node: { - text: "clause", - children: [ - { - text: "source", - children: [{ text: DEFAULT_RETURN_NODE }], - }, - { - text: "relation", - children: [{ text: "is a" }], - }, - { - text: "target", - children: [ - { - text: node.text, - }, - ], - }, - ], - }, - }).then(() => setShowQuery(true)); - } - }, [parentUid, initialQueryArgs, showQuery]); - return ( - - {showQuery ? : } - - ); +const NodeIndex = ({ node }: { node: DiscourseNode }) => { + return ; }; export default NodeIndex; diff --git a/apps/roam/src/components/settings/DiscourseNodeSpecification.tsx b/apps/roam/src/components/settings/DiscourseNodeSpecification.tsx index d6fa02e6b..fbdd1bc0f 100644 --- a/apps/roam/src/components/settings/DiscourseNodeSpecification.tsx +++ b/apps/roam/src/components/settings/DiscourseNodeSpecification.tsx @@ -1,78 +1,66 @@ import React from "react"; -import getSubTree from "roamjs-components/util/getSubTree"; -import createBlock from "roamjs-components/writes/createBlock"; import { Checkbox } from "@blueprintjs/core"; -import getBasicTreeByParentUid from "roamjs-components/queries/getBasicTreeByParentUid"; -import deleteBlock from "roamjs-components/writes/deleteBlock"; import getDiscourseNodes from "~/utils/getDiscourseNodes"; import getDiscourseNodeFormatExpression from "~/utils/getDiscourseNodeFormatExpression"; -import QueryEditor from "~/components/QueryEditor"; +import DiscourseNodeQueryEditor from "./components/DiscourseNodeQueryEditor"; +import { + getDiscourseNodeSetting, + setDiscourseNodeSetting, +} from "./utils/accessors"; +import type { Condition } from "~/utils/types"; + +const generateUID = (): string => + window.roamAlphaAPI?.util?.generateUID?.() ?? + Math.random().toString(36).substring(2, 11); const NodeSpecification = ({ - parentUid, node, }: { - parentUid: string; node: ReturnType[number]; }) => { - const [migrated, setMigrated] = React.useState(false); - const [enabled, setEnabled] = React.useState( - () => - getSubTree({ tree: getBasicTreeByParentUid(parentUid), key: "enabled" }) - ?.uid, - ); - React.useEffect(() => { - if (enabled) { - const scratchNode = getSubTree({ parentUid, key: "scratch" }); - if ( - !scratchNode.children.length || - !getSubTree({ tree: scratchNode.children, key: "conditions" }).children - .length - ) { - const conditionsUid = getSubTree({ - parentUid: scratchNode.uid, - key: "conditions", - }).uid; - const returnUid = getSubTree({ - parentUid: scratchNode.uid, - key: "return", - }).uid; - createBlock({ - parentUid: returnUid, - node: { - text: node.text, - }, - }) - .then(() => - createBlock({ - parentUid: conditionsUid, - node: { - text: "clause", - children: [ - { text: "source", children: [{ text: node.text }] }, - { text: "relation", children: [{ text: "has title" }] }, - { - text: "target", - children: [ - { - text: `/${ - getDiscourseNodeFormatExpression(node.format).source - }/`, - }, - ], - }, - ], - }, - }), - ) - .then(() => setMigrated(true)); + const nodeType = node.type; + + const [enabled, setEnabled] = React.useState(() => { + const spec = getDiscourseNodeSetting(nodeType, [ + "specification", + ]); + return spec !== null && spec !== undefined && spec.length > 0; + }); + + const createInitialCondition = React.useCallback((): Condition => { + return { + uid: generateUID(), + type: "clause", + source: node.text, + relation: "has title", + target: `/${getDiscourseNodeFormatExpression(node.format).source}/`, + }; + }, [node.text, node.format]); + + const handleEnabledChange = React.useCallback( + (e: React.FormEvent) => { + const flag = (e.target as HTMLInputElement).checked; + setEnabled(flag); + + if (flag) { + // Create initial condition when enabling + const existingSpec = getDiscourseNodeSetting(nodeType, [ + "specification", + ]); + if (!existingSpec || existingSpec.length === 0) { + const initialCondition = createInitialCondition(); + setDiscourseNodeSetting(nodeType, ["specification"], [ + initialCondition, + ]); + } + } else { + // Clear specification when disabling + setDiscourseNodeSetting(nodeType, ["specification"], []); } - } else { - const tree = getBasicTreeByParentUid(parentUid); - const scratchNode = getSubTree({ tree, key: "scratch" }); - Promise.all(scratchNode.children.map((c) => deleteBlock(c.uid))); - } - }, [parentUid, setMigrated, enabled]); + }, + [nodeType, createInitialCondition], + ); + return (

{ - const flag = (e.target as HTMLInputElement).checked; - if (flag) { - createBlock({ - parentUid, - order: 2, - node: { text: "enabled" }, - }).then(setEnabled); - } else { - deleteBlock(enabled).then(() => setEnabled("")); - } - }} + onChange={handleEnabledChange} />

-
diff --git a/apps/roam/src/components/settings/NodeConfig.tsx b/apps/roam/src/components/settings/NodeConfig.tsx index 85e4436b2..77162cd94 100644 --- a/apps/roam/src/components/settings/NodeConfig.tsx +++ b/apps/roam/src/components/settings/NodeConfig.tsx @@ -164,15 +164,13 @@ const NodeConfig = ({ onloadArgs: OnloadArgs; }) => { const suggestiveModeEnabled = useFeatureFlag("Suggestive Mode Enabled"); - // UIDs still needed for deferred complex settings (template, specification, etc.) + // UIDs still needed for deferred complex settings (template) const getUid = (key: string) => getSubTree({ parentUid: node.type, key: key, }).uid; const templateUid = getUid("Template"); - const specificationUid = getUid("Specification"); - const indexUid = getUid("Index"); const [selectedTabId, setSelectedTabId] = useState("general"); const [tagError, setTagError] = useState(""); @@ -301,11 +299,7 @@ const NodeConfig = ({ title="Index" panel={
- +
} /> @@ -329,10 +323,7 @@ const NodeConfig = ({ "The conditions specified to identify a ${nodeText} node." } /> - + } diff --git a/apps/roam/src/components/settings/components/DiscourseNodeQueryBuilder.tsx b/apps/roam/src/components/settings/components/DiscourseNodeQueryBuilder.tsx new file mode 100644 index 000000000..da72e6bc0 --- /dev/null +++ b/apps/roam/src/components/settings/components/DiscourseNodeQueryBuilder.tsx @@ -0,0 +1,127 @@ +import React, { useState, useCallback, useMemo, useEffect } from "react"; +import { Button, Card, Spinner } from "@blueprintjs/core"; +import fireQuery from "~/utils/fireQuery"; +import { DEFAULT_RETURN_NODE } from "~/utils/parseQuery"; +import type { Condition, Result, Column } from "~/utils/types"; +import { + getDiscourseNodeSetting, + setDiscourseNodeSetting, +} from "../utils/accessors"; +import DiscourseNodeQueryEditor from "./DiscourseNodeQueryEditor"; +import ResultsView from "~/components/results-view/ResultsView"; + +const generateUID = (): string => + window.roamAlphaAPI?.util?.generateUID?.() ?? + Math.random().toString(36).substring(2, 11); + +type Props = { + nodeType: string; + nodeText: string; +}; + +const DiscourseNodeQueryBuilder = ({ nodeType, nodeText }: Props) => { + const defaultCondition = useMemo( + () => ({ + uid: generateUID(), + type: "clause", + source: DEFAULT_RETURN_NODE, + relation: "is a", + target: nodeText, + }), + [nodeText], + ); + + const [isEdit, setIsEdit] = useState(() => { + const indexData = getDiscourseNodeSetting<{ + conditions: Condition[]; + }>(nodeType, ["index"]); + return !indexData?.conditions?.length; + }); + + const [loading, setLoading] = useState(false); + const [results, setResults] = useState([]); + const [columns, setColumns] = useState([ + { key: "text", uid: "", selection: DEFAULT_RETURN_NODE }, + ]); + + const getConditions = useCallback((): Condition[] => { + const indexData = getDiscourseNodeSetting<{ + conditions: Condition[]; + }>(nodeType, ["index"]); + return indexData?.conditions?.length + ? indexData.conditions + : [defaultCondition]; + }, [nodeType, defaultCondition]); + + const onRefresh = useCallback(() => { + setLoading(true); + const conditions = getConditions(); + + fireQuery({ + conditions, + selections: [], + returnNode: DEFAULT_RETURN_NODE, + }) + .then((queryResults) => { + setResults(queryResults); + setColumns([{ key: "text", uid: "", selection: DEFAULT_RETURN_NODE }]); + }) + .catch((err) => { + console.error("Query failed:", err); + setResults([]); + }) + .finally(() => { + setLoading(false); + }); + }, [getConditions]); + + const handleQuery = useCallback(() => { + const conditions = getConditions(); + if (!conditions.length) { + setDiscourseNodeSetting(nodeType, ["index", "conditions"], [ + defaultCondition, + ]); + } + setIsEdit(false); + onRefresh(); + }, [getConditions, nodeType, defaultCondition, onRefresh]); + + useEffect(() => { + if (!isEdit) { + onRefresh(); + } + }, []); + + return ( + + {isEdit && ( +
+ +
+
+
+ )} + {loading ? ( +
+ Loading Results... +
+ ) : !isEdit ? ( + setIsEdit(true)} + preventSavingSettings={true} + /> + ) : null} +
+ ); +}; + +export default DiscourseNodeQueryBuilder; diff --git a/apps/roam/src/components/settings/components/DiscourseNodeQueryEditor.tsx b/apps/roam/src/components/settings/components/DiscourseNodeQueryEditor.tsx new file mode 100644 index 000000000..02b3249a0 --- /dev/null +++ b/apps/roam/src/components/settings/components/DiscourseNodeQueryEditor.tsx @@ -0,0 +1,591 @@ +import React, { useState, useCallback, useMemo, useRef } from "react"; +import { Button, H6, InputGroup, Tabs, Tab } from "@blueprintjs/core"; +import MenuItemSelect from "roamjs-components/components/MenuItemSelect"; +import AutocompleteInput from "roamjs-components/components/AutocompleteInput"; +import { + getConditionLabels, + isTargetVariable, + sourceToTargetOptions, + sourceToTargetPlaceholder, +} from "~/utils/conditionToDatalog"; +import type { + Condition, + QBClause, + QBNot, + QBOr, + QBNor, + QBClauseData, + QBNestedData, +} from "~/utils/types"; +import { + getDiscourseNodeSetting, + setDiscourseNodeSetting, +} from "../utils/accessors"; + +const DEFAULT_RETURN_NODE = "node"; + +const generateUID = (): string => + window.roamAlphaAPI?.util?.generateUID?.() ?? + Math.random().toString(36).substring(2, 11); + +const getSourceCandidates = (cs: Condition[]): string[] => + cs.flatMap((c) => + c.type === "clause" || c.type === "not" + ? isTargetVariable({ relation: c.relation }) + ? [c.target] + : [] + : getSourceCandidates(c.conditions.flat()), + ); + +type QueryClauseProps = { + con: QBClause | QBNot; + index: number; + setConditions: React.Dispatch>; + getAvailableVariables: (index: number) => string[]; + onSave: () => void; +}; + +const QueryClause = ({ + con, + index, + setConditions, + getAvailableVariables, + onSave, +}: QueryClauseProps) => { + const debounceRef = useRef(0); + const conditionLabels = useMemo(getConditionLabels, []); + const targetOptions = useMemo( + () => sourceToTargetOptions({ source: con.source, relation: con.relation }), + [con.source, con.relation], + ); + const targetPlaceholder = useMemo( + () => sourceToTargetPlaceholder({ relation: con.relation }), + [con.relation], + ); + + const setConditionRelation = useCallback( + (e: string, timeout: boolean = true) => { + window.clearTimeout(debounceRef.current); + setConditions((conditions) => + conditions.map((c) => (c.uid === con.uid ? { ...c, relation: e } : c)), + ); + debounceRef.current = window.setTimeout( + () => onSave(), + timeout ? 1000 : 0, + ); + }, + [setConditions, con.uid, onSave], + ); + + const setConditionTarget = useCallback( + (e: string, timeout: boolean = true) => { + window.clearTimeout(debounceRef.current); + setConditions((conditions) => + conditions.map((c) => (c.uid === con.uid ? { ...c, target: e } : c)), + ); + debounceRef.current = window.setTimeout( + () => onSave(), + timeout ? 1000 : 0, + ); + }, + [setConditions, con.uid, onSave], + ); + + const availableSources = useMemo( + () => getAvailableVariables(index), + [getAvailableVariables, index], + ); + + return ( + <> + { + setConditions((conditions) => + conditions.map((c) => + c.uid === con.uid ? { ...con, source: value } : c, + ), + ); + onSave(); + }} + /> +
+ setConditionRelation(e, false)} + options={conditionLabels} + placeholder={"Choose relationship"} + id={`${con.uid}-relation`} + /> +
+
+ setConditionTarget(e, false)} + options={targetOptions} + placeholder={targetPlaceholder} + id={`${con.uid}-target`} + /> +
+ + ); +}; + +type QueryNestedDataProps = { + con: QBOr | QBNor; + setView: (s: { uid: string; branch: number }) => void; +}; + +const QueryNestedData = ({ con, setView }: QueryNestedDataProps) => { + return ( + <> + +