From 242287efc8df7720fe95bbee53246e3c51f98fce Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 31 Aug 2026 17:01:41 -0400 Subject: [PATCH 1/6] ENG-2213 Settings catalog: address every setting by key and location MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records what every setting row is called and where it lives, so a later change can find a setting without knowing which tab it is on. Nothing in the codebase held this. `zodSchema.ts` is storage shape — no labels, no descriptions, no locations — and `settingKeys.ts` holds block keys that diverge from what a row renders: `GLOBAL_KEYS.trigger` renders as "Graph-wide default", `PERSONAL_KEYS.personalNodeMenuTrigger` as "Personal override". Nor can it be collected at runtime: `Tabs` uses `renderActiveTabPanelOnly`, so inactive panels are out of the DOM and rows cannot self-register. So the label, group, description, doc link and location are authored, one entry per row. Everything else is derived rather than restated. Entries are keyed by `GROUP.member`, addresses are built by resolving that id against the `settingKeys.ts` constants, and the catalog is declared `satisfies Record`. A key added to `settingKeys.ts` is then a compile error until it is either given an entry or listed in `NonRowKeyId` with a reason, so a new setting cannot silently go missing. The remaining gap is the reverse direction: nothing checks that a row rendering `settingKeys` was classified as a row rather than exempted. An entry's `path` is the only field a relocation has to touch. Node-type settings give `path` as a function of the node uid, so one entry expands to one result per node type and adding a node type never edits this file. Six rows rendered without an anchor, so they had no stable address; each now passes the key constants already in scope at its call site. `SETTINGS_TAB_META` becomes the single source for tab labels, because a renamed tab would otherwise drift away from the locations recorded here. `SettingItemRow` falls back to the catalog for a description when the prop is omitted, so ENG-2187 can migrate call sites by deleting props rather than by copying text. Co-Authored-By: Claude Opus 5 --- .../src/components/settings/NodeConfig.tsx | 5 + .../components/settings/PageGroupPanel.tsx | 4 + .../settings/PreferencesGeneral.tsx | 2 +- .../roam/src/components/settings/Settings.tsx | 22 +- .../components/EphemeralBlocksPanel.tsx | 1 + .../settings/components/SettingItemRow.tsx | 19 +- .../settings/utils/settingsCatalog.ts | 569 ++++++++++++++++++ .../components/settings/utils/settingsTabs.ts | 55 ++ 8 files changed, 665 insertions(+), 12 deletions(-) create mode 100644 apps/roam/src/components/settings/utils/settingsCatalog.ts diff --git a/apps/roam/src/components/settings/NodeConfig.tsx b/apps/roam/src/components/settings/NodeConfig.tsx index 7d013ef94..0798ecaf7 100644 --- a/apps/roam/src/components/settings/NodeConfig.tsx +++ b/apps/roam/src/components/settings/NodeConfig.tsx @@ -17,6 +17,7 @@ import { CANVAS_KEYS, DISCOURSE_NODE_KEYS, SPECIFICATION_KEYS, + TEMPLATE_SETTING_KEYS, } from "~/components/settings/utils/settingKeys"; import DiscourseNodeSuggestiveRules from "./DiscourseNodeSuggestiveRules"; import { getNodeTagStyles } from "~/utils/getDiscourseNodeColors"; @@ -114,6 +115,7 @@ const DiscourseNodeColorSetting = ({ label="Color" description="Changes the color of tags and canvas nodes" scope="global" + settingKeys={[DISCOURSE_NODE_KEYS.canvasSettings, CANVAS_KEYS.color]} control={ { ROAM_DOCS.grammarNodes, )} scope="global" + settingKeys={[DISCOURSE_NODE_KEYS.specification]} controlPlacement="below" control={ { label="Index" description={`The saved list of all ${node.text} pages \u2014 which pages appear and which columns show.`} scope="global" + settingKeys={[DISCOURSE_NODE_KEYS.index]} control={ { ROAM_DOCS.creatingNodes, )} scope="global" + settingKeys={TEMPLATE_SETTING_KEYS} control={ diff --git a/apps/roam/src/components/settings/PreferencesGeneral.tsx b/apps/roam/src/components/settings/PreferencesGeneral.tsx index 8af6537ec..869ccbd4c 100644 --- a/apps/roam/src/components/settings/PreferencesGeneral.tsx +++ b/apps/roam/src/components/settings/PreferencesGeneral.tsx @@ -150,7 +150,7 @@ const PreferencesGeneral = ({ "Use stored relations instead of legacy pattern-based relations", ROAM_DOCS.migrationToStoredRelations, )} - settingKeys={["Reified relation triples"]} + settingKeys={[PERSONAL_KEYS.reifiedRelationTriples]} initialValue={personalSettings["Reified relation triples"]} value={storedRelations} onBeforeChange={async (checked) => { diff --git a/apps/roam/src/components/settings/Settings.tsx b/apps/roam/src/components/settings/Settings.tsx index 138c3bcfa..5ec8a023d 100644 --- a/apps/roam/src/components/settings/Settings.tsx +++ b/apps/roam/src/components/settings/Settings.tsx @@ -33,7 +33,7 @@ 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 } from "./utils/settingsTabs"; +import { SETTINGS_TAB_IDS, SETTINGS_TAB_META } from "./utils/settingsTabs"; import { resolveInitialSettingsPath, settingsNavReducer, @@ -188,7 +188,7 @@ export const SettingsDialog = ({ Preferences Features Grammar @@ -258,7 +262,7 @@ export const SettingsDialog = ({ /> Advanced diff --git a/apps/roam/src/components/settings/components/SettingItemRow.tsx b/apps/roam/src/components/settings/components/SettingItemRow.tsx index 11fcc5fcf..eb4571b1b 100644 --- a/apps/roam/src/components/settings/components/SettingItemRow.tsx +++ b/apps/roam/src/components/settings/components/SettingItemRow.tsx @@ -1,6 +1,8 @@ import React, { useId } from "react"; import { Icon, type IconName, Position, Tooltip } from "@blueprintjs/core"; import { settingAnchor } from "~/components/settings/utils/settingAnchor"; +import { describedSetting } from "~/components/settings/utils/settingsCatalog"; +import { withDocsLink } from "~/components/settings/utils/docs"; /** Per-node settings are `global`: they live on the node type's page, so the whole graph sees them. */ export type SettingScope = "personal" | "global"; @@ -58,6 +60,17 @@ const SettingItemRow = ({ }: SettingItemRowProps): React.ReactElement => { const controlId = useId(); const isAssociated = typeof control === "function"; + // The catalog fallback lets a call site drop the prop, so the row and search + // read one string rather than two that can drift. + const authored = + description === undefined ? describedSetting(settingKeys) : undefined; + const resolvedDescription = + description ?? + (authored + ? authored.docsLink + ? withDocsLink(authored.description, authored.docsLink) + : authored.description + : undefined); // Description is a sibling of the label: nested, its doc links would toggle the control (ENG-2080). const LabelTag = isAssociated ? "label" : "div"; @@ -80,8 +93,10 @@ const SettingItemRow = ({ {scope && !compact ? : null} {label} - {description ? ( -
{description}
+ {resolvedDescription ? ( +
+ {resolvedDescription} +
) : null} {error ? (
{error}
diff --git a/apps/roam/src/components/settings/utils/settingsCatalog.ts b/apps/roam/src/components/settings/utils/settingsCatalog.ts new file mode 100644 index 000000000..4d868e99c --- /dev/null +++ b/apps/roam/src/components/settings/utils/settingsCatalog.ts @@ -0,0 +1,569 @@ +import getDiscourseNodes, { + excludeDefaultNodes, +} from "~/utils/getDiscourseNodes"; +import { isSyncEnabled } from "./accessors"; +import { rootPath, type SettingsPath } from "./settingsNavigation"; +import { SETTINGS_TAB_IDS, SETTINGS_TAB_META } from "./settingsTabs"; +import { + CANVAS_KEYS, + DISCOURSE_NODE_KEYS, + EXPORT_KEYS, + FEATURE_FLAG_KEYS, + GLOBAL_KEYS, + LEFT_SIDEBAR_KEYS, + PERSONAL_KEYS, + QUERY_KEYS, + SPECIFICATION_KEYS, + SUGGESTIVE_MODE_KEYS, + SUGGESTIVE_RULES_KEYS, +} from "./settingKeys"; +import { ROAM_DOCS } from "./docs"; + +/** Every settings key, addressed as `GROUP.member`, so the key list is never restated. */ +const SETTING_KEY_GROUPS = { + CANVAS_KEYS, + DISCOURSE_NODE_KEYS, + EXPORT_KEYS, + FEATURE_FLAG_KEYS, + GLOBAL_KEYS, + LEFT_SIDEBAR_KEYS, + PERSONAL_KEYS, + QUERY_KEYS, + SPECIFICATION_KEYS, + SUGGESTIVE_MODE_KEYS, + SUGGESTIVE_RULES_KEYS, +} as const; + +type SettingKeyGroups = typeof SETTING_KEY_GROUPS; + +export type SettingKeyId = { + [G in keyof SettingKeyGroups]: `${G & string}.${keyof SettingKeyGroups[G] & string}`; +}[keyof SettingKeyGroups]; + +const resolveKeyId = (id: SettingKeyId): string => { + const [groupName, member] = id.split(".") as [keyof SettingKeyGroups, string]; + return (SETTING_KEY_GROUPS[groupName] as Record)[member]; +}; + +/** Keys that are not a searchable row. A key must land here or in AUTHORED_SETTINGS, or it fails to compile. */ +type NonRowKeyId = + // Reachable only from the hidden Admin panel. + | "FEATURE_FLAG_KEYS.enableNodeSharing" + | "FEATURE_FLAG_KEYS.suggestiveModeOverlayEnabled" + | "FEATURE_FLAG_KEYS.useNewSettingsStore" + | "SUGGESTIVE_MODE_KEYS.includeCurrentPageRelations" + | "SUGGESTIVE_MODE_KEYS.includeParentAndChildBlocks" + | "SUGGESTIVE_MODE_KEYS.pageGroups" + // Containers whose children are the rows. + | "GLOBAL_KEYS.export" + | "GLOBAL_KEYS.suggestiveMode" + | "GLOBAL_KEYS.leftSidebar" + | "PERSONAL_KEYS.leftSidebar" + | "PERSONAL_KEYS.query" + | "DISCOURSE_NODE_KEYS.canvasSettings" + | "DISCOURSE_NODE_KEYS.suggestiveRules" + // Relations are configured by their own panel, not a settings row. + | "GLOBAL_KEYS.relations" + // Persisted UI state and canvas-written values, not settings. + | "PERSONAL_KEYS.globalSectionFolded" + | "PERSONAL_KEYS.canvasNodeShortcuts" + // Per-section rows rendered from a list; no stable address yet. + | "LEFT_SIDEBAR_KEYS.children" + // Edited as a set by DiscourseNodeAttributes. + | "DISCOURSE_NODE_KEYS.attributes" + // Sub-controls of a row that is itself addressable. + | "CANVAS_KEYS.keyImageOption" + | "CANVAS_KEYS.queryBuilderAlias" + | "SPECIFICATION_KEYS.enabled" + | "SPECIFICATION_KEYS.query"; + +/** Compile-time proof that every exempted id is a real key. */ +type AssertNonRowKeysExist = NonRowKeyId extends SettingKeyId ? true : never; +const _nonRowKeysAreReal: AssertNonRowKeysExist = true; +void _nonRowKeysAreReal; + +type RowKeyId = Exclude; + +/** What only the call site knows. `path` is the only field a relocation touches, and is a + * function for node-type rows so one entry expands per node type. */ +export type AuthoredSetting = { + /** Set when the value is nested, e.g. Export rows under `GLOBAL_KEYS.export`. */ + parent?: SettingKeyId; + label: string; + /** The enclosing `SettingsGroup` title, when the row sits in one. */ + group?: string; + description?: string; + docsLink?: string; + /** Synonyms and pre-ENG-2189 section names, so muscle memory still resolves. */ + keywords?: readonly string[]; + /** + * Omitted for settings that live outside the Settings dialog — the Export + * options moved into the Export dialog in ENG-2185. Still authored here so a + * row's description and docs link have one source, but not offered by a search + * that can only navigate Settings. + */ + path?: SettingsPath | ((nodeTypeUid: string) => SettingsPath); + /** Settings a graph cannot reach are dropped rather than offered as dead ends. */ + isAvailable?: () => boolean; +}; + +/** An authored entry with its address built and its route resolved. */ +export type SearchableSetting = { + kind: "setting"; + /** Unique per result; the anchor value plus the node uid for per-node rows. */ + id: string; + anchorId: string; + label: string; + description?: string; + keywords: readonly string[]; + path: SettingsPath; + breadcrumb: string; +}; + +export type SearchablePage = { + kind: "page"; + id: string; + label: string; + keywords: readonly string[]; + path: SettingsPath; + breadcrumb: string; +}; + +export type SearchableEntry = SearchableSetting | SearchablePage; + +const nodePath = + (...subPages: string[]) => + (nodeTypeUid: string): SettingsPath => [ + SETTINGS_TAB_IDS.grammarNodes, + nodeTypeUid, + ...subPages, + ]; + +/** Labels mirror the rendered text, not the key (`GLOBAL_KEYS.trigger` renders as + * "Graph-wide default"), because users search for what they saw. */ +const AUTHORED_SETTINGS = { + "GLOBAL_KEYS.trigger": { + label: "Graph-wide default", + group: "Node trigger", + description: "The trigger to create the node menu.", + docsLink: ROAM_DOCS.creatingNodes, + keywords: ["trigger", "node menu", "global"], + path: rootPath(SETTINGS_TAB_IDS.preferencesGeneral), + }, + "PERSONAL_KEYS.personalNodeMenuTrigger": { + label: "Personal override", + group: "Node trigger", + description: "Override the global trigger for the discourse node menu.", + docsLink: ROAM_DOCS.creatingNodes, + keywords: ["trigger", "node menu", "personal"], + path: rootPath(SETTINGS_TAB_IDS.preferencesGeneral), + }, + "PERSONAL_KEYS.nodeSearchMenuTrigger": { + label: "Node search menu trigger", + description: "Set the trigger character for the node search menu.", + keywords: ["personal"], + path: rootPath(SETTINGS_TAB_IDS.preferencesGeneral), + }, + "PERSONAL_KEYS.textSelectionPopup": { + label: "Text selection popup", + description: + "Whether or not to show the discourse node menu when selecting text.", + docsLink: ROAM_DOCS.creatingNodes, + keywords: ["personal", "highlight"], + path: rootPath(SETTINGS_TAB_IDS.preferencesGeneral), + }, + "PERSONAL_KEYS.disableSidebarOpen": { + label: "Disable sidebar open", + description: "Disable opening new nodes in the sidebar when created", + keywords: ["personal", "right sidebar"], + path: rootPath(SETTINGS_TAB_IDS.preferencesGeneral), + }, + "PERSONAL_KEYS.reifiedRelationTriples": { + label: "Enable stored relations", + description: + "Use stored relations instead of legacy pattern-based relations", + docsLink: ROAM_DOCS.migrationToStoredRelations, + keywords: ["personal", "reified", "triples"], + path: rootPath(SETTINGS_TAB_IDS.preferencesGeneral), + }, + "PERSONAL_KEYS.disableProductDiagnostics": { + label: "Disable product diagnostics", + description: + "Disable sending usage signals and error reports that help us improve the product.", + keywords: ["personal", "telemetry", "analytics", "privacy"], + path: rootPath(SETTINGS_TAB_IDS.preferencesGeneral), + }, + "PERSONAL_KEYS.streamlineStyling": { + label: "Streamline styling", + description: + "Apply streamlined styling to your personal graph for a cleaner appearance.", + keywords: ["personal", "theme", "appearance"], + path: rootPath(SETTINGS_TAB_IDS.preferencesStyling), + }, + "PERSONAL_KEYS.hideFeedbackButton": { + label: "Hide feedback button", + description: + "Hide the 'Send feedback' button at the bottom right of the screen.", + keywords: ["personal", "appearance"], + path: rootPath(SETTINGS_TAB_IDS.preferencesStyling), + }, + "PERSONAL_KEYS.discourseContextOverlay": { + label: "Overlay", + description: + "Whether or not to overlay discourse context information over discourse node references.", + docsLink: ROAM_DOCS.discourseContextOverlay, + keywords: ["personal", "discourse context"], + path: rootPath(SETTINGS_TAB_IDS.featuresDiscourseContext), + }, + "PERSONAL_KEYS.overlayInCanvas": { + label: "(BETA) Overlay in canvas", + description: + "Whether or not to overlay discourse context information over canvas nodes.", + docsLink: ROAM_DOCS.discourseContextOverlay, + keywords: ["personal", "discourse context", "canvas"], + path: rootPath(SETTINGS_TAB_IDS.featuresDiscourseContext), + }, + "GLOBAL_KEYS.canvasPageFormat": { + label: "Canvas Page Format", + description: "The page format for canvas pages", + keywords: ["global", "tldraw"], + path: rootPath(SETTINGS_TAB_IDS.featuresCanvas), + }, + "PERSONAL_KEYS.discourseToolShortcut": { + label: "Discourse tool keyboard shortcut", + description: + "Set a single key to activate the discourse tool in tldraw. Only single keys (no modifiers) are supported. Leave empty for no shortcut.", + docsLink: ROAM_DOCS.creatingNodes, + keywords: ["personal", "tldraw", "hotkey"], + path: rootPath(SETTINGS_TAB_IDS.featuresCanvas), + }, + "PERSONAL_KEYS.autoCanvasRelations": { + label: "Auto canvas relations", + description: + "Automatically add discourse relations to canvas when a node is added", + docsLink: ROAM_DOCS.storedRelations, + keywords: ["personal", "tldraw"], + path: rootPath(SETTINGS_TAB_IDS.featuresCanvas), + }, + "FEATURE_FLAG_KEYS.enableLeftSidebar": { + label: "Enable left sidebar", + description: "Whether or not to enable the left sidebar.", + keywords: ["global", "shortcuts"], + path: rootPath(SETTINGS_TAB_IDS.featuresLeftSidebar), + }, + "QUERY_KEYS.hideQueryMetadata": { + parent: "PERSONAL_KEYS.query", + label: "Hide query metadata", + description: "Hide the Roam blocks that are used to power each query", + docsLink: ROAM_DOCS.querying, + keywords: ["personal", "query builder"], + path: rootPath(SETTINGS_TAB_IDS.advancedQueries), + }, + "QUERY_KEYS.defaultPageSize": { + parent: "PERSONAL_KEYS.query", + label: "Default page size", + description: "The default page size used for query results", + docsLink: ROAM_DOCS.querying, + keywords: ["personal", "query builder", "pagination"], + path: rootPath(SETTINGS_TAB_IDS.advancedQueries), + }, + "QUERY_KEYS.queryPages": { + parent: "PERSONAL_KEYS.query", + label: "Query pages", + description: + "The title formats of pages that you would like to serve as pages that generate queries", + docsLink: ROAM_DOCS.querying, + keywords: ["personal", "query builder"], + path: rootPath(SETTINGS_TAB_IDS.advancedQueries), + }, + "QUERY_KEYS.defaultFilters": { + parent: "PERSONAL_KEYS.query", + label: "Default filters", + description: + "Any filters that should be applied to your results by default", + docsLink: ROAM_DOCS.querying, + keywords: ["personal", "query builder"], + path: rootPath(SETTINGS_TAB_IDS.advancedQueries), + }, + // No `path`: ENG-2185 moved these seven out of Settings and into the Export + // dialog's Export options, so settings search cannot navigate to them. + "EXPORT_KEYS.removeSpecialCharacters": { + parent: "GLOBAL_KEYS.export", + label: "remove special characters", + description: + "Whether or not to remove the special characters in a file name.", + docsLink: ROAM_DOCS.sharing, + keywords: ["global", "markdown", "filename"], + }, + "EXPORT_KEYS.resolveBlockReferences": { + parent: "GLOBAL_KEYS.export", + label: "resolve block references", + description: + "Replaces block references in the markdown content with the block's content.", + docsLink: ROAM_DOCS.sharing, + keywords: ["global", "markdown"], + }, + "EXPORT_KEYS.resolveBlockEmbeds": { + parent: "GLOBAL_KEYS.export", + label: "resolve block embeds", + description: + "Replaces block embeds in the markdown content with the block's content tree.", + docsLink: ROAM_DOCS.sharing, + keywords: ["global", "markdown"], + }, + "EXPORT_KEYS.appendReferencedNode": { + parent: "GLOBAL_KEYS.export", + label: "append referenced node", + description: + "If a referenced node is defined in a node's format, it will be appended to the discourse context.", + docsLink: ROAM_DOCS.sharing, + keywords: ["global", "markdown"], + }, + "EXPORT_KEYS.linkType": { + parent: "GLOBAL_KEYS.export", + label: "link type", + description: "How to format links that appear in your export.", + docsLink: ROAM_DOCS.sharing, + keywords: ["global", "markdown", "wikilinks", "alias"], + }, + "EXPORT_KEYS.maxFilenameLength": { + parent: "GLOBAL_KEYS.export", + label: "max filename length", + description: "Set the maximum name length for markdown file exports.", + docsLink: ROAM_DOCS.sharing, + keywords: ["global", "markdown", "filename"], + }, + "EXPORT_KEYS.frontmatter": { + parent: "GLOBAL_KEYS.export", + label: "frontmatter", + description: + "Specify all the lines that should go to the Frontmatter of the markdown file.", + docsLink: ROAM_DOCS.sharing, + keywords: ["global", "markdown", "yaml"], + }, + "CANVAS_KEYS.color": { + parent: "DISCOURSE_NODE_KEYS.canvasSettings", + label: "Color", + description: "Changes the color of tags and canvas nodes", + keywords: ["node type", "canvas", "tag"], + path: nodePath(), + }, + "DISCOURSE_NODE_KEYS.description": { + label: "Description", + group: "Identity", + description: "Describing what the node represents in your graph.", + docsLink: ROAM_DOCS.grammarNodes, + keywords: ["node type"], + path: nodePath(), + }, + "DISCOURSE_NODE_KEYS.tag": { + label: "Tag", + group: "Identity", + description: "Designate a hashtag for marking potential nodes.", + docsLink: ROAM_DOCS.taggingCandidateNodes, + keywords: ["node type", "hashtag"], + path: nodePath(), + }, + "DISCOURSE_NODE_KEYS.format": { + label: "Format", + group: "Recognition", + description: + "DEPRECATED - Use specification instead. The format pages should have.", + docsLink: ROAM_DOCS.grammarNodes, + keywords: ["node type", "deprecated"], + path: nodePath(), + }, + "DISCOURSE_NODE_KEYS.specification": { + label: "Specification", + group: "Recognition", + description: "The conditions specified to identify a node.", + docsLink: ROAM_DOCS.grammarNodes, + keywords: ["node type", "query", "conditions"], + path: nodePath(), + }, + "DISCOURSE_NODE_KEYS.index": { + label: "Index", + group: "Recognition", + description: + "The saved list of all pages — which pages appear and which columns show.", + keywords: ["node type", "table", "columns"], + path: nodePath(), + }, + "DISCOURSE_NODE_KEYS.shortcut": { + label: "Shortcut", + group: "Creation", + description: "The trigger to quickly create a page from the node menu.", + docsLink: ROAM_DOCS.creatingNodes, + keywords: ["node type", "hotkey", "trigger"], + path: nodePath(), + }, + "DISCOURSE_NODE_KEYS.template": { + // The row itself lives on the node page; its editor is the template sub-page. + label: "Template", + group: "Creation", + description: "The template that auto fills a page when generated.", + docsLink: ROAM_DOCS.creatingNodes, + keywords: ["node type"], + path: nodePath(), + }, + "DISCOURSE_NODE_KEYS.overlay": { + label: "Overlay", + group: "Attributes", + description: "Select which attribute is used for the discourse overlay", + docsLink: ROAM_DOCS.discourseAttributes, + keywords: ["node type", "attributes"], + path: nodePath(), + }, + "CANVAS_KEYS.alias": { + parent: "DISCOURSE_NODE_KEYS.canvasSettings", + label: "Display alias", + group: "Canvas", + keywords: ["node type", "tldraw"], + path: nodePath(), + }, + "CANVAS_KEYS.keyImage": { + parent: "DISCOURSE_NODE_KEYS.canvasSettings", + label: "Key image", + group: "Canvas", + description: "Add an image to the discourse node", + keywords: ["node type", "tldraw"], + path: nodePath(), + }, + "DISCOURSE_NODE_KEYS.graphOverview": { + label: "Graph Overview", + group: "Canvas", + description: + "Whether to color the node in the graph overview based on canvas color.", + keywords: ["node type", "color"], + path: nodePath(), + }, + "SUGGESTIVE_RULES_KEYS.embeddingRef": { + parent: "DISCOURSE_NODE_KEYS.suggestiveRules", + label: "Embedding Block Ref", + group: "Suggestive mode", + description: + "Copy block ref from template which you want to be embedded and ranked.", + keywords: ["node type", "suggestive"], + path: nodePath(), + isAvailable: isSyncEnabled, + }, + "SUGGESTIVE_RULES_KEYS.isFirstChild": { + parent: "DISCOURSE_NODE_KEYS.suggestiveRules", + label: "First Child", + group: "Suggestive mode", + description: + "If the block is the first child of the embedding block ref, it will be embedded and ranked.", + keywords: ["node type", "suggestive"], + path: nodePath(), + isAvailable: isSyncEnabled, + }, +} satisfies Record; + +const settingKeysOf = ( + id: RowKeyId, + setting: AuthoredSetting, +): readonly string[] => + setting.parent + ? [resolveKeyId(setting.parent), resolveKeyId(id)] + : [resolveKeyId(id)]; + +const isNodeTypePath = ( + path: AuthoredSetting["path"], +): path is (nodeTypeUid: string) => SettingsPath => typeof path === "function"; + +const breadcrumbOf = (path: SettingsPath, trailing: string[]): string => { + const meta = SETTINGS_TAB_META[path[0] as keyof typeof SETTINGS_TAB_META]; + const head = meta ? [meta.section, meta.label] : [String(path[0])]; + return [...head, ...trailing].filter(Boolean).join(" \u203a "); +}; + +const toSearchable = ({ + setting, + anchorId, + path, + idSuffix, + trailing, +}: { + setting: AuthoredSetting; + anchorId: string; + path: SettingsPath; + idSuffix?: string; + trailing: string[]; +}): SearchableSetting => ({ + kind: "setting", + id: idSuffix ? `${anchorId}@${idSuffix}` : anchorId, + anchorId, + label: setting.label, + description: setting.description, + keywords: setting.keywords ?? [], + path, + breadcrumb: breadcrumbOf(path, trailing), +}); + +const buildPages = (): SearchablePage[] => + Object.entries(SETTINGS_TAB_META) + .filter(([, meta]) => meta.searchable) + .map(([tabId, meta]) => ({ + kind: "page" as const, + id: `page:${tabId}`, + label: meta.label, + keywords: [meta.section], + path: rootPath(tabId), + breadcrumb: meta.section, + })); + +/** Rebuilt per query, not memoised: node types and feature gates change while Settings is + * open, and `getDiscourseNodes` is cache-backed. */ +export const buildSettingsCatalog = (): SearchableEntry[] => { + const available = ( + Object.entries(AUTHORED_SETTINGS) as [RowKeyId, AuthoredSetting][] + ).filter( + // A path-less entry is authored only for its description; it is not somewhere + // this search can navigate to. + ([, setting]) => + setting.path !== undefined && (setting.isAvailable?.() ?? true), + ); + const nodeTypes = available.some(([, setting]) => + isNodeTypePath(setting.path), + ) + ? getDiscourseNodes().filter(excludeDefaultNodes) + : []; + + const settings = available.flatMap(([id, setting]): SearchableSetting[] => { + const anchorId = settingKeysOf(id, setting).join("/"); + const groupTrail = setting.group ? [setting.group] : []; + const { path } = setting; + if (path === undefined) return []; + if (!isNodeTypePath(path)) { + return [toSearchable({ setting, anchorId, path, trailing: groupTrail })]; + } + return nodeTypes.map((node) => + toSearchable({ + setting, + anchorId, + path: path(node.type), + idSuffix: node.type, + trailing: [node.text, ...groupTrail], + }), + ); + }); + + return [...settings, ...buildPages()]; +}; + +const BY_ADDRESS = new Map( + (Object.entries(AUTHORED_SETTINGS) as [RowKeyId, AuthoredSetting][]).map( + ([id, setting]) => [settingKeysOf(id, setting).join("/"), setting], + ), +); + +/** Lets a row omit the description prop and read it from here, so the two cannot drift + * apart. ENG-2187 migrates the remaining call sites. */ +export const describedSetting = ( + settingKeys: readonly string[] | undefined, +): { description?: string; docsLink?: string } | undefined => { + if (!settingKeys?.length) return undefined; + const setting = BY_ADDRESS.get(settingKeys.join("/")); + if (!setting?.description) return undefined; + return { description: setting.description, docsLink: setting.docsLink }; +}; diff --git a/apps/roam/src/components/settings/utils/settingsTabs.ts b/apps/roam/src/components/settings/utils/settingsTabs.ts index 0c0e8e163..8f1216311 100644 --- a/apps/roam/src/components/settings/utils/settingsTabs.ts +++ b/apps/roam/src/components/settings/utils/settingsTabs.ts @@ -15,6 +15,61 @@ export const SETTINGS_TAB_IDS = { export const DEFAULT_SETTINGS_TAB_ID: TabId = SETTINGS_TAB_IDS.preferencesGeneral; +/** One source for tab labels, so a rename cannot drift from recorded locations. + * `searchable: false` keeps the hidden Admin tab out of the index. */ +export const SETTINGS_TAB_META = { + [SETTINGS_TAB_IDS.preferencesGeneral]: { + section: "Preferences", + label: "General", + searchable: true, + }, + [SETTINGS_TAB_IDS.preferencesStyling]: { + section: "Preferences", + label: "Styling", + searchable: true, + }, + [SETTINGS_TAB_IDS.featuresDiscourseContext]: { + section: "Features", + label: "Discourse context", + searchable: true, + }, + [SETTINGS_TAB_IDS.featuresCanvas]: { + section: "Features", + label: "Canvas", + searchable: true, + }, + [SETTINGS_TAB_IDS.featuresLeftSidebar]: { + section: "Features", + label: "Left sidebar", + searchable: true, + }, + [SETTINGS_TAB_IDS.grammarNodes]: { + section: "Grammar", + label: "Nodes", + searchable: true, + }, + [SETTINGS_TAB_IDS.grammarRelations]: { + section: "Grammar", + label: "Relations", + searchable: true, + }, + [SETTINGS_TAB_IDS.advancedQueries]: { + section: "Advanced", + label: "Queries", + searchable: true, + }, + [SETTINGS_TAB_IDS.admin]: { + section: "Admin", + label: "Admin", + searchable: false, + }, +} as const satisfies Record< + (typeof SETTINGS_TAB_IDS)[keyof typeof SETTINGS_TAB_IDS], + { section: string; label: string; searchable: boolean } +>; + +export type SettingsTabId = keyof typeof SETTINGS_TAB_META; + /** Tab ids from before the taxonomy. Saved deep links still carry these. */ export const SETTINGS_TAB_ALIASES: Record = { "discourse-graph-home-personal": SETTINGS_TAB_IDS.preferencesGeneral, From 4c52d0a93f6d8da5de289e70ff0917ee1ecbef89 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 31 Aug 2026 23:47:55 -0400 Subject: [PATCH 2/6] ENG-2213 Make the canvas shortcut overrides searchable The key was exempted as "persisted UI state and canvas-written values", but CanvasShortcutSettings renders it as a visible editor under Features > Canvas, so a real setting was missing from the index: searching "canvas shortcut" only found the discourse tool shortcut. Authored as one row rather than one per node type, because the overrides share a single stored value; the anchor goes on the grid so a jump lands on it. Co-Authored-By: Claude Opus 5 --- .../components/settings/CanvasShortcutSettings.tsx | 6 +++++- .../src/components/settings/utils/settingsCatalog.ts | 12 ++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/roam/src/components/settings/CanvasShortcutSettings.tsx b/apps/roam/src/components/settings/CanvasShortcutSettings.tsx index 6768e0532..82decb234 100644 --- a/apps/roam/src/components/settings/CanvasShortcutSettings.tsx +++ b/apps/roam/src/components/settings/CanvasShortcutSettings.tsx @@ -5,6 +5,7 @@ import getDiscourseNodes, { excludeDefaultNodes, } from "~/utils/getDiscourseNodes"; import { setPersonalSetting } from "~/components/settings/utils/accessors"; +import { settingAnchor } from "~/components/settings/utils/settingAnchor"; import { PERSONAL_KEYS } from "~/components/settings/utils/settingKeys"; import { setSetting } from "~/utils/extensionSettings"; import { CANVAS_NODE_SHORTCUTS_KEY } from "~/data/userSettings"; @@ -103,7 +104,10 @@ const CanvasShortcutSettings = ({ }; return ( -
+
Override the canvas keyboard shortcuts
diff --git a/apps/roam/src/components/settings/utils/settingsCatalog.ts b/apps/roam/src/components/settings/utils/settingsCatalog.ts index 4d868e99c..e71a1a066 100644 --- a/apps/roam/src/components/settings/utils/settingsCatalog.ts +++ b/apps/roam/src/components/settings/utils/settingsCatalog.ts @@ -64,9 +64,8 @@ type NonRowKeyId = | "DISCOURSE_NODE_KEYS.suggestiveRules" // Relations are configured by their own panel, not a settings row. | "GLOBAL_KEYS.relations" - // Persisted UI state and canvas-written values, not settings. + // Persisted UI state, not a setting. | "PERSONAL_KEYS.globalSectionFolded" - | "PERSONAL_KEYS.canvasNodeShortcuts" // Per-section rows rendered from a list; no stable address yet. | "LEFT_SIDEBAR_KEYS.children" // Edited as a set by DiscourseNodeAttributes. @@ -237,6 +236,15 @@ const AUTHORED_SETTINGS = { keywords: ["personal", "tldraw", "hotkey"], path: rootPath(SETTINGS_TAB_IDS.featuresCanvas), }, + // One row for the whole per-node grid: the overrides share a single stored + // value and a single anchor, so search lands on the grid rather than a node. + "PERSONAL_KEYS.canvasNodeShortcuts": { + label: "Override the canvas keyboard shortcuts", + description: + "Replace the per-node-type shortcut keys used on the canvas. Changes take effect next time a canvas is opened.", + keywords: ["personal", "tldraw", "hotkey", "node type"], + path: rootPath(SETTINGS_TAB_IDS.featuresCanvas), + }, "PERSONAL_KEYS.autoCanvasRelations": { label: "Auto canvas relations", description: From cf10f8545624259777b06f48a98327c851e07053 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 31 Aug 2026 23:55:57 -0400 Subject: [PATCH 3/6] ENG-2213 Cover the catalog's addressing and coverage rules The catalog is what makes search navigable, and its invariants fail quietly: a duplicate id breaks React keys, a nested setting that drops its parent segment collides with a same-named top-level key, and an entry pointing at a tab that no longer exists sends the user nowhere. Also pins the two rules the export move introduced: settings living outside the Settings dialog stay out of the index, and stay authored here anyway so their rows keep reading one description. Co-Authored-By: Claude Opus 5 --- .../utils/__tests__/settingsCatalog.test.ts | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 apps/roam/src/components/settings/utils/__tests__/settingsCatalog.test.ts diff --git a/apps/roam/src/components/settings/utils/__tests__/settingsCatalog.test.ts b/apps/roam/src/components/settings/utils/__tests__/settingsCatalog.test.ts new file mode 100644 index 000000000..e4be2864f --- /dev/null +++ b/apps/roam/src/components/settings/utils/__tests__/settingsCatalog.test.ts @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + buildSettingsCatalog, + describedSetting, + type SearchableEntry, + type SearchableSetting, +} from "../settingsCatalog"; +import { SETTINGS_TAB_IDS, SETTINGS_TAB_META } from "../settingsTabs"; +import { + DISCOURSE_NODE_KEYS, + EXPORT_KEYS, + GLOBAL_KEYS, + PERSONAL_KEYS, + QUERY_KEYS, +} from "../settingKeys"; + +const { nodes, isSyncEnabled } = vi.hoisted(() => ({ + nodes: [ + { type: "claim-uid", text: "Claim", backedBy: "user" }, + { type: "evidence-uid", text: "Evidence", backedBy: "user" }, + ], + isSyncEnabled: vi.fn(() => false), +})); + +vi.mock("~/utils/getDiscourseNodes", () => ({ + default: () => nodes, + excludeDefaultNodes: (node: { backedBy: string }) => node.backedBy === "user", +})); + +vi.mock("../accessors", () => ({ isSyncEnabled })); + +const settingsIn = (catalog: SearchableEntry[]): SearchableSetting[] => + catalog.filter( + (entry): entry is SearchableSetting => entry.kind === "setting", + ); + +describe("buildSettingsCatalog", () => { + beforeEach(() => { + isSyncEnabled.mockReturnValue(false); + }); + + it("gives every entry a route the nav reducer can take", () => { + for (const entry of buildSettingsCatalog()) { + expect(entry.path.length).toBeGreaterThan(0); + expect(Object.values(SETTINGS_TAB_IDS)).toContain(entry.path[0]); + } + }); + + it("keeps result ids unique so they are safe as React keys", () => { + const ids = buildSettingsCatalog().map((entry) => entry.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + // The anchor is what the scroll-to selector matches, so a nested setting has to + // carry its parent segment or it would collide with a same-named top-level key. + it("addresses a nested setting by parent and key, a flat one by key alone", () => { + const catalog = settingsIn(buildSettingsCatalog()); + const anchorFor = (label: string): string | undefined => + catalog.find((entry) => entry.label === label)?.anchorId; + + expect(anchorFor("Default filters")).toBe( + `${PERSONAL_KEYS.query}/${QUERY_KEYS.defaultFilters}`, + ); + expect(anchorFor("Text selection popup")).toBe( + PERSONAL_KEYS.textSelectionPopup, + ); + }); + + // ENG-2185 moved these into the Export dialog, which this search cannot reach. + it("leaves out settings that no longer live in the Settings dialog", () => { + const exportEntries = settingsIn(buildSettingsCatalog()).filter((entry) => + entry.anchorId.startsWith(`${GLOBAL_KEYS.export}/`), + ); + expect(exportEntries).toEqual([]); + }); + + // Dropping them from the index must not drop their authored copy: the rows in + // the Export dialog still read their description from here. + it("still resolves descriptions for settings it does not index", () => { + const described = describedSetting([ + GLOBAL_KEYS.export, + EXPORT_KEYS.frontmatter, + ]); + expect(described?.description).toContain("Frontmatter"); + expect(described?.docsLink).toBeTruthy(); + }); + + it("expands a per-node-type setting once per node type", () => { + const descriptions = settingsIn(buildSettingsCatalog()).filter( + (entry) => entry.anchorId === DISCOURSE_NODE_KEYS.description, + ); + expect(descriptions).toHaveLength(nodes.length); + expect(descriptions.map((entry) => entry.path)).toEqual([ + [SETTINGS_TAB_IDS.grammarNodes, "claim-uid"], + [SETTINGS_TAB_IDS.grammarNodes, "evidence-uid"], + ]); + // Same anchor, different rows: the id is what disambiguates them. + expect(new Set(descriptions.map((entry) => entry.id)).size).toBe(2); + }); + + it("names the node type in a per-node breadcrumb", () => { + const claimTag = settingsIn(buildSettingsCatalog()).find( + (entry) => + entry.anchorId === DISCOURSE_NODE_KEYS.tag && + entry.path[1] === "claim-uid", + ); + expect(claimTag?.breadcrumb).toBe("Grammar › Nodes › Claim › Identity"); + }); + + it("drops settings the graph cannot reach", () => { + const suggestive = (): SearchableSetting[] => + settingsIn(buildSettingsCatalog()).filter((entry) => + entry.anchorId.includes(DISCOURSE_NODE_KEYS.suggestiveRules), + ); + expect(suggestive()).toEqual([]); + + isSyncEnabled.mockReturnValue(true); + expect(suggestive().length).toBeGreaterThan(0); + }); + + it("offers a page entry for every searchable tab and no others", () => { + const pages = buildSettingsCatalog().filter( + (entry) => entry.kind === "page", + ); + const expected = Object.entries(SETTINGS_TAB_META) + .filter(([, meta]) => meta.searchable) + .map(([tabId]) => tabId); + expect(pages.map((page) => page.path[0]).sort()).toEqual(expected.sort()); + }); + + it("keeps the hidden Admin tab out of the index", () => { + const pages = buildSettingsCatalog().filter( + (entry) => entry.kind === "page", + ); + expect(pages.map((page) => page.path[0])).not.toContain( + SETTINGS_TAB_IDS.admin, + ); + }); +}); + +describe("describedSetting", () => { + it("returns nothing for an unknown or empty address", () => { + expect(describedSetting(undefined)).toBeUndefined(); + expect(describedSetting([])).toBeUndefined(); + expect(describedSetting(["not-a-setting"])).toBeUndefined(); + }); +}); From c03bbc636f2bba81526abba15f749b218136bd46 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 3 Sep 2026 16:25:31 -0400 Subject: [PATCH 4/6] ENG-2213 Drop the SettingsTabId alias now defined alongside isSettingsTabId Co-Authored-By: Claude Fable 5.1 --- apps/roam/src/components/settings/utils/settingsTabs.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/roam/src/components/settings/utils/settingsTabs.ts b/apps/roam/src/components/settings/utils/settingsTabs.ts index 8f1216311..1f29d8789 100644 --- a/apps/roam/src/components/settings/utils/settingsTabs.ts +++ b/apps/roam/src/components/settings/utils/settingsTabs.ts @@ -68,8 +68,6 @@ export const SETTINGS_TAB_META = { { section: string; label: string; searchable: boolean } >; -export type SettingsTabId = keyof typeof SETTINGS_TAB_META; - /** Tab ids from before the taxonomy. Saved deep links still carry these. */ export const SETTINGS_TAB_ALIASES: Record = { "discourse-graph-home-personal": SETTINGS_TAB_IDS.preferencesGeneral, From 5c430f420ee7c8654d88ad8dbbf9fb657c3a0dd3 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 4 Sep 2026 17:47:39 -0400 Subject: [PATCH 5/6] ENG-2213 Follow the node page regroup in the catalog Index and Format now sit under Identity and Specification under Legacy, so the breadcrumbs search shows match the page. Format's description loses the DEPRECATED note along with the row. Co-Authored-By: Claude Fable 5.1 --- .../src/components/settings/utils/settingsCatalog.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/roam/src/components/settings/utils/settingsCatalog.ts b/apps/roam/src/components/settings/utils/settingsCatalog.ts index e71a1a066..d1fd5bbdb 100644 --- a/apps/roam/src/components/settings/utils/settingsCatalog.ts +++ b/apps/roam/src/components/settings/utils/settingsCatalog.ts @@ -374,16 +374,15 @@ const AUTHORED_SETTINGS = { }, "DISCOURSE_NODE_KEYS.format": { label: "Format", - group: "Recognition", - description: - "DEPRECATED - Use specification instead. The format pages should have.", + group: "Identity", + description: "The format pages should have.", docsLink: ROAM_DOCS.grammarNodes, keywords: ["node type", "deprecated"], path: nodePath(), }, "DISCOURSE_NODE_KEYS.specification": { label: "Specification", - group: "Recognition", + group: "Legacy", description: "The conditions specified to identify a node.", docsLink: ROAM_DOCS.grammarNodes, keywords: ["node type", "query", "conditions"], @@ -391,7 +390,7 @@ const AUTHORED_SETTINGS = { }, "DISCOURSE_NODE_KEYS.index": { label: "Index", - group: "Recognition", + group: "Identity", description: "The saved list of all pages — which pages appear and which columns show.", keywords: ["node type", "table", "columns"], From 92d85e97f2ccfac8f051573f51e5fa87c8c71bb5 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 7 Sep 2026 18:21:05 -0400 Subject: [PATCH 6/6] ENG-2213 Drop unit tests and trim comments to the decisions they record Co-Authored-By: Claude Fable 5.1 --- .../settings/components/SettingItemRow.tsx | 3 +- .../utils/__tests__/settingsCatalog.test.ts | 147 ------------------ .../settings/utils/settingsCatalog.ts | 21 +-- 3 files changed, 6 insertions(+), 165 deletions(-) delete mode 100644 apps/roam/src/components/settings/utils/__tests__/settingsCatalog.test.ts diff --git a/apps/roam/src/components/settings/components/SettingItemRow.tsx b/apps/roam/src/components/settings/components/SettingItemRow.tsx index eb4571b1b..61e5ba128 100644 --- a/apps/roam/src/components/settings/components/SettingItemRow.tsx +++ b/apps/roam/src/components/settings/components/SettingItemRow.tsx @@ -60,8 +60,7 @@ const SettingItemRow = ({ }: SettingItemRowProps): React.ReactElement => { const controlId = useId(); const isAssociated = typeof control === "function"; - // The catalog fallback lets a call site drop the prop, so the row and search - // read one string rather than two that can drift. + // Falls back to the catalog so the row and search read one description. const authored = description === undefined ? describedSetting(settingKeys) : undefined; const resolvedDescription = diff --git a/apps/roam/src/components/settings/utils/__tests__/settingsCatalog.test.ts b/apps/roam/src/components/settings/utils/__tests__/settingsCatalog.test.ts deleted file mode 100644 index e4be2864f..000000000 --- a/apps/roam/src/components/settings/utils/__tests__/settingsCatalog.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - buildSettingsCatalog, - describedSetting, - type SearchableEntry, - type SearchableSetting, -} from "../settingsCatalog"; -import { SETTINGS_TAB_IDS, SETTINGS_TAB_META } from "../settingsTabs"; -import { - DISCOURSE_NODE_KEYS, - EXPORT_KEYS, - GLOBAL_KEYS, - PERSONAL_KEYS, - QUERY_KEYS, -} from "../settingKeys"; - -const { nodes, isSyncEnabled } = vi.hoisted(() => ({ - nodes: [ - { type: "claim-uid", text: "Claim", backedBy: "user" }, - { type: "evidence-uid", text: "Evidence", backedBy: "user" }, - ], - isSyncEnabled: vi.fn(() => false), -})); - -vi.mock("~/utils/getDiscourseNodes", () => ({ - default: () => nodes, - excludeDefaultNodes: (node: { backedBy: string }) => node.backedBy === "user", -})); - -vi.mock("../accessors", () => ({ isSyncEnabled })); - -const settingsIn = (catalog: SearchableEntry[]): SearchableSetting[] => - catalog.filter( - (entry): entry is SearchableSetting => entry.kind === "setting", - ); - -describe("buildSettingsCatalog", () => { - beforeEach(() => { - isSyncEnabled.mockReturnValue(false); - }); - - it("gives every entry a route the nav reducer can take", () => { - for (const entry of buildSettingsCatalog()) { - expect(entry.path.length).toBeGreaterThan(0); - expect(Object.values(SETTINGS_TAB_IDS)).toContain(entry.path[0]); - } - }); - - it("keeps result ids unique so they are safe as React keys", () => { - const ids = buildSettingsCatalog().map((entry) => entry.id); - expect(new Set(ids).size).toBe(ids.length); - }); - - // The anchor is what the scroll-to selector matches, so a nested setting has to - // carry its parent segment or it would collide with a same-named top-level key. - it("addresses a nested setting by parent and key, a flat one by key alone", () => { - const catalog = settingsIn(buildSettingsCatalog()); - const anchorFor = (label: string): string | undefined => - catalog.find((entry) => entry.label === label)?.anchorId; - - expect(anchorFor("Default filters")).toBe( - `${PERSONAL_KEYS.query}/${QUERY_KEYS.defaultFilters}`, - ); - expect(anchorFor("Text selection popup")).toBe( - PERSONAL_KEYS.textSelectionPopup, - ); - }); - - // ENG-2185 moved these into the Export dialog, which this search cannot reach. - it("leaves out settings that no longer live in the Settings dialog", () => { - const exportEntries = settingsIn(buildSettingsCatalog()).filter((entry) => - entry.anchorId.startsWith(`${GLOBAL_KEYS.export}/`), - ); - expect(exportEntries).toEqual([]); - }); - - // Dropping them from the index must not drop their authored copy: the rows in - // the Export dialog still read their description from here. - it("still resolves descriptions for settings it does not index", () => { - const described = describedSetting([ - GLOBAL_KEYS.export, - EXPORT_KEYS.frontmatter, - ]); - expect(described?.description).toContain("Frontmatter"); - expect(described?.docsLink).toBeTruthy(); - }); - - it("expands a per-node-type setting once per node type", () => { - const descriptions = settingsIn(buildSettingsCatalog()).filter( - (entry) => entry.anchorId === DISCOURSE_NODE_KEYS.description, - ); - expect(descriptions).toHaveLength(nodes.length); - expect(descriptions.map((entry) => entry.path)).toEqual([ - [SETTINGS_TAB_IDS.grammarNodes, "claim-uid"], - [SETTINGS_TAB_IDS.grammarNodes, "evidence-uid"], - ]); - // Same anchor, different rows: the id is what disambiguates them. - expect(new Set(descriptions.map((entry) => entry.id)).size).toBe(2); - }); - - it("names the node type in a per-node breadcrumb", () => { - const claimTag = settingsIn(buildSettingsCatalog()).find( - (entry) => - entry.anchorId === DISCOURSE_NODE_KEYS.tag && - entry.path[1] === "claim-uid", - ); - expect(claimTag?.breadcrumb).toBe("Grammar › Nodes › Claim › Identity"); - }); - - it("drops settings the graph cannot reach", () => { - const suggestive = (): SearchableSetting[] => - settingsIn(buildSettingsCatalog()).filter((entry) => - entry.anchorId.includes(DISCOURSE_NODE_KEYS.suggestiveRules), - ); - expect(suggestive()).toEqual([]); - - isSyncEnabled.mockReturnValue(true); - expect(suggestive().length).toBeGreaterThan(0); - }); - - it("offers a page entry for every searchable tab and no others", () => { - const pages = buildSettingsCatalog().filter( - (entry) => entry.kind === "page", - ); - const expected = Object.entries(SETTINGS_TAB_META) - .filter(([, meta]) => meta.searchable) - .map(([tabId]) => tabId); - expect(pages.map((page) => page.path[0]).sort()).toEqual(expected.sort()); - }); - - it("keeps the hidden Admin tab out of the index", () => { - const pages = buildSettingsCatalog().filter( - (entry) => entry.kind === "page", - ); - expect(pages.map((page) => page.path[0])).not.toContain( - SETTINGS_TAB_IDS.admin, - ); - }); -}); - -describe("describedSetting", () => { - it("returns nothing for an unknown or empty address", () => { - expect(describedSetting(undefined)).toBeUndefined(); - expect(describedSetting([])).toBeUndefined(); - expect(describedSetting(["not-a-setting"])).toBeUndefined(); - }); -}); diff --git a/apps/roam/src/components/settings/utils/settingsCatalog.ts b/apps/roam/src/components/settings/utils/settingsCatalog.ts index d1fd5bbdb..8d5e91b3b 100644 --- a/apps/roam/src/components/settings/utils/settingsCatalog.ts +++ b/apps/roam/src/components/settings/utils/settingsCatalog.ts @@ -89,24 +89,18 @@ export type AuthoredSetting = { /** Set when the value is nested, e.g. Export rows under `GLOBAL_KEYS.export`. */ parent?: SettingKeyId; label: string; - /** The enclosing `SettingsGroup` title, when the row sits in one. */ group?: string; description?: string; docsLink?: string; /** Synonyms and pre-ENG-2189 section names, so muscle memory still resolves. */ keywords?: readonly string[]; - /** - * Omitted for settings that live outside the Settings dialog — the Export - * options moved into the Export dialog in ENG-2185. Still authored here so a - * row's description and docs link have one source, but not offered by a search - * that can only navigate Settings. - */ + /** Omitted when the setting lives outside Settings (Export options are in the Export + * dialog): still authored for its description, but not offered by search. */ path?: SettingsPath | ((nodeTypeUid: string) => SettingsPath); /** Settings a graph cannot reach are dropped rather than offered as dead ends. */ isAvailable?: () => boolean; }; -/** An authored entry with its address built and its route resolved. */ export type SearchableSetting = { kind: "setting"; /** Unique per result; the anchor value plus the node uid for per-node rows. */ @@ -236,8 +230,7 @@ const AUTHORED_SETTINGS = { keywords: ["personal", "tldraw", "hotkey"], path: rootPath(SETTINGS_TAB_IDS.featuresCanvas), }, - // One row for the whole per-node grid: the overrides share a single stored - // value and a single anchor, so search lands on the grid rather than a node. + // One row for the whole grid: the overrides share one stored value and one anchor. "PERSONAL_KEYS.canvasNodeShortcuts": { label: "Override the canvas keyboard shortcuts", description: @@ -293,8 +286,7 @@ const AUTHORED_SETTINGS = { keywords: ["personal", "query builder"], path: rootPath(SETTINGS_TAB_IDS.advancedQueries), }, - // No `path`: ENG-2185 moved these seven out of Settings and into the Export - // dialog's Export options, so settings search cannot navigate to them. + // No path: these live in the Export dialog now. "EXPORT_KEYS.removeSpecialCharacters": { parent: "GLOBAL_KEYS.export", label: "remove special characters", @@ -525,8 +517,6 @@ export const buildSettingsCatalog = (): SearchableEntry[] => { const available = ( Object.entries(AUTHORED_SETTINGS) as [RowKeyId, AuthoredSetting][] ).filter( - // A path-less entry is authored only for its description; it is not somewhere - // this search can navigate to. ([, setting]) => setting.path !== undefined && (setting.isAvailable?.() ?? true), ); @@ -564,8 +554,7 @@ const BY_ADDRESS = new Map( ), ); -/** Lets a row omit the description prop and read it from here, so the two cannot drift - * apart. ENG-2187 migrates the remaining call sites. */ +/** Lets a row omit `description` and read the catalog's, so the two cannot drift. */ export const describedSetting = ( settingKeys: readonly string[] | undefined, ): { description?: string; docsLink?: string } | undefined => {