Skip to content

Commit 7027fd5

Browse files
authored
improvement(sim-links): cleanup and simplify pass on the resource layout (#9492)
* improvement(sim-links): cleanup and simplify pass (menu reads live link, shared columns, lazy favicons, sort-reversal e2e check) * improvement(sim-links): quieter link editor; names take shape as you type
1 parent 051b223 commit 7027fd5

10 files changed

Lines changed: 90 additions & 83 deletions

File tree

‎apps/docs/content/docs/search/sim-links.mdx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Type `sim`, press **Space**, then type a link name and press **Enter**. Chrome a
2626
## Create a link
2727

2828
1. Open **Sim Links** and select **New link**.
29-
2. Enter the **Destination URL** and a **Name**. Names can use letters, numbers, and `. : @ & ~ - _`.
29+
2. Enter the **Destination URL** and a **Name**. Names use letters, numbers, and `. : @ & ~ - _`; spaces become hyphens as you type.
3030
3. Optionally add a **Description**, then select **Create**.
3131

3232
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
4949

5050
By default, any member can edit or delete a link. The link's creator and organization admins can change two options under **Access**:
5151

52-
- **Unlisted**: only the creator and admins see the link on the Sim Links page. Anyone with the name can still open it.
53-
- **Restricted**: only the creator and admins can edit or delete the link.
52+
- **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.
53+
- **Only you and admins can edit**: only the creator and admins can edit or delete the link, marked **Restricted**.
5454

5555
Changes to links are recorded in your organization's [audit logs](/platform/enterprise/audit-logs).
5656

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Plus } from '@sim/emcn/icons'
2+
import type { ResourceColumn } from '@/app/workspace/[workspaceId]/components/resource/resource'
3+
4+
/** Shared by the page and its loading chrome so the skeleton matches the table. */
5+
export const SIM_LINK_COLUMNS = [
6+
{ id: 'name', header: 'Name' },
7+
{ id: 'destination', header: 'Destination', widthMultiplier: 2 },
8+
{ id: 'description', header: 'Description', widthMultiplier: 1.4 },
9+
{ id: 'owner', header: 'Created by' },
10+
{ id: 'access', header: 'Access' },
11+
{ id: 'updated', header: 'Updated' },
12+
] as const satisfies readonly ResourceColumn[]
13+
14+
export const SIM_LINKS_SEARCH_PLACEHOLDER = 'Search Sim links...'
15+
16+
export const NEW_SIM_LINK_ACTION = { text: 'New link', icon: Plus, variant: 'primary' } as const

‎apps/sim/app/o/[organizationId]/links/loading.tsx‎

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,21 @@
11
'use client'
22

3-
import { Link, Plus } from '@sim/emcn/icons'
3+
import { Link } from '@sim/emcn/icons'
44
import {
5-
type ChromeActionSpec,
6-
ResourceChromeFallback,
7-
} from '@/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback'
8-
import type { ResourceColumn } from '@/app/workspace/[workspaceId]/components/resource/resource'
9-
10-
const COLUMNS = [
11-
{ id: 'name', header: 'Name' },
12-
{ id: 'destination', header: 'Destination', widthMultiplier: 2 },
13-
{ id: 'description', header: 'Description', widthMultiplier: 1.4 },
14-
{ id: 'owner', header: 'Created by' },
15-
{ id: 'access', header: 'Access' },
16-
{ id: 'updated', header: 'Updated' },
17-
] as const satisfies readonly ResourceColumn[]
18-
19-
const ACTIONS = [
20-
{ text: 'New link', icon: Plus, variant: 'primary' },
21-
] as const satisfies readonly ChromeActionSpec[]
5+
NEW_SIM_LINK_ACTION,
6+
SIM_LINK_COLUMNS,
7+
SIM_LINKS_SEARCH_PLACEHOLDER,
8+
} from '@/app/o/[organizationId]/links/columns'
9+
import { ResourceChromeFallback } from '@/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback'
2210

