Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,28 +1,65 @@
'use client'

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

const SimLinkOrganizationContext = createContext<string | null>(null)
interface SimLinkDraft {
url: string
title: string
}

interface SimLinkScope {
organizationId: string | null
/** Opens the new-link form for `url`, with a name suggested from `title`. */
openForm: (url: string, title: string) => void
}

const SimLinkScopeContext = createContext<SimLinkScope>({
organizationId: null,
openForm: () => {},
})

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

/** Scopes Sim link shortcuts to the organization whose links `sim/` opens for the viewer. */
/**
* Scopes Sim link shortcuts to the organization whose links `sim/` opens for the viewer, and hosts
* the new-link form at the layout root. A form opened from a menu row inside a popover would
* otherwise render beneath it, since popovers stack above modals so they can open inside one; at
* the root the form takes focus outside the popover, which dismisses it.
*/
export function SimLinkOrganizationProvider({
organizationId,
children,
}: SimLinkOrganizationProviderProps) {
const [draft, setDraft] = useState<SimLinkDraft | null>(null)
return (
<SimLinkOrganizationContext.Provider value={organizationId}>
<SimLinkScopeContext.Provider
value={{ organizationId, openForm: (url, title) => setDraft({ url, title }) }}
>
{children}
</SimLinkOrganizationContext.Provider>
{draft && organizationId && (
<SimLinkModal
organizationId={organizationId}
initialUrl={draft.url}
initialName={suggestSimLinkName(draft.title)}
onClose={() => setDraft(null)}
/>
)}
</SimLinkScopeContext.Provider>
)
}

/** The organization a new or copied Sim link belongs to; null hides every Sim link shortcut. */
export function useSimLinkOrganizationId(): string | null {
return useContext(SimLinkOrganizationContext)
return useContext(SimLinkScopeContext).organizationId
}

/** Opens the layout-hosted new-link form. */
export function useOpenSimLinkForm(): SimLinkScope['openForm'] {
return useContext(SimLinkScopeContext).openForm
}
11 changes: 7 additions & 4 deletions apps/sim/app/o/[organizationId]/links/sim-link-shortcut.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,15 @@ interface SimLinkCreator {
/** Null where Sim links are off; render no shortcut then. */
organizationId: string | null
openCreate: () => void
/** The new-link form while it is open; render it outside any menu, which unmounts on close. */
/** The new-link form while it is open, rendered inside the dialog that opened it. */
modal: ReactNode
}

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

/** The same choice as a footer chip, for a dialog that already shows the address it shortens. */
export function SimLinkChip({ url, title }: SimLinkChipProps) {
const { organizationId, openCreate, modal } = useSimLinkCreator(url, title)
const { organizationId, openCreate, modal } = useNestedSimLinkCreator(url, title)
if (!organizationId) return null
return (
<>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { flushMacrotask } from '@sim/testing/helpers/async'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SimLinkOrganizationProvider } from '@/app/o/[organizationId]/links/sim-link-organization-provider'
import { MessageSources } from '@/app/workspace/[workspaceId]/home/components/message-content/components/message-sources/message-sources'

const source = { url: 'https://docs.example.test/guide', title: 'Setup guide' }

/** Radix menus open on a primary-button pointer press, which jsdom's click() does not send. */
function press(element: Element | null) {
if (!element) throw new Error('Expected an element to press')
element.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 }))
}

async function settle() {
await act(flushMacrotask)
}

describe('creating a Sim link from the sources popover', () => {
let container: HTMLDivElement
let root: Root

beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
vi.stubGlobal(
'fetch',
vi.fn(async () => Response.json({ links: [], nextCursor: null }))
)
container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
act(() =>
root.render(
<QueryClientProvider client={new QueryClient()}>
<SimLinkOrganizationProvider organizationId='org-1'>
<MessageSources sources={[source]} />
</SimLinkOrganizationProvider>
</QueryClientProvider>
)
)
})

afterEach(() => {
act(() => root.unmount())
container.remove()
})

it('opens the form outside the popover and dismisses the popover, so the form is never beneath it', async () => {
act(() => document.querySelector<HTMLButtonElement>('button[aria-label="1 source"]')?.click())
const row = () => document.querySelector(`a[href="${source.url}"]`)
expect(row()).not.toBeNull()

act(() => press(document.querySelector(`[aria-label="Actions for ${source.title}"]`)))
await settle()
const create = [...document.querySelectorAll('[role="menuitem"]')].find(
(item) => item.textContent === 'Create Sim link'
)
if (!(create instanceof HTMLElement)) throw new Error('Expected a Create Sim link item')
expect(create.getAttribute('aria-disabled')).not.toBe('true')
act(() => create.click())
await settle()

const form = [...document.querySelectorAll('[role="dialog"]')].find((dialog) =>
dialog.textContent?.includes('New Sim link')
)
expect(form).toBeDefined()
expect(document.querySelector<HTMLInputElement>('input[type="url"]')?.value).toBe(source.url)
expect(row()).toBeNull()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ import { formatDate } from '@sim/utils/formatting'
import { findTermMatches, queryTerms } from '@/lib/knowledge/search/snippet'
import { inter } from '@/app/_styles/fonts/inter/inter'
import {
SimLinkMenuItem,
useSimLinkCreator,
} from '@/app/o/[organizationId]/links/sim-link-shortcut'
useOpenSimLinkForm,
useSimLinkOrganizationId,
} from '@/app/o/[organizationId]/links/sim-link-organization-provider'
import { SimLinkMenuItem } from '@/app/o/[organizationId]/links/sim-link-shortcut'
import {
SourceIcon,
sourceLabel,
Expand Down Expand Up @@ -76,7 +77,8 @@ interface SourceActionsProps {

function SourceActions({ source, onSummarize }: SourceActionsProps) {
const { copied, copy } = useCopyToClipboard({ resetMs: 1500 })
const simLink = useSimLinkCreator(source.url, sourceLabel(source))
const simLinkOrganizationId = useSimLinkOrganizationId()
const openSimLinkForm = useOpenSimLinkForm()
return (
<>
<DropdownMenu>
Expand All @@ -101,16 +103,15 @@ function SourceActions({ source, onSummarize }: SourceActionsProps) {
<LinkIcon className='size-[14px]' />
Copy link
</DropdownMenuItem>
{simLink.organizationId && (
{simLinkOrganizationId && (
<SimLinkMenuItem
organizationId={simLink.organizationId}
organizationId={simLinkOrganizationId}
url={source.url}
onCreate={simLink.openCreate}
onCreate={() => openSimLinkForm(source.url, sourceLabel(source))}
/>
)}
</DropdownMenuContent>
</DropdownMenu>
{simLink.modal}
</>
)
}
Expand Down
7 changes: 7 additions & 0 deletions apps/sim/lib/sim-links/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ describe('suggestSimLinkName', () => {
expect(simLinkNameSchema.safeParse(name).success).toBe(true)
})

it('joins words across colons, dots, and other name punctuation with a single hyphen', () => {
expect(suggestSimLinkName('Sim: Build and govern every AI agent')).toBe(
'sim-build-and-govern-every-ai-agent'
)
expect(suggestSimLinkName('docs.sim.ai @ v2 ~ draft')).toBe('docs-sim-ai-v2-draft')
})

it('cuts a long title to the name limit without a trailing hyphen', () => {
const word = 'a'.repeat(SIM_LINK_NAME_MAX_LENGTH - 1)
const name = suggestSimLinkName(`${word} appendix`)
Expand Down
12 changes: 6 additions & 6 deletions apps/sim/lib/sim-links/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,16 @@ export function toSimLinkName(input: string): string {
}

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

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