Skip to content

Commit 85e3c06

Browse files
authored
fix(sim-links): open the new-link form above the sources popover and suggest hyphenated names (#9654)
* fix(sim-links): open the new-link form above the sources popover and suggest hyphenated names * test(sim-links): use the shared macrotask flush in the sources popover test * test(sim-links): import the macrotask helper from its own module
1 parent 81eb564 commit 85e3c06

6 files changed

Lines changed: 147 additions & 24 deletions

File tree

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,65 @@
11
'use client'
22

3-
import { createContext, type ReactNode, useContext } from 'react'
3+
import { createContext, type ReactNode, useContext, useState } from 'react'
4+
import { suggestSimLinkName } from '@/lib/sim-links/validation'
5+
import { SimLinkModal } from '@/app/o/[organizationId]/links/sim-link-modal'
46

5-
const SimLinkOrganizationContext = createContext<string | null>(null)
7+
interface SimLinkDraft {
8+
url: string
9+
title: string
10+
}
11+
12+
interface SimLinkScope {
13+
organizationId: string | null
14+
/** Opens the new-link form for `url`, with a name suggested from `title`. */
15+
openForm: (url: string, title: string) => void
16+
}
17+
18+
const SimLinkScopeContext = createContext<SimLinkScope>({
19+
organizationId: null,
20+
openForm: () => {},
21+
})
622

723
interface SimLinkOrganizationProviderProps {
824
/** Where `sim/<name>` resolves for this session, or null when it resolves nowhere. */
925
organizationId: string | null
1026
children: ReactNode
1127
}
1228

13-
/** Scopes Sim link shortcuts to the organization whose links `sim/` opens for the viewer. */
29+
/**
30+
* Scopes Sim link shortcuts to the organization whose links `sim/` opens for the viewer, and hosts
31+
* the new-link form at the layout root. A form opened from a menu row inside a popover would
32+
* otherwise render beneath it, since popovers stack above modals so they can open inside one; at
33+
* the root the form takes focus outside the popover, which dismisses it.
34+
*/
1435
export function SimLinkOrganizationProvider({
1536
organizationId,
1637
children,
1738
}: SimLinkOrganizationProviderProps) {
39+
const [draft, setDraft] = useState<SimLinkDraft | null>(null)
1840
return (
19-
<SimLinkOrganizationContext.Provider value={organizationId}>
41+
<SimLinkScopeContext.Provider
42+
value={{ organizationId, openForm: (url, title) => setDraft({ url, title }) }}
43+
>
2044
{children}
21-
</SimLinkOrganizationContext.Provider>
45+
{draft && organizationId && (
46+
<SimLinkModal
47+
organizationId={organizationId}
48+
initialUrl={draft.url}
49+
initialName={suggestSimLinkName(draft.title)}
50+
onClose={() => setDraft(null)}
51+
/>
52+
)}
53+
</SimLinkScopeContext.Provider>
2254
)
2355
}
2456

2557
/** The organization a new or copied Sim link belongs to; null hides every Sim link shortcut. */
2658
export function useSimLinkOrganizationId(): string | null {
27-
return useContext(SimLinkOrganizationContext)
59+
return useContext(SimLinkScopeContext).organizationId
60+
}
61+
62+
/** Opens the layout-hosted new-link form. */
63+
export function useOpenSimLinkForm(): SimLinkScope['openForm'] {
64+
return useContext(SimLinkScopeContext).openForm
2865
}

‎apps/sim/app/o/[organizationId]/links/sim-link-shortcut.tsx‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,15 @@ interface SimLinkCreator {
1313
/** Null where Sim links are off; render no shortcut then. */
1414
organizationId: string | null
1515
openCreate: () => void
16-
/** The new-link form while it is open; render it outside any menu, which unmounts on close. */
16+
/** The new-link form while it is open, rendered inside the dialog that opened it. */
1717
modal: ReactNode
1818
}
1919

20-
/** Opens the new-link form for `url`, prefilled with a name suggested from `title`. */
21-
export function useSimLinkCreator(url: string, title: string): SimLinkCreator {
20+
/**
21+
* A new-link form nested in the dialog that opens it, so it stacks above that dialog and keeps its
22+
* focus trap. Menus and popovers use the layout-hosted form (`useOpenSimLinkForm`) instead.
23+
*/
24+
function useNestedSimLinkCreator(url: string, title: string): SimLinkCreator {
2225
const organizationId = useSimLinkOrganizationId()
2326
const [open, setOpen] = useState(false)
2427
return {
@@ -112,7 +115,7 @@ interface SimLinkChipProps {
112115

113116
/** The same choice as a footer chip, for a dialog that already shows the address it shortens. */
114117
export function SimLinkChip({ url, title }: SimLinkChipProps) {
115-
const { organizationId, openCreate, modal } = useSimLinkCreator(url, title)
118+
const { organizationId, openCreate, modal } = useNestedSimLinkCreator(url, title)
116119
if (!organizationId) return null
117120
return (
118121
<>
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { flushMacrotask } from '@sim/testing/helpers/async'
6+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
7+
import { createRoot, type Root } from 'react-dom/client'
8+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
9+
import { SimLinkOrganizationProvider } from '@/app/o/[organizationId]/links/sim-link-organization-provider'
10+
import { MessageSources } from '@/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources'
11+
12+
const source = { url: 'https://docs.example.test/guide', title: 'Setup guide' }
13+
14+
/** Radix menus open on a primary-button pointer press, which jsdom's click() does not send. */
15+
function press(element: Element | null) {
16+
if (!element) throw new Error('Expected an element to press')
17+
element.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 }))
18+
}
19+
20+
async function settle() {
21+
await act(flushMacrotask)
22+
}
23+
24+
describe('creating a Sim link from the sources popover', () => {
25+
let container: HTMLDivElement
26+
let root: Root
27+
28+
beforeEach(() => {
29+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
30+
vi.stubGlobal(
31+
'fetch',
32+
vi.fn(async () => Response.json({ links: [], nextCursor: null }))
33+
)
34+
container = document.createElement('div')
35+
document.body.append(container)
36+
root = createRoot(container)
37+
act(() =>
38+
root.render(
39+
<QueryClientProvider client={new QueryClient()}>
40+
<SimLinkOrganizationProvider organizationId='org-1'>
41+
<MessageSources sources={[source]} />
42+
</SimLinkOrganizationProvider>
43+
</QueryClientProvider>
44+
)
45+
)
46+
})
47+
48+
afterEach(() => {
49+
act(() => root.unmount())
50+
container.remove()
51+
})
52+
53+
it('opens the form outside the popover and dismisses the popover, so the form is never beneath it', async () => {
54+
act(() => document.querySelector<HTMLButtonElement>('button[aria-label="1 source"]')?.click())
55+
const row = () => document.querySelector(`a[href="${source.url}"]`)
56+
expect(row()).not.toBeNull()
57+
58+
act(() => press(document.querySelector(`[aria-label="Actions for ${source.title}"]`)))
59+
await settle()
60+
const create = [...document.querySelectorAll('[role="menuitem"]')].find(
61+
(item) => item.textContent === 'Create Sim link'
62+
)
63+
if (!(create instanceof HTMLElement)) throw new Error('Expected a Create Sim link item')
64+
expect(create.getAttribute('aria-disabled')).not.toBe('true')
65+
act(() => create.click())
66+
await settle()
67+
68+
const form = [...document.querySelectorAll('[role="dialog"]')].find((dialog) =>
69+
dialog.textContent?.includes('New Sim link')
70+
)
71+
expect(form).toBeDefined()
72+
expect(document.querySelector<HTMLInputElement>('input[type="url"]')?.value).toBe(source.url)
73+
expect(row()).toBeNull()
74+
})
75+
})

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-card/source-card.tsx‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,10 @@ import { formatDate } from '@sim/utils/formatting'
2121
import { findTermMatches, queryTerms } from '@/lib/knowledge/search/snippet'
2222
import { inter } from '@/app/_styles/fonts/inter/inter'
2323
import {
24-
SimLinkMenuItem,
25-
useSimLinkCreator,
26-
} from '@/app/o/[organizationId]/links/sim-link-shortcut'
24+
useOpenSimLinkForm,
25+
useSimLinkOrganizationId,
26+
} from '@/app/o/[organizationId]/links/sim-link-organization-provider'
27+
import { SimLinkMenuItem } from '@/app/o/[organizationId]/links/sim-link-shortcut'
2728
import {
2829
SourceIcon,
2930
sourceLabel,
@@ -76,7 +77,8 @@ interface SourceActionsProps {
7677

7778
function SourceActions({ source, onSummarize }: SourceActionsProps) {
7879
const { copied, copy } = useCopyToClipboard({ resetMs: 1500 })
79-
const simLink = useSimLinkCreator(source.url, sourceLabel(source))
80+
const simLinkOrganizationId = useSimLinkOrganizationId()
81+
const openSimLinkForm = useOpenSimLinkForm()
8082
return (
8183
<>
8284
<DropdownMenu>
@@ -101,16 +103,15 @@ function SourceActions({ source, onSummarize }: SourceActionsProps) {
101103
<LinkIcon className='size-[14px]' />
102104
Copy link
103105
</DropdownMenuItem>
104-
{simLink.organizationId && (
106+
{simLinkOrganizationId && (
105107
<SimLinkMenuItem
106-
organizationId={simLink.organizationId}
108+
organizationId={simLinkOrganizationId}
107109
url={source.url}
108-
onCreate={simLink.openCreate}
110+
onCreate={() => openSimLinkForm(source.url, sourceLabel(source))}
109111
/>
110112
)}
111113
</DropdownMenuContent>
112114
</DropdownMenu>
113-
{simLink.modal}
114115
</>
115116
)
116117
}

‎apps/sim/lib/sim-links/validation.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ describe('suggestSimLinkName', () => {
99
expect(simLinkNameSchema.safeParse(name).success).toBe(true)
1010
})
1111

12+
it('joins words across colons, dots, and other name punctuation with a single hyphen', () => {
13+
expect(suggestSimLinkName('Sim: Build and govern every AI agent')).toBe(
14+
'sim-build-and-govern-every-ai-agent'
15+
)
16+
expect(suggestSimLinkName('docs.sim.ai @ v2 ~ draft')).toBe('docs-sim-ai-v2-draft')
17+
})
18+
1219
it('cuts a long title to the name limit without a trailing hyphen', () => {
1320
const word = 'a'.repeat(SIM_LINK_NAME_MAX_LENGTH - 1)
1421
const name = suggestSimLinkName(`${word} appendix`)

‎apps/sim/lib/sim-links/validation.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,16 @@ export function toSimLinkName(input: string): string {
3535
}
3636

3737
/**
38-
* A starting name for a link made from a page's title: lower case, words joined by single hyphens,
39-
* cut to the name length limit. Empty when no letter or digit survives, so the person types one
40-
* instead of being handed a name that is only punctuation.
38+
* A starting name for a link made from a page's title: its letters and digits in lower case, every
39+
* other run of characters (spaces, colons, dots, dashes) turned into one hyphen, cut to the name
40+
* length limit. Empty when no letter or digit survives, so the person types one instead.
4141
*/
4242
export function suggestSimLinkName(title: string): string {
43-
const name = toSimLinkName(title.trim().toLowerCase())
44-
.replace(/-{2,}/g, '-')
43+
return title
44+
.toLowerCase()
45+
.replace(/[^a-z0-9]+/g, '-')
4546
.slice(0, SIM_LINK_NAME_MAX_LENGTH)
4647
.replace(/^-+|-+$/g, '')
47-
return /[a-z0-9]/.test(name) ? name : ''
4848
}
4949

5050
/** Sentence punctuation and closing quotes or brackets that follow a link pasted from prose. */

0 commit comments

Comments
 (0)