2311
export default function SimLinksLoading() {
2412
return (
2513
<ResourceChromeFallback
2614
icon={Link}
2715
title='Sim Links'
28-
columns={COLUMNS}
29-
actions={ACTIONS}
30-
searchPlaceholder='Search Sim links...'
16+
columns={SIM_LINK_COLUMNS}
17+
actions={[NEW_SIM_LINK_ACTION]}
18+
searchPlaceholder={SIM_LINKS_SEARCH_PLACEHOLDER}
3119
hasSort
3220
/>
3321
)

‎apps/sim/app/o/[organizationId]/links/sim-link-context-menu.tsx‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,14 @@ export function SimLinkContextMenu({
6161
Edit
6262
</DropdownMenuItem>
6363
)}
64-
{onDelete && <DropdownMenuSeparator />}
6564
{onDelete && (
66-
<DropdownMenuItem onSelect={onDelete}>
67-
<Trash />
68-
Delete
69-
</DropdownMenuItem>
65+
<>
66+
<DropdownMenuSeparator />
67+
<DropdownMenuItem onSelect={onDelete}>
68+
<Trash />
69+
Delete
70+
</DropdownMenuItem>
71+
</>
7072
)}
7173
</DropdownMenuContent>
7274
</DropdownMenu>

‎apps/sim/app/o/[organizationId]/links/sim-link-icon.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export function SimLinkIcon({ url }: SimLinkIconProps) {
2121
src={faviconUrl(hostname, 64)}
2222
alt=''
2323
referrerPolicy='no-referrer'
24+
loading='lazy'
25+
decoding='async'
2426
className='rounded-sm'
2527
onError={() => setFailedHostname(hostname)}
2628
/>

‎apps/sim/app/o/[organizationId]/links/sim-link-modal.tsx‎

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,18 @@ import {
1818
SIM_LINK_NAME_MAX_LENGTH,
1919
SIM_LINK_VARIABLE,
2020
simLinkFieldsSchema,
21+
toSimLinkName,
2122
} from '@/lib/sim-links/validation'
2223
import { useCreateSimLink, useUpdateSimLink } from '@/hooks/queries/sim-links'
2324

2425
type Field = 'name' | 'url' | 'defaultUrl' | 'description'
2526

27+
const SIM_LINKS_DOCS_URL = 'https://docs.sim.ai/search/sim-links'
28+
29+
function openDocs() {
30+
window.open(SIM_LINKS_DOCS_URL, '_blank', 'noopener,noreferrer')
31+
}
32+
2633
interface SimLinkModalProps {
2734
organizationId: string
2835
onClose: () => void
@@ -101,11 +108,6 @@ export function SimLinkModal({ organizationId, onClose, link, initialName }: Sim
101108
value={draft.url}
102109
onChange={(value) => change('url', value)}
103110
error={errors.url}
104-
hint={
105-
variable
106-
? `Variable link: sim/${draft.name || 'name'}/value fills each ${SIM_LINK_VARIABLE}.`
107-
: `Add ${SIM_LINK_VARIABLE} where a value typed after the name should go.`
108-
}
109111
/>
110112
{variable && (
111113
<ChipModalField
@@ -117,7 +119,7 @@ export function SimLinkModal({ organizationId, onClose, link, initialName }: Sim
117119
value={draft.defaultUrl ?? ''}
118120
onChange={(value) => change('defaultUrl', value || null)}
119121
error={errors.defaultUrl}
120-
hint={`Opens when sim/${draft.name || 'name'} is used without a value.`}
122+
hint={`Opens when no value fills ${SIM_LINK_VARIABLE}.`}
121123
/>
122124
)}
123125
<ChipModalField
@@ -128,9 +130,8 @@ export function SimLinkModal({ organizationId, onClose, link, initialName }: Sim
128130
placeholder='handbook'
129131
maxLength={SIM_LINK_NAME_MAX_LENGTH}
130132
value={draft.name}
131-
onChange={(value) => change('name', value)}
133+
onChange={(value) => change('name', toSimLinkName(value))}
132134
error={errors.name ?? (nameTaken ? mutation.error?.message : undefined)}
133-
hint='Letters, numbers, and . : @ & ~ - _. Case and punctuation are ignored, so sim/Q4-goals is sim/q4goals.'
134135
/>
135136
<ChipModalField
136137
type='input'
@@ -146,9 +147,7 @@ export function SimLinkModal({ organizationId, onClose, link, initialName }: Sim
146147
type='custom'
147148
title='Access'
148149
hint={
149-
canManage
150-
? 'Anyone in your organization can open this link.'
151-
: 'Only the creator or an organization admin can change access.'
150+
canManage ? undefined : 'Only the creator or an organization admin can change access.'
152151
}
153152
>
154153
{(aria) => (
@@ -162,7 +161,7 @@ export function SimLinkModal({ organizationId, onClose, link, initialName }: Sim
162161
onCheckedChange={(checked) => change('unlisted', checked === true)}
163162
/>
164163
<span className='text-[var(--text-primary)] text-small'>
165-
Hide from the Sim Links page for everyone except you and admins
164+
Hide from the Sim Links page
166165
</span>
167166
</label>
168167
<label htmlFor={restrictEditId} className='flex cursor-pointer items-center gap-2'>
@@ -174,7 +173,7 @@ export function SimLinkModal({ organizationId, onClose, link, initialName }: Sim
174173
onCheckedChange={(checked) => change('restrictEdit', checked === true)}
175174
/>
176175
<span className='text-[var(--text-primary)] text-small'>
177-
Restrict editing to you and admins
176+
Only you and admins can edit
178177
</span>
179178
</label>
180179
</div>
@@ -184,6 +183,7 @@ export function SimLinkModal({ organizationId, onClose, link, initialName }: Sim
184183
</ChipModalBody>
185184
<ChipModalFooter
186185
onCancel={onClose}
186+
secondaryActions={[{ label: 'Learn more', onClick: openDocs }]}
187187
primaryAction={{
188188
label: mutation.isPending ? pendingLabel : actionLabel,
189189
disabled: mutation.isPending,

‎apps/sim/app/o/[organizationId]/links/sim-links.tsx‎

Lines changed: 18 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@
22

33
import { useMemo, useState } from 'react'
44
import { Avatar, Chip, ChipConfirmModal, ChipModalError, writeTextToClipboard } from '@sim/emcn'
5-
import { Link, Plus } from '@sim/emcn/icons'
5+
import { Link } from '@sim/emcn/icons'
66
import { createLogger } from '@sim/logger'
77
import { useQueryStates } from 'nuqs'
88
import type { SimLink } from '@/lib/api/contracts/sim-links'
99
import { simLinkPath } from '@/lib/navigation/paths'
1010
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
11+
import {
12+
NEW_SIM_LINK_ACTION,
13+
SIM_LINK_COLUMNS,
14+
SIM_LINKS_SEARCH_PLACEHOLDER,
15+
} from '@/app/o/[organizationId]/links/columns'
1116
import SimLinksLoading from '@/app/o/[organizationId]/links/loading'
1217
import {
1318
simLinksParsers,
@@ -28,7 +33,6 @@ import type {
2833
import { timeCell } from '@/app/workspace/[workspaceId]/components/resource/components/time-cell'
2934
import {
3035
Resource,
31-
type ResourceColumn,
3236
type ResourceRow,
3337
} from '@/app/workspace/[workspaceId]/components/resource/resource'
3438
import { useDeleteSimLink, useSimLinks } from '@/hooks/queries/sim-links'
@@ -39,15 +43,6 @@ import { useUrlSort } from '@/hooks/use-url-sort'
3943

4044
const logger = createLogger('SimLinks')
4145

42-
const COLUMNS = [
43-
{ id: 'name', header: 'Name' },
44-
{ id: 'destination', header: 'Destination', widthMultiplier: 2 },
45-
{ id: 'description', header: 'Description', widthMultiplier: 1.4 },
46-
{ id: 'owner', header: 'Created by' },
47-
{ id: 'access', header: 'Access' },
48-
{ id: 'updated', header: 'Updated' },
49-
] as const satisfies readonly ResourceColumn[]
50-
5146
const SORT_OPTIONS = [
5247
{ id: 'name', label: 'Name' },
5348
{ id: 'popular', label: 'Opens' },
@@ -83,15 +78,18 @@ export function SimLinks({ organizationId }: SimLinksProps) {
8378
)
8479
const debouncedSearch = useSearchFilterValue(search, SEARCH_DEBOUNCE_MS)
8580
const { sort, dir, activeSort, onSort, onClear } = useUrlSort(simLinksSortParams, simLinksUrlKeys)
86-
const links = useSimLinks(organizationId, debouncedSearch.trim(), sort, dir)
81+
const query = debouncedSearch.trim()
82+
const links = useSimLinks(organizationId, query, sort, dir)
8783
const remove = useDeleteSimLink(organizationId)
8884
const contextMenu = useContextMenu()
89-
const [menuLink, setMenuLink] = useState<SimLink | null>(null)
85+
const [menuLinkId, setMenuLinkId] = useState<string | null>(null)
9086
const [editing, setEditing] = useState<SimLink | null>(null)
9187
const [deleting, setDeleting] = useState<SimLink | null>(null)
9288

9389
const loaded = useMemo(() => links.data?.pages.flatMap((page) => page.links) ?? [], [links.data])
9490
const linksById = useMemo(() => new Map(loaded.map((link) => [link.id, link])), [loaded])
91+
const menuLink = menuLinkId ? linksById.get(menuLinkId) : undefined
92+
if (menuLinkId && !menuLink) setMenuLinkId(null)
9593

9694
const rows: ResourceRow[] = useMemo(
9795
() =>
@@ -125,10 +123,9 @@ export function SimLinks({ organizationId }: SimLinksProps) {
125123
value: search,
126124
onChange: setSearch,
127125
onClearAll: () => setSearch(''),
128-
placeholder: 'Search Sim links...',
126+
placeholder: SIM_LINKS_SEARCH_PLACEHOLDER,
129127
}
130128
const sortConfig: SortConfig = { options: SORT_OPTIONS, active: activeSort, onSort, onClear }
131-
const searching = Boolean(debouncedSearch.trim())
132129
const startCreate = () => void setView({ create: '' })
133130

134131
if (!links.data && !links.isLoadingError) return <SimLinksLoading />
@@ -138,22 +135,19 @@ export function SimLinks({ organizationId }: SimLinksProps) {
138135
<Resource.Header
139136
icon={Link}
140137
title='Sim Links'
141-
actions={[
142-
{ id: 'create', text: 'New link', icon: Plus, variant: 'primary', onSelect: startCreate },
143-
]}
138+
actions={[{ ...NEW_SIM_LINK_ACTION, id: 'create', onSelect: startCreate }]}
144139
/>
145140
<Resource.Options search={searchConfig} sort={sortConfig} />
146141
<Resource.Table
147-
columns={COLUMNS}
142+
columns={SIM_LINK_COLUMNS}
148143
rows={rows}
149144
onRowClick={(id) => {
150145
const link = linksById.get(id)
151146
if (link) openLink(link.name)
152147
}}
153148
onRowContextMenu={(event, id) => {
154-
const link = linksById.get(id)
155-
if (!link) return
156-
setMenuLink(link)
149+
if (!linksById.has(id)) return
150+
setMenuLinkId(id)
157151
contextMenu.handleContextMenu(event)
158152
}}
159153
onLoadMore={() => void links.fetchNextPage()}
@@ -167,9 +161,9 @@ export function SimLinks({ organizationId }: SimLinksProps) {
167161
<p className='text-[var(--text-error)] text-small'>{links.error.message}</p>
168162
<Chip onClick={() => void links.refetch()}>Retry</Chip>
169163
</div>
170-
) : searching ? (
164+
) : query ? (
171165
<ResourceNoResults
172-
search={debouncedSearch}
166+
search={query}
173167
filterCount={0}
174168
searchScope='Searched every Sim link in your organization.'
175169
onClear={() => setSearch('')}

‎apps/sim/lib/sim-links/repository.ts‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export async function listSimLinks(
8787
dir = sort === 'popular' ? 'desc' : 'asc',
8888
}: { search?: string; cursor?: string; sort?: SimLinkSort; dir?: SortDirection }
8989
): Promise<{ links: SimLinkRecord[]; nextCursor: string | null }> {
90-
const order = dir === 'asc' ? asc : desc
90+
const [order, after] = dir === 'asc' ? ([asc, gt] as const) : ([desc, lt] as const)
9191
const conditions: (SQL | undefined)[] = [eq(simLink.organizationId, organizationId)]
9292
if (!viewer.isAdmin) {
9393
conditions.push(or(eq(simLink.unlisted, false), eq(simLink.createdBy, viewer.userId)))
@@ -111,11 +111,7 @@ export async function listSimLinks(
111111
.limit(PAGE_SIZE)
112112
return { links: rows.map(toRecord), nextCursor: null }
113113
}
114-
if (cursor) {
115-
conditions.push(
116-
dir === 'asc' ? gt(simLink.normalizedName, cursor) : lt(simLink.normalizedName, cursor)
117-
)
118-
}
114+
if (cursor) conditions.push(after(simLink.normalizedName, cursor))
119115
const rows = await selectRecords(db)
120116
.where(and(...conditions))
121117
.orderBy(order(simLink.normalizedName))

‎apps/sim/lib/sim-links/validation.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { z } from 'zod'
22

33
const NAME_PATTERN = /^[A-Za-z0-9.:@&~_-]+$/
44
const NAME_PUNCTUATION = /[.:@&~_-]/g
5+
const NAME_DISALLOWED = /[^A-Za-z0-9.:@&~_-]/g
56
export const SIM_LINK_NAME_MAX_LENGTH = 64
67
export const SIM_LINK_DESCRIPTION_MAX_LENGTH = 500
78
const URL_MAX_LENGTH = 2048
@@ -18,6 +19,17 @@ export function normalizeSimLinkName(name: string): string {
1819
return name.toLowerCase().replace(NAME_PUNCTUATION, '')
1920
}
2021

22+
/**
23+
* Shapes typed or pasted text into a name as it is entered: a leading `sim/` is dropped, spaces
24+
* and slashes become hyphens, and any other character a name can't hold is removed.
25+
*/
26+
export function toSimLinkName(input: string): string {
27+
return input
28+
.replace(/^sim\//i, '')
29+
.replace(/[\s/]+/g, '-')
30+
.replace(NAME_DISALLOWED, '')
31+
}
32+
2133
export function isVariableSimLinkUrl(url: string): boolean {
2234
return url.includes(SIM_LINK_VARIABLE)
2335
}

0 commit comments

Comments
 (0)