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')