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
6 changes: 3 additions & 3 deletions apps/docs/content/docs/search/sim-links.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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**.
Expand All @@ -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).

Expand Down
16 changes: 16 additions & 0 deletions apps/sim/app/o/[organizationId]/links/columns.ts
Original file line number Diff line number Diff line change
@@ -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
30 changes: 9 additions & 21 deletions apps/sim/app/o/[organizationId]/links/loading.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ResourceChromeFallback
icon={Link}
title='Sim Links'
columns={COLUMNS}
actions={ACTIONS}
searchPlaceholder='Search Sim links...'
columns={SIM_LINK_COLUMNS}
actions={[NEW_SIM_LINK_ACTION]}
searchPlaceholder={SIM_LINKS_SEARCH_PLACEHOLDER}
hasSort
/>
)
Expand Down
12 changes: 7 additions & 5 deletions apps/sim/app/o/[organizationId]/links/sim-link-context-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,14 @@ export function SimLinkContextMenu({
Edit
</DropdownMenuItem>
)}
{onDelete && <DropdownMenuSeparator />}
{onDelete && (
<DropdownMenuItem onSelect={onDelete}>
<Trash />
Delete
</DropdownMenuItem>
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onDelete}>
<Trash />
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/app/o/[organizationId]/links/sim-link-icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
/>
Expand Down
26 changes: 13 additions & 13 deletions apps/sim/app/o/[organizationId]/links/sim-link-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 && (
<ChipModalField
Expand All @@ -117,7 +119,7 @@ export function SimLinkModal({ organizationId, onClose, link, initialName }: Sim
value={draft.defaultUrl ?? ''}
onChange={(value) => 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}.`}
/>
)}
<ChipModalField
Expand All @@ -128,9 +130,8 @@ export function SimLinkModal({ organizationId, onClose, link, initialName }: Sim
placeholder='handbook'
maxLength={SIM_LINK_NAME_MAX_LENGTH}
value={draft.name}
onChange={(value) => 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.'
/>
<ChipModalField
type='input'
Expand All @@ -146,9 +147,7 @@ export function SimLinkModal({ organizationId, onClose, link, initialName }: Sim
type='custom'
title='Access'
hint={
canManage
? 'Anyone in your organization can open this link.'
: 'Only the creator or an organization admin can change access.'
canManage ? undefined : 'Only the creator or an organization admin can change access.'
}
>
{(aria) => (
Expand All @@ -162,7 +161,7 @@ export function SimLinkModal({ organizationId, onClose, link, initialName }: Sim
onCheckedChange={(checked) => change('unlisted', checked === true)}
/>
<span className='text-[var(--text-primary)] text-small'>
Hide from the Sim Links page for everyone except you and admins
Hide from the Sim Links page
</span>
</label>
<label htmlFor={restrictEditId} className='flex cursor-pointer items-center gap-2'>
Expand All @@ -174,7 +173,7 @@ export function SimLinkModal({ organizationId, onClose, link, initialName }: Sim
onCheckedChange={(checked) => change('restrictEdit', checked === true)}
/>
<span className='text-[var(--text-primary)] text-small'>
Restrict editing to you and admins
Only you and admins can edit
</span>
</label>
</div>
Expand All @@ -184,6 +183,7 @@ export function SimLinkModal({ organizationId, onClose, link, initialName }: Sim
</ChipModalBody>
<ChipModalFooter
onCancel={onClose}
secondaryActions={[{ label: 'Learn more', onClick: openDocs }]}
primaryAction={{
label: mutation.isPending ? pendingLabel : actionLabel,
disabled: mutation.isPending,
Expand Down
42 changes: 18 additions & 24 deletions apps/sim/app/o/[organizationId]/links/sim-links.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@

import { useMemo, useState } from 'react'
import { Avatar, Chip, ChipConfirmModal, ChipModalError, writeTextToClipboard } from '@sim/emcn'
import { Link, Plus } from '@sim/emcn/icons'
import { Link } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { useQueryStates } from 'nuqs'
import type { SimLink } from '@/lib/api/contracts/sim-links'
import { simLinkPath } from '@/lib/navigation/paths'
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
import {
NEW_SIM_LINK_ACTION,
SIM_LINK_COLUMNS,
SIM_LINKS_SEARCH_PLACEHOLDER,
} from '@/app/o/[organizationId]/links/columns'
import SimLinksLoading from '@/app/o/[organizationId]/links/loading'
import {
simLinksParsers,
Expand All @@ -28,7 +33,6 @@ import type {
import { timeCell } from '@/app/workspace/[workspaceId]/components/resource/components/time-cell'
import {
Resource,
type ResourceColumn,
type ResourceRow,
} from '@/app/workspace/[workspaceId]/components/resource/resource'
import { useDeleteSimLink, useSimLinks } from '@/hooks/queries/sim-links'
Expand All @@ -39,15 +43,6 @@ import { useUrlSort } from '@/hooks/use-url-sort'

const logger = createLogger('SimLinks')

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 SORT_OPTIONS = [
{ id: 'name', label: 'Name' },
{ id: 'popular', label: 'Opens' },
Expand Down Expand Up @@ -83,15 +78,18 @@ export function SimLinks({ organizationId }: SimLinksProps) {
)
const debouncedSearch = useSearchFilterValue(search, SEARCH_DEBOUNCE_MS)
const { sort, dir, activeSort, onSort, onClear } = useUrlSort(simLinksSortParams, simLinksUrlKeys)
const links = useSimLinks(organizationId, debouncedSearch.trim(), sort, dir)
const query = debouncedSearch.trim()
const links = useSimLinks(organizationId, query, sort, dir)
const remove = useDeleteSimLink(organizationId)
const contextMenu = useContextMenu()
const [menuLink, setMenuLink] = useState<SimLink | null>(null)
const [menuLinkId, setMenuLinkId] = useState<string | null>(null)
const [editing, setEditing] = useState<SimLink | null>(null)
const [deleting, setDeleting] = useState<SimLink | null>(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
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
if (menuLinkId && !menuLink) setMenuLinkId(null)

const rows: ResourceRow[] = useMemo(
() =>
Expand Down Expand Up @@ -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 <SimLinksLoading />
Expand All @@ -138,22 +135,19 @@ export function SimLinks({ organizationId }: SimLinksProps) {
<Resource.Header
icon={Link}
title='Sim Links'
actions={[
{ id: 'create', text: 'New link', icon: Plus, variant: 'primary', onSelect: startCreate },
]}
actions={[{ ...NEW_SIM_LINK_ACTION, id: 'create', onSelect: startCreate }]}
/>
<Resource.Options search={searchConfig} sort={sortConfig} />
<Resource.Table
columns={COLUMNS}
columns={SIM_LINK_COLUMNS}
rows={rows}
onRowClick={(id) => {
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()}
Expand All @@ -167,9 +161,9 @@ export function SimLinks({ organizationId }: SimLinksProps) {
<p className='text-[var(--text-error)] text-small'>{links.error.message}</p>
<Chip onClick={() => void links.refetch()}>Retry</Chip>
</div>
) : searching ? (
) : query ? (
<ResourceNoResults
search={debouncedSearch}
search={query}
filterCount={0}
searchScope='Searched every Sim link in your organization.'
onClear={() => setSearch('')}
Expand Down
8 changes: 2 additions & 6 deletions apps/sim/lib/sim-links/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand All @@ -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))
Expand Down
12 changes: 12 additions & 0 deletions apps/sim/lib/sim-links/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Expand Down
Loading
Loading