Skip to content

Commit 9b547e3

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(files): navigate sim file mentions
1 parent cbf3aad commit 9b547e3

7 files changed

Lines changed: 121 additions & 37 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.test.tsx

Lines changed: 87 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,14 @@ import type { Editor } from '@tiptap/react'
1212
import { createRoot, type Root } from 'react-dom/client'
1313
import { afterEach, describe, expect, it, vi } from 'vitest'
1414

15+
const navigation = vi.hoisted(() => ({
16+
push: vi.fn(),
17+
params: {} as Record<string, string>,
18+
}))
19+
1520
vi.mock('next/navigation', () => ({
16-
useRouter: () => ({ push: vi.fn() }),
17-
useParams: () => ({}),
21+
useRouter: () => ({ push: navigation.push }),
22+
useParams: () => navigation.params,
1823
}))
1924

2025
// Override the global `getAllBlocks: () => ({})` stub — `getIconColorMap` iterates it as an array.
@@ -25,38 +30,59 @@ function fakeNode(attrs: Record<string, unknown>) {
2530
return { attrs } as unknown as Parameters<typeof MentionChipView>[0]['node']
2631
}
2732

28-
function fakeEditor(): Editor {
29-
return { storage: { mentionMenu: { navigable: false } } } as unknown as Editor
33+
function fakeEditor(navigable = false): Editor {
34+
return { storage: { mentionMenu: { navigable } } } as unknown as Editor
3035
}
3136

3237
let container: HTMLDivElement | null = null
3338
let root: Root | null = null
3439

40+
async function renderChip({
41+
kind = 'file',
42+
id = 'f1',
43+
label = 'notes.md',
44+
navigable = false,
45+
workspaceId,
46+
}: {
47+
kind?: string
48+
id?: string
49+
label?: string
50+
navigable?: boolean
51+
workspaceId?: string
52+
} = {}): Promise<HTMLElement> {
53+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
54+
navigation.params = workspaceId ? { workspaceId } : {}
55+
container = document.createElement('div')
56+
document.body.appendChild(container)
57+
root = createRoot(container)
58+
59+
await act(async () => {
60+
root?.render(
61+
MentionChipView({
62+
node: fakeNode({ kind, id, label }),
63+
editor: fakeEditor(navigable),
64+
} as Parameters<typeof MentionChipView>[0])
65+
)
66+
})
67+
68+
const chip = container.querySelector('.mention-chip') as HTMLElement
69+
expect(chip).not.toBeNull()
70+
return chip
71+
}
72+
3573
afterEach(() => {
3674
if (root) act(() => root?.unmount())
3775
container?.remove()
3876
container = null
3977
root = null
78+
navigation.params = {}
79+
navigation.push.mockReset()
80+
vi.restoreAllMocks()
4081
})
4182

4283
describe('MentionChipView', () => {
4384
it('renders its wrapper with no explicit text-color utility class', async () => {
44-
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
45-
container = document.createElement('div')
46-
document.body.appendChild(container)
47-
root = createRoot(container)
48-
49-
await act(async () => {
50-
root?.render(
51-
MentionChipView({
52-
node: fakeNode({ kind: 'file', id: 'f1', label: 'notes.md' }),
53-
editor: fakeEditor(),
54-
} as Parameters<typeof MentionChipView>[0])
55-
)
56-
})
57-
58-
const chip = container.querySelector('.mention-chip') as HTMLElement
59-
expect(chip).not.toBeNull()
85+
const chip = await renderChip()
6086

6187
// Any `text-*` utility targeting the wrapper itself — bare, or Tailwind's self-targeting
6288
// `[&]:text-*` arbitrary variant (as opposed to a descendant variant like `[&>svg]:text-*`,
@@ -79,4 +105,45 @@ describe('MentionChipView', () => {
79105
'text-[var(--text-icon)]'
80106
)
81107
})
108+
109+
it('routes an ordinary click to the canonical resource path', async () => {
110+
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
111+
const chip = await renderChip({ navigable: true, workspaceId: 'ws1' })
112+
113+
act(() => chip.dispatchEvent(new MouseEvent('click', { bubbles: true })))
114+
115+
expect(navigation.push).toHaveBeenCalledOnce()
116+
expect(navigation.push).toHaveBeenCalledWith('/workspace/ws1/files/f1')
117+
expect(open).not.toHaveBeenCalled()
118+
})
119+
120+
it.each([
121+
['Cmd', { metaKey: true }],
122+
['Ctrl', { ctrlKey: true }],
123+
])('opens a %s-click in a new tab without routing the current tab', async (_name, modifier) => {
124+
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
125+
const chip = await renderChip({ navigable: true, workspaceId: 'ws1' })
126+
127+
act(() =>
128+
chip.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, ...modifier }))
129+
)
130+
131+
expect(open).toHaveBeenCalledOnce()
132+
expect(open).toHaveBeenCalledWith('/workspace/ws1/files/f1', '_blank', 'noopener,noreferrer')
133+
expect(navigation.push).not.toHaveBeenCalled()
134+
})
135+
136+
it.each([
137+
['navigation is disabled', false, 'ws1', 'file'],
138+
['the workspace route is absent', true, undefined, 'file'],
139+
['the resource kind is unsupported', true, 'ws1', 'integration'],
140+
])('stays inert when %s', async (_case, navigable, workspaceId, kind) => {
141+
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
142+
const chip = await renderChip({ navigable, workspaceId, kind })
143+
144+
act(() => chip.dispatchEvent(new MouseEvent('click', { bubbles: true })))
145+
146+
expect(navigation.push).not.toHaveBeenCalled()
147+
expect(open).not.toHaveBeenCalled()
148+
})
82149
})

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention-chip.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,10 @@ const CHIP_CLASS =
2828

