From 567cfa2889aa1936bb57884887080c66651ec88f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 18:20:57 -0700 Subject: [PATCH 1/7] improvement(sim-links): use the shared resource table layout --- apps/docs/content/docs/search/sim-links.mdx | 4 +- .../components/organization-page/index.ts | 5 +- .../organization-page/organization-page.tsx | 2 +- .../app/o/[organizationId]/links/loading.tsx | 21 +- .../o/[organizationId]/links/search-params.ts | 16 + .../links/sim-link-context-menu.tsx | 80 +++++ .../o/[organizationId]/links/sim-links.tsx | 304 ++++++++++-------- apps/sim/hooks/queries/sim-links.ts | 16 +- apps/sim/lib/api/contracts/sim-links.ts | 2 + .../lib/sim-links/application/use-cases.ts | 8 +- apps/sim/lib/sim-links/repository.ts | 27 +- apps/sim/scripts/test-sim-links-e2e.ts | 20 +- 12 files changed, 328 insertions(+), 177 deletions(-) create mode 100644 apps/sim/app/o/[organizationId]/links/sim-link-context-menu.tsx diff --git a/apps/docs/content/docs/search/sim-links.mdx b/apps/docs/content/docs/search/sim-links.mdx index d5682e028de..12870b31350 100644 --- a/apps/docs/content/docs/search/sim-links.mdx +++ b/apps/docs/content/docs/search/sim-links.mdx @@ -5,7 +5,7 @@ description: Short sim/ links that take everyone in your organization to the rig Sim Links are short, memorable names for the pages your team opens often, like `sim/handbook` for the employee handbook or `sim/okta` for your sign-in portal. Anyone in the organization can open them, with or without a Search seat. They work like go links in other tools. -Open **Sim Links** in the organization sidebar to see every link in your organization, with who created it, how often it's opened, and when it last changed. Search by name, destination, or description, and select **Most used** to see the 50 links your team opens most. +Open **Sim Links** in the organization sidebar to see every link in your organization, with who created it, how often it's opened, and when it last changed. Search by name, destination, or description, and use **Sort → Opens** to see the 50 links your team opens most. ## Open a link @@ -35,7 +35,7 @@ Chrome administrators can add this shortcut for everyone with the `SiteSearchSet 4. Optionally add a **Description** so teammates know what the link is for. 5. Select **Create**. -Select **Copy link** from a link's menu to share it. +Right-click a link and select **Copy link** to share it. ## Add values to a link diff --git a/apps/sim/app/o/[organizationId]/components/organization-page/index.ts b/apps/sim/app/o/[organizationId]/components/organization-page/index.ts index 5f218f39dc1..eac6781b000 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-page/index.ts +++ b/apps/sim/app/o/[organizationId]/components/organization-page/index.ts @@ -1,6 +1,3 @@ export { PAGE_COLUMN_CLASS } from '@/app/o/[organizationId]/components/organization-page/constants' -export { - OrganizationPage, - type OrganizationPageTab, -} from '@/app/o/[organizationId]/components/organization-page/organization-page' +export { OrganizationPage } from '@/app/o/[organizationId]/components/organization-page/organization-page' export { useOrganizationPageFilters } from '@/app/o/[organizationId]/components/organization-page/use-organization-page-filters' diff --git a/apps/sim/app/o/[organizationId]/components/organization-page/organization-page.tsx b/apps/sim/app/o/[organizationId]/components/organization-page/organization-page.tsx index 98ba520b3fd..0cd6a8e495d 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-page/organization-page.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-page/organization-page.tsx @@ -20,7 +20,7 @@ import { SIDEBAR_DIVIDER_PAD_BELOW_CLASS, } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' -export interface OrganizationPageTab { +interface OrganizationPageTab { id: string label: string } diff --git a/apps/sim/app/o/[organizationId]/links/loading.tsx b/apps/sim/app/o/[organizationId]/links/loading.tsx index dfe90f79bbe..69bdb115fff 100644 --- a/apps/sim/app/o/[organizationId]/links/loading.tsx +++ b/apps/sim/app/o/[organizationId]/links/loading.tsx @@ -1,19 +1,8 @@ -import { cn } from '@sim/emcn' -import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar' -import { PAGE_COLUMN_CLASS } from '@/app/o/[organizationId]/components/organization-page' +'use client' + +import { Link } from '@sim/emcn/icons' +import { ResourceChromeFallback } from '@/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback' export default function SimLinksLoading() { - return ( -
-
-
-
-
-

Sim Links

-

- Loading Sim links -

