diff --git a/apps/docs/content/docs/search/sim-links.mdx b/apps/docs/content/docs/search/sim-links.mdx
index 5ce3a7207fd..e9d5d553eba 100644
--- a/apps/docs/content/docs/search/sim-links.mdx
+++ b/apps/docs/content/docs/search/sim-links.mdx
@@ -26,7 +26,7 @@ Type `sim`, press **Space**, then type a link name and press **Enter**. Chrome a
## Create a link
1. Open **Sim Links** and select **New link**.
-2. Enter the **Destination URL** and a **Name**. Names can use letters, numbers, and `. : @ & ~ - _`.
+2. Enter the **Destination URL** and a **Name**. Names use letters, numbers, and `. : @ & ~ - _`; spaces become hyphens as you type.
3. Optionally add a **Description**, then select **Create**.
To share a link, right-click it and select **Copy link**.
@@ -49,8 +49,8 @@ Values fill each `{*}` in order, and the last `{*}` takes the rest. A link with
By default, any member can edit or delete a link. The link's creator and organization admins can change two options under **Access**:
-- **Unlisted**: only the creator and admins see the link on the Sim Links page. Anyone with the name can still open it.
-- **Restricted**: only the creator and admins can edit or delete the link.
+- **Hide from the Sim Links page**: only the creator and admins see the link in the list, marked **Unlisted**. Anyone with the name can still open it.
+- **Only you and admins can edit**: only the creator and admins can edit or delete the link, marked **Restricted**.
Changes to links are recorded in your organization's [audit logs](/platform/enterprise/audit-logs).
diff --git a/apps/sim/app/o/[organizationId]/links/columns.ts b/apps/sim/app/o/[organizationId]/links/columns.ts
new file mode 100644
index 00000000000..c3b7dd43148
--- /dev/null
+++ b/apps/sim/app/o/[organizationId]/links/columns.ts
@@ -0,0 +1,16 @@
+import { Plus } from '@sim/emcn/icons'
+import type { ResourceColumn } from '@/app/workspace/[workspaceId]/components/resource/resource'
+
+/** Shared by the page and its loading chrome so the skeleton matches the table. */
+export const SIM_LINK_COLUMNS = [
+ { id: 'name', header: 'Name' },
+ { id: 'destination', header: 'Destination', widthMultiplier: 2 },
+ { id: 'description', header: 'Description', widthMultiplier: 1.4 },
+ { id: 'owner', header: 'Created by' },
+ { id: 'access', header: 'Access' },
+ { id: 'updated', header: 'Updated' },
+] as const satisfies readonly ResourceColumn[]
+
+export const SIM_LINKS_SEARCH_PLACEHOLDER = 'Search Sim links...'
+
+export const NEW_SIM_LINK_ACTION = { text: 'New link', icon: Plus, variant: 'primary' } as const
diff --git a/apps/sim/app/o/[organizationId]/links/loading.tsx b/apps/sim/app/o/[organizationId]/links/loading.tsx
index 67d019510b2..11b0a7898c8 100644
--- a/apps/sim/app/o/[organizationId]/links/loading.tsx
+++ b/apps/sim/app/o/[organizationId]/links/loading.tsx
@@ -1,33 +1,21 @@
'use client'
-import { Link, Plus } from '@sim/emcn/icons'
+import { Link } from '@sim/emcn/icons'
import {
- type ChromeActionSpec,
- ResourceChromeFallback,
-} from '@/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback'
-import type { ResourceColumn } from '@/app/workspace/[workspaceId]/components/resource/resource'
-
-const COLUMNS = [
- { id: 'name', header: 'Name' },
- { id: 'destination', header: 'Destination', widthMultiplier: 2 },
- { id: 'description', header: 'Description', widthMultiplier: 1.4 },
- { id: 'owner', header: 'Created by' },
- { id: 'access', header: 'Access' },
- { id: 'updated', header: 'Updated' },
-] as const satisfies readonly ResourceColumn[]
-
-const ACTIONS = [
- { text: 'New link', icon: Plus, variant: 'primary' },
-] as const satisfies readonly ChromeActionSpec[]
+ NEW_SIM_LINK_ACTION,
+ SIM_LINK_COLUMNS,
+ SIM_LINKS_SEARCH_PLACEHOLDER,
+} from '@/app/o/[organizationId]/links/columns'
+import { ResourceChromeFallback } from '@/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback'
export default function SimLinksLoading() {
return (
)
diff --git a/apps/sim/app/o/[organizationId]/links/sim-link-context-menu.tsx b/apps/sim/app/o/[organizationId]/links/sim-link-context-menu.tsx
index d8adf7d5ed5..98e08ece0c4 100644
--- a/apps/sim/app/o/[organizationId]/links/sim-link-context-menu.tsx
+++ b/apps/sim/app/o/[organizationId]/links/sim-link-context-menu.tsx
@@ -61,12 +61,14 @@ export function SimLinkContextMenu({
Edit
)}
- {onDelete && }
{onDelete && (
-
-
- Delete
-
+ <>
+
+
+
+ Delete
+
+ >
)}
diff --git a/apps/sim/app/o/[organizationId]/links/sim-link-icon.tsx b/apps/sim/app/o/[organizationId]/links/sim-link-icon.tsx
index 921f17367c3..4849b22ed33 100644
--- a/apps/sim/app/o/[organizationId]/links/sim-link-icon.tsx
+++ b/apps/sim/app/o/[organizationId]/links/sim-link-icon.tsx
@@ -21,6 +21,8 @@ export function SimLinkIcon({ url }: SimLinkIconProps) {
src={faviconUrl(hostname, 64)}
alt=''
referrerPolicy='no-referrer'
+ loading='lazy'
+ decoding='async'
className='rounded-sm'
onError={() => setFailedHostname(hostname)}
/>
diff --git a/apps/sim/app/o/[organizationId]/links/sim-link-modal.tsx b/apps/sim/app/o/[organizationId]/links/sim-link-modal.tsx
index 89f78506625..4ce55a6a8af 100644
--- a/apps/sim/app/o/[organizationId]/links/sim-link-modal.tsx
+++ b/apps/sim/app/o/[organizationId]/links/sim-link-modal.tsx
@@ -18,11 +18,18 @@ import {
SIM_LINK_NAME_MAX_LENGTH,
SIM_LINK_VARIABLE,
simLinkFieldsSchema,
+ toSimLinkName,
} from '@/lib/sim-links/validation'
import { useCreateSimLink, useUpdateSimLink } from '@/hooks/queries/sim-links'
type Field = 'name' | 'url' | 'defaultUrl' | 'description'
+const SIM_LINKS_DOCS_URL = 'https://docs.sim.ai/search/sim-links'
+
+function openDocs() {
+ window.open(SIM_LINKS_DOCS_URL, '_blank', 'noopener,noreferrer')
+}
+
interface SimLinkModalProps {
organizationId: string
onClose: () => void
@@ -101,11 +108,6 @@ export function SimLinkModal({ organizationId, onClose, link, initialName }: Sim
value={draft.url}
onChange={(value) => change('url', value)}
error={errors.url}
- hint={
- variable
- ? `Variable link: sim/${draft.name || 'name'}/value fills each ${SIM_LINK_VARIABLE}.`
- : `Add ${SIM_LINK_VARIABLE} where a value typed after the name should go.`
- }
/>
{variable && (
change('defaultUrl', value || null)}
error={errors.defaultUrl}
- hint={`Opens when sim/${draft.name || 'name'} is used without a value.`}
+ hint={`Opens when no value fills ${SIM_LINK_VARIABLE}.`}
/>
)}
change('name', value)}
+ onChange={(value) => change('name', toSimLinkName(value))}
error={errors.name ?? (nameTaken ? mutation.error?.message : undefined)}
- hint='Letters, numbers, and . : @ & ~ - _. Case and punctuation are ignored, so sim/Q4-goals is sim/q4goals.'
/>
{(aria) => (
@@ -162,7 +161,7 @@ export function SimLinkModal({ organizationId, onClose, link, initialName }: Sim
onCheckedChange={(checked) => change('unlisted', checked === true)}
/>
- Hide from the Sim Links page for everyone except you and admins
+ Hide from the Sim Links page
@@ -184,6 +183,7 @@ export function SimLinkModal({ organizationId, onClose, link, initialName }: Sim
(null)
+ const [menuLinkId, setMenuLinkId] = useState(null)
const [editing, setEditing] = useState(null)
const [deleting, setDeleting] = useState(null)
const loaded = useMemo(() => links.data?.pages.flatMap((page) => page.links) ?? [], [links.data])
const linksById = useMemo(() => new Map(loaded.map((link) => [link.id, link])), [loaded])
+ const menuLink = menuLinkId ? linksById.get(menuLinkId) : undefined
+ if (menuLinkId && !menuLink) setMenuLinkId(null)
const rows: ResourceRow[] = useMemo(
() =>
@@ -125,10 +123,9 @@ export function SimLinks({ organizationId }: SimLinksProps) {
value: search,
onChange: setSearch,
onClearAll: () => setSearch(''),
- placeholder: 'Search Sim links...',
+ placeholder: SIM_LINKS_SEARCH_PLACEHOLDER,
}
const sortConfig: SortConfig = { options: SORT_OPTIONS, active: activeSort, onSort, onClear }
- const searching = Boolean(debouncedSearch.trim())
const startCreate = () => void setView({ create: '' })
if (!links.data && !links.isLoadingError) return
@@ -138,22 +135,19 @@ export function SimLinks({ organizationId }: SimLinksProps) {
{
const link = linksById.get(id)
if (link) openLink(link.name)
}}
onRowContextMenu={(event, id) => {
- const link = linksById.get(id)
- if (!link) return
- setMenuLink(link)
+ if (!linksById.has(id)) return
+ setMenuLinkId(id)
contextMenu.handleContextMenu(event)
}}
onLoadMore={() => void links.fetchNextPage()}
@@ -167,9 +161,9 @@ export function SimLinks({ organizationId }: SimLinksProps) {
{links.error.message}
void links.refetch()}>Retry
- ) : searching ? (
+ ) : query ? (
setSearch('')}
diff --git a/apps/sim/lib/sim-links/repository.ts b/apps/sim/lib/sim-links/repository.ts
index 13540051adb..9512a4d4b57 100644
--- a/apps/sim/lib/sim-links/repository.ts
+++ b/apps/sim/lib/sim-links/repository.ts
@@ -87,7 +87,7 @@ export async function listSimLinks(
dir = sort === 'popular' ? 'desc' : 'asc',
}: { search?: string; cursor?: string; sort?: SimLinkSort; dir?: SortDirection }
): Promise<{ links: SimLinkRecord[]; nextCursor: string | null }> {
- const order = dir === 'asc' ? asc : desc
+ const [order, after] = dir === 'asc' ? ([asc, gt] as const) : ([desc, lt] as const)
const conditions: (SQL | undefined)[] = [eq(simLink.organizationId, organizationId)]
if (!viewer.isAdmin) {
conditions.push(or(eq(simLink.unlisted, false), eq(simLink.createdBy, viewer.userId)))
@@ -111,11 +111,7 @@ export async function listSimLinks(
.limit(PAGE_SIZE)
return { links: rows.map(toRecord), nextCursor: null }
}
- if (cursor) {
- conditions.push(
- dir === 'asc' ? gt(simLink.normalizedName, cursor) : lt(simLink.normalizedName, cursor)
- )
- }
+ if (cursor) conditions.push(after(simLink.normalizedName, cursor))
const rows = await selectRecords(db)
.where(and(...conditions))
.orderBy(order(simLink.normalizedName))
diff --git a/apps/sim/lib/sim-links/validation.ts b/apps/sim/lib/sim-links/validation.ts
index 7216e22c9be..ddd8b5c44f3 100644
--- a/apps/sim/lib/sim-links/validation.ts
+++ b/apps/sim/lib/sim-links/validation.ts
@@ -2,6 +2,7 @@ import { z } from 'zod'
const NAME_PATTERN = /^[A-Za-z0-9.:@&~_-]+$/
const NAME_PUNCTUATION = /[.:@&~_-]/g
+const NAME_DISALLOWED = /[^A-Za-z0-9.:@&~_-]/g
export const SIM_LINK_NAME_MAX_LENGTH = 64
export const SIM_LINK_DESCRIPTION_MAX_LENGTH = 500
const URL_MAX_LENGTH = 2048
@@ -18,6 +19,17 @@ export function normalizeSimLinkName(name: string): string {
return name.toLowerCase().replace(NAME_PUNCTUATION, '')
}
+/**
+ * Shapes typed or pasted text into a name as it is entered: a leading `sim/` is dropped, spaces
+ * and slashes become hyphens, and any other character a name can't hold is removed.
+ */
+export function toSimLinkName(input: string): string {
+ return input
+ .replace(/^sim\//i, '')
+ .replace(/[\s/]+/g, '-')
+ .replace(NAME_DISALLOWED, '')
+}
+
export function isVariableSimLinkUrl(url: string): boolean {
return url.includes(SIM_LINK_VARIABLE)
}
diff --git a/apps/sim/scripts/test-sim-links-e2e.ts b/apps/sim/scripts/test-sim-links-e2e.ts
index 45201fac767..683383c1ccd 100644
--- a/apps/sim/scripts/test-sim-links-e2e.ts
+++ b/apps/sim/scripts/test-sim-links-e2e.ts
@@ -103,10 +103,9 @@ async function create(member: Member, body: SimLinkBody): Promise {
return link.id
}
-async function listedNames(member: Member, search?: string, order?: string): Promise {
- const params = [search && `search=${encodeURIComponent(search)}`, order].filter(Boolean)
- const query = params.length ? `?${params.join('&')}` : ''
- const { links } = await api(member, query, {})
+async function listedNames(member: Member, params: Record = {}): Promise {
+ const query = new URLSearchParams(params).toString()
+ const { links } = await api(member, query ? `?${query}` : '', {})
assert(Array.isArray(links), 'Expected a link list')
return links.map((link) => (isRecordLike(link) ? String(link.name) : ''))
}
@@ -285,8 +284,8 @@ async function run() {
})
await check('search matches names regardless of punctuation and the sim/ prefix', async () => {
- assert.deepEqual(await listedNames(member, 'sim/teamhand'), ['Team-Handbook'])
- assert.deepEqual(await listedNames(member, 'everything lives'), ['Team-Handbook'])
+ assert.deepEqual(await listedNames(member, { search: 'sim/teamhand' }), ['Team-Handbook'])
+ assert.deepEqual(await listedNames(member, { search: 'everything lives' }), ['Team-Handbook'])
})
await check('restricted links refuse edits from other members', async () => {
@@ -345,12 +344,10 @@ async function run() {
assert.equal(visits.get('jira'), 4, 'Each jira redirect counts once')
assert.equal(visits.get('gh'), 0, 'A link opened without its values is not a visit')
await api(member, '?sort=popular&cursor=jira', { expected: 400 })
- const descending = await listedNames(member, undefined, 'sort=name&dir=desc')
- const sortKey = (name: string) => name.toLowerCase().replace(/[.:@&~_-]/g, '')
assert.deepEqual(
- descending.map(sortKey),
- descending.map(sortKey).sort().reverse(),
- 'Expected Z to A by the matching key'
+ await listedNames(member, { sort: 'name', dir: 'desc' }),
+ [...(await listedNames(member))].reverse(),
+ 'Expected Z to A to be the default A to Z order reversed'
)
const { links: leastUsed } = await api(member, '?sort=popular&dir=asc', {})
assert(Array.isArray(leastUsed), 'Expected a link list')