2929
/**
3030
* Live chip: an entity icon + label matching the chat input's mention rendering. Where the host opted
31-
* into navigation (the file viewer), Cmd/Ctrl-click routes to the resource; in a modal field it stays
32-
* inert so a click can't navigate away from an unsaved edit. This view pulls the block registry (for
33-
* integration brand icons), so it's kept out of the headless {@link MarkdownMention} module.
31+
* into navigation (the file viewer), a click routes to the resource and Cmd/Ctrl-click opens it in a
32+
* new tab; in a modal field it stays inert so a click can't navigate away from an unsaved edit. This
33+
* view pulls the block registry (for integration brand icons), so it's kept out of the headless
34+
* {@link MarkdownMention} module.
3435
*/
3536
export function MentionChipView({ node, editor }: ReactNodeViewProps) {
3637
const router = useRouter()
@@ -42,8 +43,13 @@ export function MentionChipView({ node, editor }: ReactNodeViewProps) {
4243
const path = navigable && workspaceId ? simLinkPath(workspaceId, kind, id) : null
4344

4445
const handleClick = (event: MouseEvent) => {
45-
if (!path || !(event.metaKey || event.ctrlKey)) return
46+
if (!path) return
4647
event.preventDefault()
48+
event.stopPropagation()
49+
if (event.metaKey || event.ctrlKey) {
50+
window.open(path, '_blank', 'noopener,noreferrer')
51+
return
52+
}
4753
router.push(path)
4854
}
4955

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/mention.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ export const MENTION_PLUGIN_KEY = new PluginKey('mention')
1313
* Per-editor storage for the `@` mention extension. The host component populates {@link store} with
1414
* the current workspace mention data and may set {@link onOpen} to lazily start fetching that data the
1515
* first time the menu is triggered. {@link enabled} gates the menu off entirely (e.g. a field with no
16-
* workspace scope) so `@` stays literal text. {@link navigable} lets a chip Cmd/Ctrl-click to its
17-
* resource — on for the file viewer, off inside a modal field so it can't route away from an edit.
16+
* workspace scope) so `@` stays literal text. {@link navigable} lets a chip route to its resource — on
17+
* for the file viewer, off inside a modal field so it can't route away from an edit.
1818
*/
1919
export interface MentionStorage {
2020
store: MentionStore

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,22 @@ describe('simLinkPath', () => {
66

77
// Each destination must match a real route — skills/folders deep-link via query params (no [id] route).
88
it('resolves every kind to its real in-app route', () => {
9-
expect(simLinkPath(ws, 'file', 'f1')).toBe('/workspace/ws1/files/f1/view')
9+
expect(simLinkPath(ws, 'file', 'f1')).toBe('/workspace/ws1/files/f1')
1010
expect(simLinkPath(ws, 'folder', 'd1')).toBe('/workspace/ws1/files?folderId=d1')
1111
expect(simLinkPath(ws, 'table', 't1')).toBe('/workspace/ws1/tables/t1')
1212
expect(simLinkPath(ws, 'knowledge', 'k1')).toBe('/workspace/ws1/knowledge/k1')
1313
expect(simLinkPath(ws, 'workflow', 'w1')).toBe('/workspace/ws1/w/w1')
1414
expect(simLinkPath(ws, 'skill', 's1')).toBe('/workspace/ws1/skills?skillId=s1')
1515
})
1616

17+
it('encodes ids as a single route or query component', () => {
18+
expect(simLinkPath(ws, 'file', 'f/1?tab=raw')).toBe('/workspace/ws1/files/f%2F1%3Ftab%3Draw')
19+
expect(simLinkPath('ws/1', 'file', 'f1')).toBe('/workspace/ws%2F1/files/f1')
20+
expect(simLinkPath(ws, 'folder', 'd/1&archived=true')).toBe(
21+
'/workspace/ws1/files?folderId=d%2F1%26archived%3Dtrue'
22+
)
23+
})
24+
1725
it('returns null for kinds with no navigable resource (integration) and unknown kinds', () => {
1826
// An integration mention's id is a block type, not a routable resource.
1927
expect(simLinkPath(ws, 'integration', 'slack')).toBeNull()

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/sim-link.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,21 @@ export function toSimHref(kind: string, id: string): string {
1818
* credentials), so the chip stays display-only.
1919
*/
2020
export function simLinkPath(workspaceId: string, kind: string, id: string): string | null {
21-
const base = `/workspace/${workspaceId}`
21+
const base = `/workspace/${encodeURIComponent(workspaceId)}`
22+
const encodedId = encodeURIComponent(id)
2223
switch (kind) {
2324
case 'file':
24-
return `${base}/files/${id}/view`
25+
return `${base}/files/${encodedId}`
2526
case 'folder':
26-
return `${base}/files?folderId=${id}`
27+
return `${base}/files?folderId=${encodedId}`
2728
case 'table':
28-
return `${base}/tables/${id}`
29+
return `${base}/tables/${encodedId}`
2930
case 'knowledge':
30-
return `${base}/knowledge/${id}`
31+
return `${base}/knowledge/${encodedId}`
3132
case 'workflow':
32-
return `${base}/w/${id}`
33+
return `${base}/w/${encodedId}`
3334
case 'skill':
34-
return `${base}/skills?skillId=${id}`
35+
return `${base}/skills?skillId=${encodedId}`
3536
default:
3637
return null
3738
}

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention/use-editor-mentions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { Editor } from '@tiptap/react'
33
import { useMarkdownMentions } from './use-markdown-mentions'
44

55
interface UseEditorMentionsOptions {
6-
/** Whether a chip can Cmd/Ctrl-click to its resource. On for the file viewer, off in modal fields. */
6+
/** Whether a chip can navigate to its resource. On for the file viewer, off in modal fields. */
77
navigable?: boolean
88
/** Force the `@` insertion menu off even with a workspace; existing tags still render. */
99
disableTagging?: boolean

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,10 @@ const EDITOR_SURFACE_CLASS =
9999
*/
100100
interface ReadOnlyPlaceholderProps {
101101
content: JSONContent
102+
workspaceId: string
102103
}
103104

104-
function ReadOnlyPlaceholder({ content }: ReadOnlyPlaceholderProps) {
105+
function ReadOnlyPlaceholder({ content, workspaceId }: ReadOnlyPlaceholderProps) {
105106
const editor = useEditor({
106107
extensions: EXTENSIONS,
107108
editable: false,
@@ -113,6 +114,7 @@ function ReadOnlyPlaceholder({ content }: ReadOnlyPlaceholderProps) {
113114
content,
114115
editorProps: { attributes: { class: 'rich-markdown-nodes rich-markdown-prose' } },
115116
})
117+
useEditorMentions(editor, workspaceId, { navigable: true, disableTagging: true })
116118
return <EditorContent editor={editor} className={EDITOR_SURFACE_CLASS} />
117119
}
118120

@@ -1221,7 +1223,7 @@ export function LoadedRichMarkdownEditor({
12211223
}}
12221224
/>
12231225
{showPlaceholder && placeholderContent && (
1224-
<ReadOnlyPlaceholder content={placeholderContent} />
1226+
<ReadOnlyPlaceholder content={placeholderContent} workspaceId={workspaceId} />
12251227
)}
12261228
<EditorContent
12271229
editor={editor}

0 commit comments

Comments
 (0)