-
-
- ) + return } diff --git a/apps/sim/app/o/[organizationId]/links/search-params.ts b/apps/sim/app/o/[organizationId]/links/search-params.ts index 49717c6240c..740400f1e17 100644 --- a/apps/sim/app/o/[organizationId]/links/search-params.ts +++ b/apps/sim/app/o/[organizationId]/links/search-params.ts @@ -1,6 +1,22 @@ import { createSerializer, parseAsString } from 'nuqs/server' +import { SIM_LINK_SORTS } from '@/lib/sim-links/validation' +import { createSortParams } from '@/lib/url-state' /** `create` opens the new-link editor, prefilled with its value as the name. */ export const simLinkViewParsers = { create: parseAsString } as const export const serializeSimLinkView = createSerializer(simLinkViewParsers) + +/** `search` filters by name, destination, or description; only its URL write is debounced. */ +export const simLinksParsers = { + ...simLinkViewParsers, + search: parseAsString.withDefault(''), +} as const + +/** Shared `sort` + `dir` params for the Sim Links list. Default: alphabetical. */ +export const simLinksSortParams = createSortParams(SIM_LINK_SORTS, { + column: 'name', + direction: 'asc', +}) + +export const simLinksUrlKeys = { history: 'replace', clearOnDefault: true } as const 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 new file mode 100644 index 00000000000..514ffddd1d1 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/links/sim-link-context-menu.tsx @@ -0,0 +1,80 @@ +'use client' + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@sim/emcn' +import { ArrowUpRight, Duplicate, Pencil, Trash } from '@sim/emcn/icons' + +interface SimLinkContextMenuProps { + isOpen: boolean + position: { x: number; y: number } + onClose: () => void + onOpen: () => void + onCopyLink: () => void + /** Present only when the viewer may edit the link. */ + onEdit?: () => void + /** Present only when the viewer may delete the link. */ + onDelete?: () => void +} + +/** Row actions for one Sim link, anchored where the row was right-clicked. */ +export function SimLinkContextMenu({ + isOpen, + position, + onClose, + onOpen, + onCopyLink, + onEdit, + onDelete, +}: SimLinkContextMenuProps) { + return ( + !open && onClose()} modal={false}> + +
+ + e.preventDefault()} + > + + + Open + + {onEdit && ( + + + Edit + + )} + + + Copy link + + {onDelete && } + {onDelete && ( + + + Delete + + )} + + + ) +} diff --git a/apps/sim/app/o/[organizationId]/links/sim-links.tsx b/apps/sim/app/o/[organizationId]/links/sim-links.tsx index 5420c3497dc..37e6f403a5e 100644 --- a/apps/sim/app/o/[organizationId]/links/sim-links.tsx +++ b/apps/sim/app/o/[organizationId]/links/sim-links.tsx @@ -1,174 +1,202 @@ 'use client' -import { useState } from 'react' -import { - Chip, - ChipConfirmModal, - ChipModalError, - ChipTag, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, - writeTextToClipboard, -} from '@sim/emcn' -import { MoreHorizontal, Plus } from '@sim/emcn/icons' +import { useMemo, useState } from 'react' +import { Avatar, Chip, ChipConfirmModal, ChipModalError, writeTextToClipboard } from '@sim/emcn' +import { Link, Plus } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' -import { formatRelativeTime } from '@sim/utils/formatting' import { useQueryStates } from 'nuqs' +import { EmptyState } from '@/components/empty-state/empty-state' import type { SimLink } from '@/lib/api/contracts/sim-links' import { simLinkPath } from '@/lib/navigation/paths' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { - OrganizationPage, - type OrganizationPageTab, - useOrganizationPageFilters, -} from '@/app/o/[organizationId]/components/organization-page' -import { simLinkViewParsers } from '@/app/o/[organizationId]/links/search-params' + simLinksParsers, + simLinksSortParams, + simLinksUrlKeys, +} from '@/app/o/[organizationId]/links/search-params' +import { SimLinkContextMenu } from '@/app/o/[organizationId]/links/sim-link-context-menu' import { SimLinkIcon } from '@/app/o/[organizationId]/links/sim-link-icon' import { SimLinkModal } from '@/app/o/[organizationId]/links/sim-link-modal' import { SimLinksEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' +import type { + SearchConfig, + SortConfig, +} from '@/app/workspace/[workspaceId]/components/resource/components/resource-options' +import { timeCell } from '@/app/workspace/[workspaceId]/components/resource/components/time-cell' import { - SettingsEmptyState, - SettingsQueryErrorState, -} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' -import { SettingsListPagination } from '@/app/workspace/[workspaceId]/settings/components/settings-list-pagination' -import { - RESOURCE_LIST_STACK, - SettingsResourceRow, -} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' + Resource, + type ResourceColumn, + type ResourceRow, +} from '@/app/workspace/[workspaceId]/components/resource/resource' import { useDeleteSimLink, useSimLinks } from '@/hooks/queries/sim-links' -import { useDebounce } from '@/hooks/use-debounce' +import { useContextMenu } from '@/hooks/use-context-menu' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' +import { useSearchFilterValue } from '@/hooks/use-search-filter-value' +import { useUrlSort } from '@/hooks/use-url-sort' const logger = createLogger('SimLinks') -const SORT_TABS = [ - { id: 'name', label: 'A–Z' }, - { id: 'popular', label: 'Most used' }, -] as const satisfies readonly OrganizationPageTab[] +const COLUMNS: ResourceColumn[] = [ + { id: 'name', header: 'Name', widthMultiplier: 0.7 }, + { id: 'destination', header: 'Destination', widthMultiplier: 1.6 }, + { id: 'description', header: 'Description', widthMultiplier: 1.4 }, + { id: 'owner', header: 'Created by' }, + { id: 'opens', header: 'Opens', widthMultiplier: 0.7 }, + { id: 'access', header: 'Access' }, + { id: 'updated', header: 'Last updated' }, +] -async function copyLink(path: string) { - try { - await writeTextToClipboard(new URL(path, window.location.origin).href) - } catch (error) { - logger.error('Failed to copy Sim link', { error }) - } +const SORT_OPTIONS = [ + { id: 'name', label: 'Name' }, + { id: 'popular', label: 'Opens' }, +] + +/** How the link is shared beyond the default: open to everyone and editable by any member. */ +function accessLabel(link: SimLink): string | null { + const limits = [link.unlisted && 'Unlisted', link.restrictEdit && 'Restricted'].filter(Boolean) + return limits.length ? limits.join(', ') : null } -interface SimLinkDetailsProps { - link: SimLink +function openLink(name: string) { + window.open(simLinkPath(name), '_blank', 'noopener,noreferrer') } -/** Who made the link, how often it is used, when it last changed, and how it is shared. */ -function SimLinkDetails({ link }: SimLinkDetailsProps) { - const details = [ - link.createdBy?.name, - link.visitCount > 0 && `${link.visitCount} ${link.visitCount === 1 ? 'open' : 'opens'}`, - formatRelativeTime(link.updatedAt), - ].filter(Boolean) - return ( - <> - - {details.join(' · ')} - - {link.unlisted && Unlisted} - {link.restrictEdit && Restricted} - - ) +async function copyLink(name: string) { + try { + await writeTextToClipboard(new URL(simLinkPath(name), window.location.origin).href) + } catch (error) { + logger.error('Failed to copy Sim link', { error }) + } } interface SimLinksProps { organizationId: string } -/** Every Sim link the viewer may browse, with create, edit, and delete in place. */ +/** Every Sim link the viewer may browse, in the shared resource table. */ export function SimLinks({ organizationId }: SimLinksProps) { - const { search, tab } = useOrganizationPageFilters() - const query = useDebounce(search.trim(), SEARCH_DEBOUNCE_MS) - const sort = tab === 'popular' ? 'popular' : 'name' - const links = useSimLinks(organizationId, query, sort) + const [{ search, create }, setView] = useQueryStates(simLinksParsers, simLinksUrlKeys) + const setSearch = useDebouncedSearchSetter((value, options) => + setView({ search: value }, options) + ) + 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 remove = useDeleteSimLink(organizationId) - const [{ create }, setView] = useQueryStates(simLinkViewParsers) + const contextMenu = useContextMenu() + const [menuLink, setMenuLink] = useState(null) const [editing, setEditing] = useState(null) const [deleting, setDeleting] = useState(null) - const rows = links.data?.pages.flatMap((page) => page.links) ?? [] + + 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 rows: ResourceRow[] = useMemo( + () => + loaded.map((link) => ({ + id: link.id, + cells: { + name: { icon: , label: `sim/${link.name}` }, + destination: { label: link.url }, + description: { label: link.description }, + owner: link.createdBy + ? { + icon: ( + + ), + label: link.createdBy.name, + } + : { label: null }, + opens: { label: String(link.visitCount) }, + access: { label: accessLabel(link) }, + updated: timeCell(link.updatedAt), + }, + })), + [loaded] + ) + + const searchConfig: SearchConfig = { + value: search, + onChange: setSearch, + onClearAll: () => setSearch(''), + placeholder: 'Search Sim links...', + } + const sortConfig: SortConfig = { options: SORT_OPTIONS, active: activeSort, onSort, onClear } + const searching = Boolean(debouncedSearch.trim()) + const startCreate = () => void setView({ create: '' }) return ( - void setView({ create: '' })}> - New link - - } - > -
- {links.isLoadingError ? ( - void links.refetch()} - variant='inline' - /> - ) : !links.data ? ( - Loading Sim links - ) : !rows.length && query ? ( - No matching Sim links - ) : !rows.length ? ( - void setView({ create: '' })} /> - ) : ( - rows.map((link) => { - const path = simLinkPath(link.name) - return ( - } - title={`sim/${link.name}`} - description={link.description ? `${link.description} · ${link.url}` : link.url} - badge={} - onClick={() => window.open(path, '_blank', 'noopener,noreferrer')} - clickLabel={`Open sim/${link.name}`} - trailing={ - - - - - - void copyLink(path)}> - Copy link - - {link.canEdit && ( - <> - setEditing(link)}> - Edit - - - { - remove.reset() - setDeleting(link) - }} - > - Delete - - - )} - - + + + + { + const link = linksById.get(id) + if (link) openLink(link.name) + }} + onRowContextMenu={(event, id) => { + const link = linksById.get(id) + if (!link) return + setMenuLink(link) + contextMenu.handleContextMenu(event) + }} + onLoadMore={() => void links.fetchNextPage()} + hasMore={links.hasNextPage} + isLoadingMore={links.isFetchingNextPage} + isFetching={links.isFetching} + loadMoreFailed={links.isFetchNextPageError} + emptyState={ + links.isLoadingError ? ( +
+

{links.error.message}

+ void links.refetch()}>Retry +
+ ) : !links.data ? undefined : searching ? ( + setSearch('')}> + Clear search + + } + /> + ) : ( + + ) + } + /> + {menuLink && ( + openLink(menuLink.name)} + onCopyLink={() => void copyLink(menuLink.name)} + onEdit={menuLink.canEdit ? () => setEditing(menuLink) : undefined} + onDelete={ + menuLink.canEdit + ? () => { + remove.reset() + setDeleting(menuLink) } - /> - ) - }) - )} - -
+ : undefined + } + /> + )} {create !== null && ( {remove.error?.message} -
+ ) } diff --git a/apps/sim/hooks/queries/sim-links.ts b/apps/sim/hooks/queries/sim-links.ts index 9d0f97641c1..5501bcc8d5a 100644 --- a/apps/sim/hooks/queries/sim-links.ts +++ b/apps/sim/hooks/queries/sim-links.ts @@ -13,24 +13,30 @@ import { updateSimLinkContract, } from '@/lib/api/contracts/sim-links' import type { SimLinkSort } from '@/lib/sim-links/validation' +import type { SortDirection } from '@/lib/url-state' const SIM_LINKS_STALE_TIME = 30 * 1000 const simLinkKeys = { all: ['sim-links'] as const, lists: (organizationId: string) => [...simLinkKeys.all, 'list', organizationId] as const, - list: (organizationId: string, search: string, sort: SimLinkSort) => - [...simLinkKeys.lists(organizationId), search, sort] as const, + list: (organizationId: string, search: string, sort: SimLinkSort, dir: SortDirection) => + [...simLinkKeys.lists(organizationId), search, sort, dir] as const, } -export function useSimLinks(organizationId: string, search: string, sort: SimLinkSort) { +export function useSimLinks( + organizationId: string, + search: string, + sort: SimLinkSort, + dir: SortDirection +) { return useInfiniteQuery({ - queryKey: simLinkKeys.list(organizationId, search, sort), + queryKey: simLinkKeys.list(organizationId, search, sort, dir), initialPageParam: undefined as string | undefined, queryFn: ({ signal, pageParam }) => requestJson(listSimLinksContract, { params: { id: organizationId }, - query: { search: search || undefined, cursor: pageParam, sort }, + query: { search: search || undefined, cursor: pageParam, sort, dir }, signal, }), getNextPageParam: (page) => page.nextCursor ?? undefined, diff --git a/apps/sim/lib/api/contracts/sim-links.ts b/apps/sim/lib/api/contracts/sim-links.ts index ab71a288387..c7d37aef390 100644 --- a/apps/sim/lib/api/contracts/sim-links.ts +++ b/apps/sim/lib/api/contracts/sim-links.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { defineRouteContract } from '@/lib/api/contracts' import { organizationIdSchema } from '@/lib/api/contracts/primitives' import { SIM_LINK_SORTS, simLinkFieldsSchema } from '@/lib/sim-links/validation' +import { SORT_DIRECTIONS } from '@/lib/url-state' const params = z.object({ id: organizationIdSchema }) const linkParams = params.extend({ linkId: z.string().min(1).max(255) }) @@ -39,6 +40,7 @@ export const listSimLinksContract = defineRouteContract({ search: z.string().max(200).optional(), cursor: z.string().min(1).max(128).optional(), sort: z.enum(SIM_LINK_SORTS).optional(), + dir: z.enum(SORT_DIRECTIONS).optional(), }) .refine((query) => !(query.sort === 'popular' && query.cursor), { path: ['cursor'], diff --git a/apps/sim/lib/sim-links/application/use-cases.ts b/apps/sim/lib/sim-links/application/use-cases.ts index d3eee1a68af..9266bb40d94 100644 --- a/apps/sim/lib/sim-links/application/use-cases.ts +++ b/apps/sim/lib/sim-links/application/use-cases.ts @@ -17,6 +17,7 @@ import { requireSimLinksEnabled } from '@/lib/sim-links/feature-flag' import type { SimLinkAccess, SimLinkRecord, SimLinkViewer } from '@/lib/sim-links/repository' import * as repository from '@/lib/sim-links/repository' import type { SimLinkFields, SimLinkSort } from '@/lib/sim-links/validation' +import type { SortDirection } from '@/lib/url-state' const logger = createLogger('SimLinks') @@ -139,7 +140,12 @@ export const listSimLinks = defineAuthorizedOrganizationUseCase({ input, context, }: OrganizationUseCaseContext< - OrganizationInput & { search?: string; cursor?: string; sort?: SimLinkSort } + OrganizationInput & { + search?: string + cursor?: string + sort?: SimLinkSort + dir?: SortDirection + } >) { const viewer = viewerOf(context) const page = await repository.listSimLinks(context.organizationId, viewer, input) diff --git a/apps/sim/lib/sim-links/repository.ts b/apps/sim/lib/sim-links/repository.ts index 7beeb631a57..13540051adb 100644 --- a/apps/sim/lib/sim-links/repository.ts +++ b/apps/sim/lib/sim-links/repository.ts @@ -2,13 +2,14 @@ import { db } from '@sim/db' import { simLink, user } from '@sim/db/schema' import { generateId } from '@sim/utils/id' import { escapeLikePattern } from '@sim/utils/string' -import { and, asc, desc, eq, gt, ilike, or, type SQL, sql } from 'drizzle-orm' +import { and, asc, desc, eq, gt, ilike, lt, or, type SQL, sql } from 'drizzle-orm' import type { DbOrTx, DbTransaction } from '@/lib/db/types' import { normalizeSimLinkName, type SimLinkFields, type SimLinkSort, } from '@/lib/sim-links/validation' +import type { SortDirection } from '@/lib/url-state' const PAGE_SIZE = 50 @@ -72,15 +73,21 @@ function toRecord({ } /** - * The links `viewer` may browse: pages by name, or the most visited as a single page, since a - * cursor over live visit counts would skip links that gain visits between pages. Unlisted links - * are listed only to their creator and organization admins. + * The links `viewer` may browse: pages by name in either direction, or by visits as a single + * page, since a cursor over live visit counts would skip links that gain visits between pages. + * Unlisted links are listed only to their creator and organization admins. */ export async function listSimLinks( organizationId: string, viewer: SimLinkViewer, - { search, cursor, sort = 'name' }: { search?: string; cursor?: string; sort?: SimLinkSort } + { + search, + cursor, + sort = 'name', + 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 conditions: (SQL | undefined)[] = [eq(simLink.organizationId, organizationId)] if (!viewer.isAdmin) { conditions.push(or(eq(simLink.unlisted, false), eq(simLink.createdBy, viewer.userId))) @@ -100,14 +107,18 @@ export async function listSimLinks( if (sort === 'popular') { const rows = await selectRecords(db) .where(and(...conditions)) - .orderBy(desc(simLink.visitCount), asc(simLink.normalizedName)) + .orderBy(order(simLink.visitCount), asc(simLink.normalizedName)) .limit(PAGE_SIZE) return { links: rows.map(toRecord), nextCursor: null } } - if (cursor) conditions.push(gt(simLink.normalizedName, cursor)) + if (cursor) { + conditions.push( + dir === 'asc' ? gt(simLink.normalizedName, cursor) : lt(simLink.normalizedName, cursor) + ) + } const rows = await selectRecords(db) .where(and(...conditions)) - .orderBy(asc(simLink.normalizedName)) + .orderBy(order(simLink.normalizedName)) .limit(PAGE_SIZE + 1) const links = rows.slice(0, PAGE_SIZE).map(toRecord) return { diff --git a/apps/sim/scripts/test-sim-links-e2e.ts b/apps/sim/scripts/test-sim-links-e2e.ts index 0ce9eb6d163..45201fac767 100644 --- a/apps/sim/scripts/test-sim-links-e2e.ts +++ b/apps/sim/scripts/test-sim-links-e2e.ts @@ -103,8 +103,9 @@ async function create(member: Member, body: SimLinkBody): Promise { return link.id } -async function listedNames(member: Member, search?: string): Promise { - const query = search ? `?search=${encodeURIComponent(search)}` : '' +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, {}) assert(Array.isArray(links), 'Expected a link list') return links.map((link) => (isRecordLike(link) ? String(link.name) : '')) @@ -344,6 +345,21 @@ 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' + ) + const { links: leastUsed } = await api(member, '?sort=popular&dir=asc', {}) + assert(Array.isArray(leastUsed), 'Expected a link list') + const ascending = leastUsed.map((link) => (isRecordLike(link) ? Number(link.visitCount) : -1)) + assert.deepEqual( + ascending, + [...ascending].sort((a, b) => a - b), + 'Expected least visited first' + ) }) await check('deleting a link stops it resolving and every change is audited', async () => { From 81aa75b75602d925700609ff1cf20656a1be5d26 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 18:24:26 -0700 Subject: [PATCH 2/7] improvement(sim-links): match sibling resource pages (loading chrome, shared no-results, menu order, columns) --- .../app/o/[organizationId]/links/loading.tsx | 30 +++++++++++++++++-- .../links/sim-link-context-menu.tsx | 8 ++--- .../o/[organizationId]/links/sim-links.tsx | 25 ++++++++-------- .../resource-no-results.tsx | 13 +++++--- 4 files changed, 52 insertions(+), 24 deletions(-) diff --git a/apps/sim/app/o/[organizationId]/links/loading.tsx b/apps/sim/app/o/[organizationId]/links/loading.tsx index 69bdb115fff..c5076c55cd1 100644 --- a/apps/sim/app/o/[organizationId]/links/loading.tsx +++ b/apps/sim/app/o/[organizationId]/links/loading.tsx @@ -1,8 +1,32 @@ 'use client' -import { Link } from '@sim/emcn/icons' -import { ResourceChromeFallback } from '@/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback' +import { Link, Plus } from '@sim/emcn/icons' +import { + type ChromeActionSpec, + ResourceChromeFallback, +} from '@/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback' + +const COLUMNS = [ + { id: 'name', header: 'Name' }, + { id: 'destination', header: 'Destination', widthMultiplier: 1.6 }, + { id: 'description', header: 'Description', widthMultiplier: 1.4 }, + { id: 'owner', header: 'Created by' }, + { id: 'opens', header: 'Opens' }, + { id: 'access', header: 'Access' }, + { id: 'updated', header: 'Updated' }, +] + +const ACTIONS: ChromeActionSpec[] = [{ text: 'New link', icon: Plus, variant: 'primary' }] export default function SimLinksLoading() { - return + 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 514ffddd1d1..afc1e8c15c4 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 @@ -57,16 +57,16 @@ export function SimLinkContextMenu({ Open + + + Copy link + {onEdit && ( Edit )} - - - Copy link - {onDelete && } {onDelete && ( diff --git a/apps/sim/app/o/[organizationId]/links/sim-links.tsx b/apps/sim/app/o/[organizationId]/links/sim-links.tsx index 37e6f403a5e..98b12c740cb 100644 --- a/apps/sim/app/o/[organizationId]/links/sim-links.tsx +++ b/apps/sim/app/o/[organizationId]/links/sim-links.tsx @@ -5,7 +5,6 @@ import { Avatar, Chip, ChipConfirmModal, ChipModalError, writeTextToClipboard } import { Link, Plus } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { useQueryStates } from 'nuqs' -import { EmptyState } from '@/components/empty-state/empty-state' import type { SimLink } from '@/lib/api/contracts/sim-links' import { simLinkPath } from '@/lib/navigation/paths' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' @@ -17,7 +16,10 @@ import { import { SimLinkContextMenu } from '@/app/o/[organizationId]/links/sim-link-context-menu' import { SimLinkIcon } from '@/app/o/[organizationId]/links/sim-link-icon' import { SimLinkModal } from '@/app/o/[organizationId]/links/sim-link-modal' -import { SimLinksEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' +import { + ResourceNoResults, + SimLinksEmptyState, +} from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' import type { SearchConfig, SortConfig, @@ -37,13 +39,13 @@ import { useUrlSort } from '@/hooks/use-url-sort' const logger = createLogger('SimLinks') const COLUMNS: ResourceColumn[] = [ - { id: 'name', header: 'Name', widthMultiplier: 0.7 }, + { id: 'name', header: 'Name' }, { id: 'destination', header: 'Destination', widthMultiplier: 1.6 }, { id: 'description', header: 'Description', widthMultiplier: 1.4 }, { id: 'owner', header: 'Created by' }, - { id: 'opens', header: 'Opens', widthMultiplier: 0.7 }, + { id: 'opens', header: 'Opens' }, { id: 'access', header: 'Access' }, - { id: 'updated', header: 'Last updated' }, + { id: 'updated', header: 'Updated' }, ] const SORT_OPTIONS = [ @@ -165,14 +167,11 @@ export function SimLinks({ organizationId }: SimLinksProps) { void links.refetch()}>Retry
) : !links.data ? undefined : searching ? ( - setSearch('')}> - Clear search - - } + setSearch('')} /> ) : ( diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-no-results.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-no-results.tsx index 650be72ed63..8c29cacd4c5 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-no-results.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-no-results.tsx @@ -12,6 +12,8 @@ interface ResourceNoResultsProps { filterCount: number /** Clears the query and every filter, restoring the open folder's contents. */ onClear: () => void + /** Where a search looked, for a list that isn't foldered within a workspace. */ + searchScope?: string } /** @@ -27,14 +29,17 @@ interface ResourceNoResultsProps { * while filters narrow only the open folder. The search branch wins when both are set, * because the wider scope is the more surprising of the two. */ -export function ResourceNoResults({ search, filterCount, onClear }: ResourceNoResultsProps) { +export function ResourceNoResults({ + search, + filterCount, + onClear, + searchScope = 'Searched every folder in this workspace.', +}: ResourceNoResultsProps) { const trimmed = search.trim() return ( {trimmed && filterCount > 0 From 805924e7ed1e190040f2dad108be3822e9ec6a8c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 18:25:16 -0700 Subject: [PATCH 3/7] improvement(sim-links): const sort options; sort menu accepts readonly options --- apps/sim/app/o/[organizationId]/links/sim-links.tsx | 2 +- .../resource/components/resource-options/resource-options.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/o/[organizationId]/links/sim-links.tsx b/apps/sim/app/o/[organizationId]/links/sim-links.tsx index 98b12c740cb..77af4af27da 100644 --- a/apps/sim/app/o/[organizationId]/links/sim-links.tsx +++ b/apps/sim/app/o/[organizationId]/links/sim-links.tsx @@ -51,7 +51,7 @@ const COLUMNS: ResourceColumn[] = [ const SORT_OPTIONS = [ { id: 'name', label: 'Name' }, { id: 'popular', label: 'Opens' }, -] +] as const /** How the link is shared beyond the default: open to everyone and editable by any member. */ function accessLabel(link: SimLink): string | null { diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options.tsx index 85c62bca02e..56c8100374a 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options/resource-options.tsx @@ -44,7 +44,7 @@ export interface ColumnOption { } export interface SortConfig { - options: ColumnOption[] + options: readonly ColumnOption[] active: { column: string; direction: SortDirection } | null onSort: (column: string, direction: SortDirection) => void onClear?: () => void From 05f8acc240b1772d8b03e070054e845f02b84b7a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 18:33:16 -0700 Subject: [PATCH 4/7] improvement(sim-links): drop the Opens column and give Destination the space --- apps/docs/content/docs/search/sim-links.mdx | 2 +- apps/sim/app/o/[organizationId]/links/loading.tsx | 3 +-- apps/sim/app/o/[organizationId]/links/sim-links.tsx | 4 +--- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/docs/content/docs/search/sim-links.mdx b/apps/docs/content/docs/search/sim-links.mdx index 12870b31350..358f42ac677 100644 --- a/apps/docs/content/docs/search/sim-links.mdx +++ b/apps/docs/content/docs/search/sim-links.mdx @@ -5,7 +5,7 @@ description: Short sim/ links that take everyone in your organization to the rig Sim Links are short, memorable names for the pages your team opens often, like `sim/handbook` for the employee handbook or `sim/okta` for your sign-in portal. Anyone in the organization can open them, with or without a Search seat. They work like go links in other tools. -Open **Sim Links** in the organization sidebar to see every link in your organization, with who created it, how often it's opened, and when it last changed. Search by name, destination, or description, and use **Sort → Opens** to see the 50 links your team opens most. +Open **Sim Links** in the organization sidebar to see every link in your organization, with who created it and when it last changed. Search by name, destination, or description, and use **Sort → Opens** to see the 50 links your team opens most. ## Open a link diff --git a/apps/sim/app/o/[organizationId]/links/loading.tsx b/apps/sim/app/o/[organizationId]/links/loading.tsx index c5076c55cd1..29268348dc4 100644 --- a/apps/sim/app/o/[organizationId]/links/loading.tsx +++ b/apps/sim/app/o/[organizationId]/links/loading.tsx @@ -8,10 +8,9 @@ import { const COLUMNS = [ { id: 'name', header: 'Name' }, - { id: 'destination', header: 'Destination', widthMultiplier: 1.6 }, + { id: 'destination', header: 'Destination', widthMultiplier: 2 }, { id: 'description', header: 'Description', widthMultiplier: 1.4 }, { id: 'owner', header: 'Created by' }, - { id: 'opens', header: 'Opens' }, { id: 'access', header: 'Access' }, { id: 'updated', header: 'Updated' }, ] diff --git a/apps/sim/app/o/[organizationId]/links/sim-links.tsx b/apps/sim/app/o/[organizationId]/links/sim-links.tsx index 77af4af27da..d6ac14c38f8 100644 --- a/apps/sim/app/o/[organizationId]/links/sim-links.tsx +++ b/apps/sim/app/o/[organizationId]/links/sim-links.tsx @@ -40,10 +40,9 @@ const logger = createLogger('SimLinks') const COLUMNS: ResourceColumn[] = [ { id: 'name', header: 'Name' }, - { id: 'destination', header: 'Destination', widthMultiplier: 1.6 }, + { id: 'destination', header: 'Destination', widthMultiplier: 2 }, { id: 'description', header: 'Description', widthMultiplier: 1.4 }, { id: 'owner', header: 'Created by' }, - { id: 'opens', header: 'Opens' }, { id: 'access', header: 'Access' }, { id: 'updated', header: 'Updated' }, ] @@ -114,7 +113,6 @@ export function SimLinks({ organizationId }: SimLinksProps) { label: link.createdBy.name, } : { label: null }, - opens: { label: String(link.visitCount) }, access: { label: accessLabel(link) }, updated: timeCell(link.updatedAt), }, From f713c7306db97aa00eed3314eef06335e1382a1e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 18:39:36 -0700 Subject: [PATCH 5/7] improvement(sim-links): loading chrome until the first page, readonly resource props, docs match the UI --- apps/docs/content/docs/search/sim-links.mdx | 14 +++++++------- apps/sim/app/o/[organizationId]/links/loading.tsx | 7 +++++-- .../app/o/[organizationId]/links/search-params.ts | 2 +- .../sim/app/o/[organizationId]/links/sim-links.tsx | 9 ++++++--- .../resource-chrome-fallback.tsx | 4 ++-- .../[workspaceId]/components/resource/resource.tsx | 9 ++++++--- 6 files changed, 27 insertions(+), 18 deletions(-) diff --git a/apps/docs/content/docs/search/sim-links.mdx b/apps/docs/content/docs/search/sim-links.mdx index 358f42ac677..b2d3130f74a 100644 --- a/apps/docs/content/docs/search/sim-links.mdx +++ b/apps/docs/content/docs/search/sim-links.mdx @@ -9,7 +9,7 @@ Open **Sim Links** in the organization sidebar to see every link in your organiz ## Open a link -Go to `https://sim.ai/sim/`, for example `https://sim.ai/sim/handbook`. On a self-hosted deployment, use your Sim address instead of `sim.ai`. Sim sends you to the link's destination. If you aren't signed in, Sim asks you to sign in first, then continues to the link. +Go to `https://www.sim.ai/sim/`, for example `https://www.sim.ai/sim/handbook`. On a self-hosted deployment, use your Sim address instead. Sim sends you to the link's destination. If you aren't signed in, Sim asks you to sign in first, then continues to the link. Names ignore case and punctuation, so `sim/Team-Handbook`, `sim/team_handbook`, and `sim/teamhandbook` all open the same link. The destination still applies its own permissions. @@ -21,7 +21,7 @@ Add Sim as a site search shortcut in Chrome so you can type `sim handbook` inste 1. Open Chrome **Settings → Search engine → Manage search engines and site search**. 2. Next to **Site search**, select **Add**. -3. Enter `Sim` as the name, `sim` as the shortcut, and `https://sim.ai/sim/%s` as the URL. +3. Enter `Sim` as the name, `sim` as the shortcut, and `https://www.sim.ai/sim/%s` as the URL. Type `sim`, press **Space** or **Tab**, then type a link name such as `handbook` or `jira/123` and press **Enter**. @@ -30,7 +30,7 @@ Chrome administrators can add this shortcut for everyone with the `SiteSearchSet ## Create a link 1. Open **Sim Links** and select **New link**. -2. Enter the **Destination URL**, a full `https://` address. +2. Enter the **Destination URL**, a full `https://` or `http://` address. 3. Enter a **Name**. Use letters, numbers, and `. : @ & ~ - _`. 4. Optionally add a **Description** so teammates know what the link is for. 5. Select **Create**. @@ -53,12 +53,12 @@ Each value fills the next `{*}` in order, and the last `{*}` takes everything th ## Control access -Under **Access**, the creator of a link can choose: +The **Access** section of a link has two options. Only the link's creator and organization admins can change them: -- **Unlist**: hides the link from the Sim Links page for everyone except the creator and organization admins. Anyone who knows the name can still open it. -- **Restrict editing**: only the creator and organization admins can edit or delete the link. Everyone can still open it. +- **Hide from the Sim Links page for everyone except you and admins**: the link is listed only for its creator and organization admins. Anyone who knows the name can still open it. +- **Restrict editing to you and admins**: only the creator and organization admins can edit or delete the link. Everyone can still open it. -Without restrictions, any member can edit or delete a link. Only the creator and organization admins can change its access settings. Creating, editing, and deleting links is recorded in your organization's [audit logs](/platform/enterprise/audit-logs). +The **Access** column on the Sim Links page shows **Unlisted** or **Restricted** for links that use them. Without either option, any member can edit or delete a link. Creating, editing, and deleting links is recorded in your organization's [audit logs](/platform/enterprise/audit-logs). ## Related docs diff --git a/apps/sim/app/o/[organizationId]/links/loading.tsx b/apps/sim/app/o/[organizationId]/links/loading.tsx index 29268348dc4..67d019510b2 100644 --- a/apps/sim/app/o/[organizationId]/links/loading.tsx +++ b/apps/sim/app/o/[organizationId]/links/loading.tsx @@ -5,6 +5,7 @@ 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' }, @@ -13,9 +14,11 @@ const COLUMNS = [ { id: 'owner', header: 'Created by' }, { id: 'access', header: 'Access' }, { id: 'updated', header: 'Updated' }, -] +] as const satisfies readonly ResourceColumn[] -const ACTIONS: ChromeActionSpec[] = [{ text: 'New link', icon: Plus, variant: 'primary' }] +const ACTIONS = [ + { text: 'New link', icon: Plus, variant: 'primary' }, +] as const satisfies readonly ChromeActionSpec[] export default function SimLinksLoading() { return ( diff --git a/apps/sim/app/o/[organizationId]/links/search-params.ts b/apps/sim/app/o/[organizationId]/links/search-params.ts index 740400f1e17..5549f6ea3ed 100644 --- a/apps/sim/app/o/[organizationId]/links/search-params.ts +++ b/apps/sim/app/o/[organizationId]/links/search-params.ts @@ -3,7 +3,7 @@ import { SIM_LINK_SORTS } from '@/lib/sim-links/validation' import { createSortParams } from '@/lib/url-state' /** `create` opens the new-link editor, prefilled with its value as the name. */ -export const simLinkViewParsers = { create: parseAsString } as const +const simLinkViewParsers = { create: parseAsString } as const export const serializeSimLinkView = createSerializer(simLinkViewParsers) diff --git a/apps/sim/app/o/[organizationId]/links/sim-links.tsx b/apps/sim/app/o/[organizationId]/links/sim-links.tsx index d6ac14c38f8..f06ad5f0f7e 100644 --- a/apps/sim/app/o/[organizationId]/links/sim-links.tsx +++ b/apps/sim/app/o/[organizationId]/links/sim-links.tsx @@ -8,6 +8,7 @@ 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 SimLinksLoading from '@/app/o/[organizationId]/links/loading' import { simLinksParsers, simLinksSortParams, @@ -38,14 +39,14 @@ import { useUrlSort } from '@/hooks/use-url-sort' const logger = createLogger('SimLinks') -const COLUMNS: ResourceColumn[] = [ +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' }, @@ -130,6 +131,8 @@ export function SimLinks({ organizationId }: SimLinksProps) { const searching = Boolean(debouncedSearch.trim()) const startCreate = () => void setView({ create: '' }) + if (!links.data && !links.isLoadingError) return + return ( {links.error.message}

void links.refetch()}>Retry
- ) : !links.data ? undefined : searching ? ( + ) : searching ? ( (colIdx === 0 ? 2.5 : 1.0) * (col.widthMultiplier ?? 1) ) @@ -209,7 +212,7 @@ export interface ResourceTableHandle { } interface ResourceTableProps { - columns: ResourceColumn[] + columns: readonly ResourceColumn[] rows: ResourceRow[] selectedRowId?: string | null /** Optional imperative handle exposing {@link ResourceTableHandle} (e.g. for keyboard-nav scrolling). */ @@ -639,7 +642,7 @@ const CellContent = memo(function CellContent({ interface DataRowProps { row: ResourceRow - columns: ResourceColumn[] + columns: readonly ResourceColumn[] selectedRowId?: string | null selectable?: SelectableConfig rowDragDrop?: RowDragDropConfig From e7a321a879f40f414bc4b99dade2c7dbf04d5917 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 18:40:45 -0700 Subject: [PATCH 6/7] docs(sim-links): tighten the Sim Links page --- apps/docs/content/docs/search/sim-links.mdx | 38 +++++++++------------ 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/apps/docs/content/docs/search/sim-links.mdx b/apps/docs/content/docs/search/sim-links.mdx index b2d3130f74a..5ce3a7207fd 100644 --- a/apps/docs/content/docs/search/sim-links.mdx +++ b/apps/docs/content/docs/search/sim-links.mdx @@ -3,45 +3,39 @@ title: Sim Links description: Short sim/ links that take everyone in your organization to the right page --- -Sim Links are short, memorable names for the pages your team opens often, like `sim/handbook` for the employee handbook or `sim/okta` for your sign-in portal. Anyone in the organization can open them, with or without a Search seat. They work like go links in other tools. +Sim Links are short names for pages your team opens often, like `sim/handbook` or `sim/okta`. Every member of your organization can use them, with or without a Search seat. -Open **Sim Links** in the organization sidebar to see every link in your organization, with who created it and when it last changed. Search by name, destination, or description, and use **Sort → Opens** to see the 50 links your team opens most. +Open **Sim Links** in the organization sidebar to see your organization's links. Search by name, destination, or description, or use **Sort → Opens** to see the most used links. ## Open a link -Go to `https://www.sim.ai/sim/`, for example `https://www.sim.ai/sim/handbook`. On a self-hosted deployment, use your Sim address instead. Sim sends you to the link's destination. If you aren't signed in, Sim asks you to sign in first, then continues to the link. +Go to `https://www.sim.ai/sim/`, for example `https://www.sim.ai/sim/handbook`. On a self-hosted deployment, use your Sim address. If you aren't signed in, Sim asks you to sign in first. -Names ignore case and punctuation, so `sim/Team-Handbook`, `sim/team_handbook`, and `sim/teamhandbook` all open the same link. The destination still applies its own permissions. - -If nobody has created the link yet, Sim offers to create it with that name. +Names ignore case and punctuation, so `sim/Team-Handbook` and `sim/teamhandbook` open the same link. If a link doesn't exist yet, Sim offers to create it. ## Open links from the address bar -Add Sim as a site search shortcut in Chrome so you can type `sim handbook` instead of the full address: +Add Sim as a Chrome site search shortcut to type `sim handbook` instead: 1. Open Chrome **Settings → Search engine → Manage search engines and site search**. 2. Next to **Site search**, select **Add**. 3. Enter `Sim` as the name, `sim` as the shortcut, and `https://www.sim.ai/sim/%s` as the URL. -Type `sim`, press **Space** or **Tab**, then type a link name such as `handbook` or `jira/123` and press **Enter**. - -Chrome administrators can add this shortcut for everyone with the `SiteSearchSettings` policy, using the same shortcut and URL. +Type `sim`, press **Space**, then type a link name and press **Enter**. Chrome administrators can add the shortcut for everyone with the `SiteSearchSettings` policy. ## Create a link 1. Open **Sim Links** and select **New link**. -2. Enter the **Destination URL**, a full `https://` or `http://` address. -3. Enter a **Name**. Use letters, numbers, and `. : @ & ~ - _`. -4. Optionally add a **Description** so teammates know what the link is for. -5. Select **Create**. +2. Enter the **Destination URL** and a **Name**. Names can use letters, numbers, and `. : @ & ~ - _`. +3. Optionally add a **Description**, then select **Create**. -Right-click a link and select **Copy link** to share it. +To share a link, right-click it and select **Copy link**. ## Add values to a link -Anything after the name is added to the destination. If `sim/docs` opens `https://docs.example.com`, then `sim/docs/billing/invoices` opens `https://docs.example.com/billing/invoices`. +Anything after the name is added to the destination: if `sim/docs` opens `https://docs.example.com`, then `sim/docs/billing` opens `https://docs.example.com/billing`. -To place a value somewhere else, put `{*}` in the destination URL: +To place a value elsewhere, put `{*}` in the destination URL: | Destination URL | You open | Sim opens | | --- | --- | --- | @@ -49,16 +43,16 @@ To place a value somewhere else, put `{*}` in the destination URL: | `https://wiki.example.com/search?q={*}` | `sim/wiki/expense policy` | `https://wiki.example.com/search?q=expense%20policy` | | `https://github.com/acme/{*}/issues/{*}` | `sim/issues/api/42` | `https://github.com/acme/api/issues/42` | -Each value fills the next `{*}` in order, and the last `{*}` takes everything that remains. A link with `{*}` can also have a **Default destination** on the same site, which opens when you use the link without a value. `{*}` can't replace the domain, and values can't be `.` or `..`. +Values fill each `{*}` in order, and the last `{*}` takes the rest. A link with `{*}` can set a **Default destination** on the same site for when no value is given. `{*}` can't replace the domain. ## Control access -The **Access** section of a link has two options. Only the link's creator and organization admins can change them: +By default, any member can edit or delete a link. The link's creator and organization admins can change two options under **Access**: -- **Hide from the Sim Links page for everyone except you and admins**: the link is listed only for its creator and organization admins. Anyone who knows the name can still open it. -- **Restrict editing to you and admins**: only the creator and organization admins can edit or delete the link. Everyone can still open it. +- **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. -The **Access** column on the Sim Links page shows **Unlisted** or **Restricted** for links that use them. Without either option, any member can edit or delete a link. Creating, editing, and deleting links is recorded in your organization's [audit logs](/platform/enterprise/audit-logs). +Changes to links are recorded in your organization's [audit logs](/platform/enterprise/audit-logs). ## Related docs From b655d25f3dd8e4b3b613623d8ee51b0cfa6aec03 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 30 Oct 2026 18:50:43 -0700 Subject: [PATCH 7/7] improvement(sim-links): context-menu anchor uses Tailwind for fixed styles --- .../o/[organizationId]/links/sim-link-context-menu.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) 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 afc1e8c15c4..d8adf7d5ed5 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 @@ -35,14 +35,8 @@ export function SimLinkContextMenu({ !open && onClose()} modal={false}>