diff --git a/apps/docs/content/docs/search/sim-links.mdx b/apps/docs/content/docs/search/sim-links.mdx index d5682e028de..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, 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 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://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. 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://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**. +3. Enter `Sim` as the name, `sim` as the shortcut, and `https://www.sim.ai/sim/%s` as the URL. -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://` 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**. -Select **Copy link** from a link's menu 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 -Under **Access**, the creator of a link can choose: +By default, any member can edit or delete a link. The link's creator and organization admins can change two options under **Access**: -- **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. +- **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. -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). +Changes to links are recorded in your organization's [audit logs](/platform/enterprise/audit-logs). ## Related docs 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..67d019510b2 100644 --- a/apps/sim/app/o/[organizationId]/links/loading.tsx +++ b/apps/sim/app/o/[organizationId]/links/loading.tsx @@ -1,19 +1,34 @@ -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, Plus } 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[] export default function SimLinksLoading() { return ( -
-
-
-
-
-

Sim Links

-

- Loading Sim links -

-
-
+ ) } diff --git a/apps/sim/app/o/[organizationId]/links/search-params.ts b/apps/sim/app/o/[organizationId]/links/search-params.ts index 49717c6240c..5549f6ea3ed 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 +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..d8adf7d5ed5 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/links/sim-link-context-menu.tsx @@ -0,0 +1,74 @@ +'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 + + + + Copy link + + {onEdit && ( + + + Edit + + )} + {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..f06ad5f0f7e 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 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 { - 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 { - SettingsEmptyState, - SettingsQueryErrorState, -} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' -import { SettingsListPagination } from '@/app/workspace/[workspaceId]/settings/components/settings-list-pagination' + ResourceNoResults, + 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 { - 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 = [ + { 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[] -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' }, +] as const + +/** 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 }, + 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: '' }) + + if (!links.data && !links.isLoadingError) return 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 +
+ ) : searching ? ( + setSearch('')} + /> + ) : ( + + ) + } + /> + {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/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/resource-chrome-fallback.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/resource-chrome-fallback.tsx index 48fca17aa52..698b8ecb075 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/resource-chrome-fallback.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/resource-chrome-fallback.tsx @@ -33,9 +33,9 @@ interface ResourceChromeFallbackProps { */ breadcrumbs?: BreadcrumbItem[] /** Table column headers. Omit on bodies that don't render `Resource.Table` (the table editor). */ - columns?: ResourceColumn[] + columns?: readonly ResourceColumn[] /** The page's exact header action chips (handlers wired to a no-op). */ - actions?: ChromeActionSpec[] + actions?: readonly ChromeActionSpec[] /** Search placeholder. Omit to hide the search box (matches a page with no search). */ searchPlaceholder?: string /** Paint the Sort chip (its menu never opens during the fallback, so the option list is irrelevant). */ 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 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 diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index 5a3293a5594..acc9d15ff7e 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -153,7 +153,10 @@ const CHECKBOX_COLUMN_WIDTH = '52px' * virtualized grid layout reproduces the exact column widths. The checkbox * column, when present, is a fixed leading track. */ -function buildGridTemplateColumns(columns: ResourceColumn[], hasCheckbox: boolean): string { +function buildGridTemplateColumns( + columns: readonly ResourceColumn[], + hasCheckbox: boolean +): string { const weights = columns.map( (col, colIdx) => (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 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 () => {