Skip to content

Commit 297fc45

Browse files
committed
improvement(sim-links): cleanup and simplify pass (menu reads live link, shared columns, lazy favicons, sort-reversal e2e check)
1 parent 051b223 commit 297fc45

7 files changed

Lines changed: 61 additions & 67 deletions

File tree

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-links.tsx‎

Lines changed: 17 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,17 @@ 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
9592

9693
const rows: ResourceRow[] = useMemo(
9794
() =>
@@ -125,10 +122,9 @@ export function SimLinks({ organizationId }: SimLinksProps) {
125122
value: search,
126123
onChange: setSearch,
127124
onClearAll: () => setSearch(''),
128-
placeholder: 'Search Sim links...',
125+
placeholder: SIM_LINKS_SEARCH_PLACEHOLDER,
129126
}
130127
const sortConfig: SortConfig = { options: SORT_OPTIONS, active: activeSort, onSort, onClear }
131-
const searching = Boolean(debouncedSearch.trim())
132128
const startCreate = () => void setView({ create: '' })
133129

134130
if (!links.data && !links.isLoadingError) return <SimLinksLoading />
@@ -138,22 +134,19 @@ export function SimLinks({ organizationId }: SimLinksProps) {
138134
<Resource.Header
139135
icon={Link}
140136
title='Sim Links'
141-
actions={[
142-
{ id: 'create', text: 'New link', icon: Plus, variant: 'primary', onSelect: startCreate },
143-
]}
137+
actions={[{ ...NEW_SIM_LINK_ACTION, id: 'create', onSelect: startCreate }]}
144138
/>
145139
<Resource.Options search={searchConfig} sort={sortConfig} />
146140
<Resource.Table
147-
columns={COLUMNS}
141+
columns={SIM_LINK_COLUMNS}
148142
rows={rows}
149143
onRowClick={(id) => {
150144
const link = linksById.get(id)
151145
if (link) openLink(link.name)
152146
}}
153147
onRowContextMenu={(event, id) => {
154-
const link = linksById.get(id)
155-
if (!link) return
156-
setMenuLink(link)
148+
if (!linksById.has(id)) return
149+
setMenuLinkId(id)
157150
contextMenu.handleContextMenu(event)
158151
}}
159152
onLoadMore={() => void links.fetchNextPage()}
@@ -167,9 +160,9 @@ export function SimLinks({ organizationId }: SimLinksProps) {
167160
<p className='text-[var(--text-error)] text-small'>{links.error.message}</p>
168161
<Chip onClick={() => void links.refetch()}>Retry</Chip>
169162
</div>
170-
) : searching ? (
163+
) : query ? (
171164
<ResourceNoResults
172-
search={debouncedSearch}
165+
search={query}
173166
filterCount={0}
174167
searchScope='Searched every Sim link in your organization.'
175168
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/scripts/test-sim-links-e2e.ts‎

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,9 @@ async function create(member: Member, body: SimLinkBody): Promise<string> {
103103
return link.id
104104
}
105105

106-
async function listedNames(member: Member, search?: string, order?: string): Promise<string[]> {
107-
const params = [search && `search=${encodeURIComponent(search)}`, order].filter(Boolean)
108-
const query = params.length ? `?${params.join('&')}` : ''
109-
const { links } = await api(member, query, {})
106+
async function listedNames(member: Member, params: Record<string, string> = {}): Promise<string[]> {
107+
const query = new URLSearchParams(params).toString()
108+
const { links } = await api(member, query ? `?${query}` : '', {})
110109
assert(Array.isArray(links), 'Expected a link list')
111110
return links.map((link) => (isRecordLike(link) ? String(link.name) : ''))
112111
}
@@ -285,8 +284,8 @@ async function run() {
285284
})
286285

287286
await check('search matches names regardless of punctuation and the sim/ prefix', async () => {
288-
assert.deepEqual(await listedNames(member, 'sim/teamhand'), ['Team-Handbook'])
289-
assert.deepEqual(await listedNames(member, 'everything lives'), ['Team-Handbook'])
287+
assert.deepEqual(await listedNames(member, { search: 'sim/teamhand' }), ['Team-Handbook'])
288+
assert.deepEqual(await listedNames(member, { search: 'everything lives' }), ['Team-Handbook'])
290289
})
291290

292291
await check('restricted links refuse edits from other members', async () => {
@@ -345,12 +344,10 @@ async function run() {
345344
assert.equal(visits.get('jira'), 4, 'Each jira redirect counts once')
346345
assert.equal(visits.get('gh'), 0, 'A link opened without its values is not a visit')
347346
await api(member, '?sort=popular&cursor=jira', { expected: 400 })
348-
const descending = await listedNames(member, undefined, 'sort=name&dir=desc')
349-
const sortKey = (name: string) => name.toLowerCase().replace(/[.:@&~_-]/g, '')
350347
assert.deepEqual(
351-
descending.map(sortKey),
352-
descending.map(sortKey).sort().reverse(),
353-
'Expected Z to A by the matching key'
348+
await listedNames(member, { sort: 'name', dir: 'desc' }),
349+
[...(await listedNames(member))].reverse(),
350+
'Expected Z to A to be the default A to Z order reversed'
354351
)
355352
const { links: leastUsed } = await api(member, '?sort=popular&dir=asc', {})
356353
assert(Array.isArray(leastUsed), 'Expected a link list')

0 commit comments

Comments
 (0)