Skip to content
28 changes: 9 additions & 19 deletions apps/roam/src/components/settings/DiscourseNodeConfigPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ import {
Tooltip,
} from "@blueprintjs/core";
import React, { useState } from "react";
import getDiscourseNodes from "~/utils/getDiscourseNodes";
import getDiscourseNodes, {
excludeDefaultNodes,
} from "~/utils/getDiscourseNodes";
import refreshConfigTree from "~/utils/refreshConfigTree";
import type { CustomField } from "roamjs-components/components/ConfigPanels/types";
import posthog from "posthog-js";
import getDiscourseRelations, {
type DiscourseRelation,
Expand All @@ -24,20 +25,12 @@ import {
} from "./utils/accessors";
import { GLOBAL_KEYS } from "./utils/settingKeys";
import { invalidateDiscourseNodeTypeCaches } from "~/utils/discourseNodeTypeCache";
import { useSettingsNav } from "./navigation/SettingsNavContext";

type DiscourseNodeConfigPanelProps = React.ComponentProps<
CustomField["options"]["component"]
> & {
isPopup?: boolean;
setSelectedTabId: (id: string) => void;
};

const DiscourseNodeConfigPanel: React.FC<DiscourseNodeConfigPanelProps> = ({
isPopup,
setSelectedTabId,
}) => {
const DiscourseNodeConfigPanel: React.FC = () => {
const { push } = useSettingsNav();
const [nodes, setNodes] = useState(() =>
getDiscourseNodes().filter((n) => n.backedBy === "user"),
getDiscourseNodes().filter(excludeDefaultNodes),
);
const [label, setLabel] = useState("");
const [isCreating, setIsCreating] = useState(false);
Expand All @@ -52,11 +45,8 @@ const DiscourseNodeConfigPanel: React.FC<DiscourseNodeConfigPanelProps> = ({
>([]);
const [nodeTypeIdToDelete, setNodeTypeIdToDelete] = useState<string>("");
const navigateToNode = (uid: string) => {
if (isPopup) {
setSelectedTabId(uid);
} else {
window.roamAlphaAPI.ui.mainWindow.openPage({ page: { uid } });
}
push(uid);
posthog.capture("Settings: Node Type Opened", { nodeTypeUid: uid });
};

const createNodeType = async (): Promise<void> => {
Expand Down
55 changes: 55 additions & 0 deletions apps/roam/src/components/settings/GrammarNodesRoute.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import React, { useEffect } from "react";
import { OnloadArgs } from "roamjs-components/types";
import getDiscourseNodes, {
excludeDefaultNodes,
} from "~/utils/getDiscourseNodes";
import { useSettingsNav } from "./navigation/SettingsNavContext";
import SettingsPageHeader from "./navigation/SettingsPageHeader";
import DiscourseNodeConfigPanel from "./DiscourseNodeConfigPanel";
import NodeConfig from "./NodeConfig";

const NODES_ANCESTOR_LABELS = ["Grammar"] as const;

const GrammarNodesRoute = ({
onloadArgs,
}: {
onloadArgs: OnloadArgs;
}): JSX.Element => {
const { segments, goToDepth } = useSettingsNav();
const nodes = getDiscourseNodes().filter(excludeDefaultNodes);

const [nodeTypeUid] = segments;
const node = nodeTypeUid
? nodes.find((n) => n.type === nodeTypeUid)
: undefined;

// A deleted node type or stale deep link resolves to nothing; return to the list.
const isStalePath = Boolean(nodeTypeUid) && !node;
useEffect(() => {
if (isStalePath) goToDepth(0);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The path outlives this panel — renderActiveTabPanelOnly unmounts and remounts it on every tab switch — so it can point at a node type deleted in the meantime, or arrive stale from a saved link. goToDepth(0) rather than pop() so a depth-2 stale path converges in one dispatch instead of two.

}, [isStalePath, goToDepth]);

const resolveLabel = (segment: string): string =>
nodes.find((n) => n.type === segment)?.text ?? segment;

return (
<div className="flex h-full flex-col">
<SettingsPageHeader
ancestorLabels={NODES_ANCESTOR_LABELS}
rootLabel="Nodes"
resolveLabel={resolveLabel}
/>
<div className="min-h-0 flex-auto overflow-y-auto">
{node ? (
<NodeConfig node={node} onloadArgs={onloadArgs} />
) : (
<div className="p-1">
<DiscourseNodeConfigPanel />
</div>
)}
</div>
</div>
);
};

export default GrammarNodesRoute;
73 changes: 35 additions & 38 deletions apps/roam/src/components/settings/Settings.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import React, { useEffect, useMemo, useState } from "react";
import React, {
useCallback,
useEffect,
useMemo,
useReducer,
useRef,
useState,
} from "react";
import { OnloadArgs } from "roamjs-components/types";
import {
Classes,
Expand All @@ -16,11 +23,6 @@ import discourseConfigRef from "~/utils/discourseConfigRef";
import DiscourseGraphExport from "./ExportSettings";
import QuerySettings from "./QuerySettings";
import AdminPanel from "./AdminPanel";
import DiscourseNodeConfigPanel from "./DiscourseNodeConfigPanel";
import getDiscourseNodes, {
excludeDefaultNodes,
} from "~/utils/getDiscourseNodes";
import NodeConfig from "./NodeConfig";
import PreferencesGeneral from "./PreferencesGeneral";
import PreferencesStyling from "./PreferencesStyling";
import LeftSidebarSettings from "./LeftSidebarSettings";
Expand All @@ -32,7 +34,14 @@ import { getVersionWithDate } from "~/utils/getVersion";
import posthog from "posthog-js";
import { bulkReadSettings } from "./utils/accessors";
import { onSettingChange, settingKeys } from "./utils/settingsEmitter";
import { SETTINGS_TAB_IDS, resolveSettingsTabId } from "./utils/settingsTabs";
import { SETTINGS_TAB_IDS } from "./utils/settingsTabs";
import {
resolveInitialSettingsPath,
settingsNavReducer,
tabIdOf,
} from "./utils/settingsNavigation";
import { SettingsNavProvider } from "./navigation/SettingsNavContext";
import GrammarNodesRoute from "./GrammarNodesRoute";

const SectionHeader = ({ children }: { children: React.ReactNode }) => (
<div className="bp3-tab-copy mt-4 cursor-default select-none text-lg font-semibold text-neutral-dark">
Expand Down Expand Up @@ -77,10 +86,15 @@ export const SettingsDialog = ({
const relationsNode = grammarNode?.children.find(
(node) => node.text === "relations",
);
const nodesNode = grammarNode?.children.find((node) => node.text === "nodes");
const nodes = getDiscourseNodes().filter(excludeDefaultNodes);
const [activeTabId, setActiveTabId] = useState<TabId>(() =>
resolveSettingsTabId(selectedTabId),
const [path, dispatch] = useReducer(
settingsNavReducer,
selectedTabId,
resolveInitialSettingsPath,
);
const activeTabId = tabIdOf(path);
const selectTab = useCallback(
(tabId: string) => dispatch({ type: "select-tab", tabId }),
[],
);
// eslint-disable-next-line react-hooks/exhaustive-deps
const settings = useMemo(() => bulkReadSettings(), [activeTabId]);
Expand All @@ -103,30 +117,29 @@ export const SettingsDialog = ({
const { versionStamp } = getVersionWithDate();
const openAdminPanel = (): void => {
setShowAdminPanel(true);
setActiveTabId(SETTINGS_TAB_IDS.admin);
selectTab(SETTINGS_TAB_IDS.admin);
posthog.capture("Settings: Admin Panel Opened from Footer");
};

const initialTabId = useRef(activeTabId).current;
useEffect(() => {
posthog.capture("Settings: Dialog Opened", {
initialTabId: String(resolveSettingsTabId(selectedTabId)),
});
}, [selectedTabId]);
posthog.capture("Settings: Dialog Opened", { initialTabId });
}, [initialTabId]);

useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
if (e.ctrlKey && e.shiftKey && e.key === "A") {
e.stopPropagation();
e.preventDefault();
setShowAdminPanel(true);
setActiveTabId(SETTINGS_TAB_IDS.admin);
selectTab(SETTINGS_TAB_IDS.admin);
posthog.capture("Settings: Admin Panel Opened via Shortcut");
}
};

window.addEventListener("keydown", handleKeyPress);
return () => window.removeEventListener("keydown", handleKeyPress);
}, []);
}, [selectTab]);
return (
<Dialog
isOpen={isOpen}
Expand Down Expand Up @@ -169,7 +182,7 @@ export const SettingsDialog = ({
<Tabs
className="dg-settings-tabs flex h-full"
onChange={(id) => {
setActiveTabId(id);
selectTab(String(id));
posthog.capture("Settings: Tab Opened", {
tabId: String(id),
});
Expand Down Expand Up @@ -243,16 +256,10 @@ export const SettingsDialog = ({
<Tab
id={SETTINGS_TAB_IDS.grammarNodes}
title="Nodes"
className="overflow-y-auto"
panel={
<DiscourseNodeConfigPanel
title="Nodes"
uid={nodesNode?.uid || ""}
parentUid={grammarNode?.uid || ""}
defaultValue={[]}
setSelectedTabId={setActiveTabId}
isPopup={true}
/>
<SettingsNavProvider path={path} dispatch={dispatch}>
<GrammarNodesRoute onloadArgs={onloadArgs} />
</SettingsNavProvider>
}
/>
<Tab
Expand All @@ -268,16 +275,6 @@ export const SettingsDialog = ({
/>
}
/>
<SectionHeader>Node types</SectionHeader>
{nodes.map((n) => (
<Tab
key={n.type}
id={n.type}
title={n.text}
className="overflow-y-auto"
panel={<NodeConfig node={n} onloadArgs={onloadArgs} />}
/>
))}
<SectionHeader>Advanced</SectionHeader>
<Tab
id={SETTINGS_TAB_IDS.advancedQueries}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import type { DiscourseNodeBaseProps } from "./BlockPropSettingPanels";
const DEBOUNCE_MS = 250;
const TEMPLATE_BUFFER_TEXT = "Template";

type DualWriteBlocksPanelProps = DiscourseNodeBaseProps & {
type DualWriteBlocksPanelProps = Omit<DiscourseNodeBaseProps, "title"> & {
title?: string;
uid: string;
defaultValue?: InputTextNode[];
};
Expand Down Expand Up @@ -191,10 +192,12 @@ const DualWriteBlocksPanel = ({

return (
<>
<Label>
{title}
<Description description={description} />
</Label>
{title ? (
<Label>
{title}
<Description description={description} />
</Label>
) : null}
<style>{`.dg-dualwrite-blocks > div > .rm-block-main {
display: none;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React, { createContext, useContext, useMemo } from "react";
import {
depthOf,
segmentsOf,
type SettingsNavAction,
type SettingsPath,
} from "~/components/settings/utils/settingsNavigation";

export type SettingsNavValue = {
path: SettingsPath;
depth: number;
segments: readonly string[];
push: (segment: string) => void;
pop: () => void;
goToDepth: (depth: number) => void;
};

const SettingsNavContext = createContext<SettingsNavValue | null>(null);

export const SettingsNavProvider = ({
path,
dispatch,
children,
}: {
path: SettingsPath;
dispatch: React.Dispatch<SettingsNavAction>;
children: React.ReactNode;
}): JSX.Element => {
const value = useMemo<SettingsNavValue>(
() => ({
path,
depth: depthOf(path),
segments: segmentsOf(path),
push: (segment) => dispatch({ type: "push", segment }),
pop: () => dispatch({ type: "pop" }),
goToDepth: (depth) => dispatch({ type: "truncate", depth }),
}),
[path, dispatch],
);
return (
<SettingsNavContext.Provider value={value}>
{children}
</SettingsNavContext.Provider>
);
};

export const useSettingsNav = (): SettingsNavValue => {
const context = useContext(SettingsNavContext);
if (!context) {
throw new Error("useSettingsNav must be used within a SettingsNavProvider");
}
return context;
};
Loading
Loading