Skip to content

Commit c4278bf

Browse files
committed
Merge remote-tracking branch 'origin/staging' into improvement/enterprise-self-host
2 parents 29ab3d0 + 591702b commit c4278bf

12 files changed

Lines changed: 172 additions & 149 deletions

File tree

apps/sim/app/api/pinned-items/route.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,23 @@ describe('Pinned Items API', () => {
180180
)
181181
})
182182

183+
it('accepts folder as a pinnable resourceType', async () => {
184+
mockReturning.mockReturnValueOnce([
185+
{ ...pinnedWorkflowRow, resourceType: 'folder', resourceId: 'folder-1' },
186+
])
187+
188+
const response = await POST(
189+
createMockRequest('POST', { ...body, resourceType: 'folder', resourceId: 'folder-1' })
190+
)
191+
192+
expect(response.status).toBe(201)
193+
const data = await response.json()
194+
expect(data.pinnedItem).toMatchObject({ resourceType: 'folder', resourceId: 'folder-1' })
195+
expect(mockValues).toHaveBeenCalledWith(
196+
expect.objectContaining({ resourceType: 'folder', resourceId: 'folder-1' })
197+
)
198+
})
199+
183200
it('returns 404 when the resource is not in the workspace', async () => {
184201
mockLimit.mockReturnValueOnce([])
185202

apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx

Lines changed: 3 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -58,15 +58,6 @@ export interface ResourceCell {
5858
* resting cell exactly (same gap, weight, icon size).
5959
*/
6060
editing?: ResourceCellEditing
61-
/**
62-
* Trailing node pinned to the far edge of the cell, after the label. Use this for
63-
* per-row affordances (e.g. a pin toggle) so the canonical icon + truncating-label
64-
* rendering is reused rather than re-derived in a `content` node. Suppressed while
65-
* the cell is in rename mode, where the input owns the full width.
66-
*
67-
* The row sets `group`, so an adornment may use `group-hover:` to reveal itself.
68-
*/
69-
endAdornment?: ReactNode
7061
}
7162

7263
export interface ResourceRow {
@@ -497,16 +488,9 @@ interface CellContentProps {
497488
label: string
498489
content?: ReactNode
499490
editing?: ResourceCellEditing
500-
endAdornment?: ReactNode
501491
}
502492

503-
const CellContent = memo(function CellContent({
504-
icon,
505-
label,
506-
content,
507-
editing,
508-
endAdornment,
509-
}: CellContentProps) {
493+
const CellContent = memo(function CellContent({ icon, label, content, editing }: CellContentProps) {
510494
if (editing) {
511495
return (
512496
<span className={cn('flex min-w-0 items-center', chipContentGap)}>
@@ -522,19 +506,12 @@ const CellContent = memo(function CellContent({
522506
)
523507
}
524508
if (content) return <>{content}</>
525-
const body = (
509+
return (
526510
<span className={cn('flex min-w-0 items-center', chipContentGap)}>
527511
{icon && <span className={cellIconNodeClass}>{icon}</span>}
528512
<FloatingOverflowText label={label} className={cn('block', chipContentLabelClass)} />
529513
</span>
530514
)
531-
if (!endAdornment) return body
532-
return (
533-
<span className='flex min-w-0 flex-1 items-center justify-between'>
534-
{body}
535-
{endAdornment}
536-
</span>
537-
)
538515
})
539516

540517
interface DataRowProps {
@@ -661,7 +638,7 @@ const DataRow = memo(function DataRow({
661638
data-resource-row
662639
data-row-id={row.id}
663640
className={cn(
664-
'group grid w-full transition-colors',
641+
'grid w-full transition-colors',
665642
isWindowed && 'absolute top-0 left-0',
666643
!isAnyDragActive && 'hover-hover:bg-[var(--surface-3)]',
667644
onRowClick && 'cursor-pointer',
@@ -704,7 +681,6 @@ const DataRow = memo(function DataRow({
704681
label={cell?.label || EMPTY_CELL_PLACEHOLDER}
705682
content={cell?.content}
706683
editing={cell?.editing}
707-
endAdornment={cell?.endAdornment}
708684
/>
709685
</div>
710686
)

apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
FolderInput,
1616
Pencil,
1717
} from '@sim/emcn'
18-
import { Download, Link, Trash } from '@sim/emcn/icons'
18+
import { Download, Link, Pin, Trash } from '@sim/emcn/icons'
1919
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options'
2020
import { renderMoveOption } from '@/app/workspace/[workspaceId]/files/move-options'
2121

@@ -29,6 +29,9 @@ interface FileRowContextMenuProps {
2929
onDelete: () => void
3030
onMove?: (optionValue: string) => void
3131
onShare?: () => void
32+
onTogglePin: () => void
33+
/** Pin state of the right-clicked row, driving the Pin/Unpin label. */
34+
pinned: boolean
3235
moveOptions?: MoveOptionNode[]
3336
canEdit: boolean
3437
selectedCount: number
@@ -44,6 +47,8 @@ export const FileRowContextMenu = memo(function FileRowContextMenu({
4447
onDelete,
4548
onMove,
4649
onShare,
50+
onTogglePin,
51+
pinned,
4752
moveOptions,
4853
canEdit,
4954
selectedCount,
@@ -78,6 +83,12 @@ export const FileRowContextMenu = memo(function FileRowContextMenu({
7883
{isMultiSelect ? `Download ${selectedCount} items` : 'Download'}
7984
</DropdownMenuItem>
8085
)}
86+
{!isMultiSelect && (
87+
<DropdownMenuItem onSelect={onTogglePin}>
88+
<Pin />
89+
{pinned ? 'Unpin' : 'Pin'}
90+
</DropdownMenuItem>
91+
)}
8192
{canEdit && (
8293
<>
8394
<DropdownMenuSeparator />

apps/sim/app/workspace/[workspaceId]/files/files.tsx

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ import { useParams, useRouter } from 'next/navigation'
2525
import { useQueryStates } from 'nuqs'
2626
import { usePostHog } from 'posthog-js/react'
2727
import { getDocumentIcon } from '@/components/icons/document-icons'
28-
import { PinButton } from '@/components/pin-button'
2928
import { useLimitUpgradeToast } from '@/lib/billing/client'
3029
import { captureEvent } from '@/lib/posthog/client'
3130
import { triggerArchiveDownload, triggerFileDownload } from '@/lib/uploads/client/download'
@@ -85,7 +84,7 @@ import {
8584
} from '@/app/workspace/[workspaceId]/files/search-params'
8685
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
8786
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
88-
import { usePinnedIds } from '@/hooks/queries/pinned-items'
87+
import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items'
8988
import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace'
9089
import {
9190
useBulkArchiveWorkspaceFileItems,
@@ -215,6 +214,10 @@ export function Files() {
215214
const { data: folders = EMPTY_WORKSPACE_FILE_FOLDERS } = useWorkspaceFileFolders(workspaceId)
216215
const { data: members } = useWorkspaceMembersQuery(workspaceId)
217216
const pinnedFileIds = usePinnedIds(workspaceId, 'file')
217+
// Folders pin under their own resource type, so their pinned set is a separate query.
218+
const pinnedFolderIds = usePinnedIds(workspaceId, 'folder')
219+
const pinItem = usePinItem()
220+
const unpinItem = useUnpinItem()
218221
const membersById = useMemo(() => {
219222
const map = new Map<string, WorkspaceMember>()
220223
for (const member of members ?? []) map.set(member.userId, member)
@@ -398,6 +401,12 @@ export function Files() {
398401
const col = activeSort?.column ?? 'name'
399402
const dir = activeSort?.direction ?? 'asc'
400403
return [...searched].sort((a, b) => {
404+
// Pinned folders float to the top of every sort/direction — pinning is a
405+
// user-declared priority, not another sort key to be inverted by `desc`.
406+
const aPinned = pinnedFolderIds.has(a.id)
407+
const bPinned = pinnedFolderIds.has(b.id)
408+
if (aPinned !== bPinned) return aPinned ? -1 : 1
409+
401410
let cmp = 0
402411
if (col === 'updated') {
403412
cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
@@ -408,7 +417,7 @@ export function Files() {
408417
}
409418
return dir === 'asc' ? cmp : -cmp
410419
})
411-
}, [folders, currentFolderId, debouncedSearchTerm, activeSort])
420+
}, [folders, currentFolderId, debouncedSearchTerm, activeSort, pinnedFolderIds])
412421

413422
const filteredFiles = useMemo(() => {
414423
const needle = debouncedSearchTerm.trim().toLowerCase()
@@ -521,15 +530,6 @@ export function Files() {
521530
name: {
522531
icon: <Icon className='size-[14px]' />,
523532
label: file.name,
524-
endAdornment: (
525-
<PinButton
526-
workspaceId={workspaceId}
527-
resourceType='file'
528-
resourceId={file.id}
529-
pinned={pinnedFileIds.has(file.id)}
530-
className='ml-2'
531-
/>
532-
),
533533
},
534534
size: {
535535
label: formatFileSize(file.size, { includeBytes: true }),
@@ -547,7 +547,7 @@ export function Files() {
547547
})
548548

549549
return [...folderRows, ...fileRows]
550-
}, [visibleFolders, filteredFiles, membersById, folderSizeMap, workspaceId, pinnedFileIds])
550+
}, [visibleFolders, filteredFiles, membersById, folderSizeMap])
551551

552552
const rows: ResourceRow[] = useMemo(() => {
553553
if (!listRename.editingId) return baseRows
@@ -1365,6 +1365,17 @@ export function Files() {
13651365
closeContextMenu()
13661366
}, [selectedRowIds, handleBulkDelete, closeContextMenu])
13671367

1368+
const handleContextMenuTogglePin = useCallback(() => {
1369+
const item = contextMenuItemRef.current
1370+
if (!item) return
1371+
const resourceType = item.kind === 'folder' ? 'folder' : 'file'
1372+
const pinned =
1373+
item.kind === 'folder' ? pinnedFolderIds.has(item.id) : pinnedFileIds.has(item.id)
1374+
const mutation = pinned ? unpinItem : pinItem
1375+
mutation.mutate({ workspaceId, resourceType, resourceId: item.id })
1376+
closeContextMenu()
1377+
}, [workspaceId, pinnedFolderIds, pinnedFileIds, closeContextMenu])
1378+
13681379
const handleContextMenuMove = useCallback(
13691380
async (optionValue: string) => {
13701381
const targetFolderId = optionValue === '__root__' ? null : optionValue
@@ -1985,6 +1996,16 @@ export function Files() {
19851996
)
19861997
}
19871998

1999+
/**
2000+
* Read off the same ref the context-menu handlers use, so the menu's Pin/Unpin label
2001+
* describes the row that was right-clicked. Opening the menu is a state change, so
2002+
* this re-reads on the render that shows it.
2003+
*/
2004+
const contextMenuItem = contextMenuItemRef.current
2005+
const isContextMenuItemPinned = contextMenuItem
2006+
? (contextMenuItem.kind === 'folder' ? pinnedFolderIds : pinnedFileIds).has(contextMenuItem.id)
2007+
: false
2008+
19882009
return (
19892010
<div
19902011
className='relative flex h-full flex-col overflow-hidden'
@@ -2064,11 +2085,9 @@ export function Files() {
20642085
onRename={handleContextMenuRename}
20652086
onDelete={handleContextMenuDelete}
20662087
onMove={handleContextMenuMove}
2067-
onShare={
2068-
canEdit && contextMenuItemRef.current?.kind === 'file'
2069-
? handleContextMenuShare
2070-
: undefined
2071-
}
2088+
onShare={canEdit && contextMenuItem?.kind === 'file' ? handleContextMenuShare : undefined}
2089+
onTogglePin={handleContextMenuTogglePin}
2090+
pinned={isContextMenuItemPinned}
20722091
moveOptions={contextMenuMoveOptions}
20732092
canEdit={canEdit}
20742093
selectedCount={selectedRowIds.size}

apps/sim/app/workspace/[workspaceId]/knowledge/components/knowledge-base-context-menu/knowledge-base-context-menu.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
DropdownMenuSeparator,
99
DropdownMenuTrigger,
1010
} from '@sim/emcn'
11-
import { Duplicate, Pencil, SquareArrowUpRight, TagIcon, Trash } from '@sim/emcn/icons'
11+
import { Duplicate, Pencil, Pin, SquareArrowUpRight, TagIcon, Trash } from '@sim/emcn/icons'
1212

1313
interface KnowledgeBaseContextMenuProps {
1414
isOpen: boolean
@@ -17,6 +17,9 @@ interface KnowledgeBaseContextMenuProps {
1717
onOpenInNewTab?: () => void
1818
onViewTags?: () => void
1919
onCopyId?: () => void
20+
onTogglePin?: () => void
21+
/** Pin state of the right-clicked base, driving the Pin/Unpin label. */
22+
pinned?: boolean
2023
onEdit?: () => void
2124
onDelete?: () => void
2225
showOpenInNewTab?: boolean
@@ -38,6 +41,8 @@ export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({
3841
onOpenInNewTab,
3942
onViewTags,
4043
onCopyId,
44+
onTogglePin,
45+
pinned = false,
4146
onEdit,
4247
onDelete,
4348
showOpenInNewTab = true,
@@ -48,7 +53,7 @@ export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({
4853
disableDelete = false,
4954
}: KnowledgeBaseContextMenuProps) {
5055
const hasNavigationSection = showOpenInNewTab && !!onOpenInNewTab
51-
const hasInfoSection = (showViewTags && !!onViewTags) || !!onCopyId
56+
const hasInfoSection = (showViewTags && !!onViewTags) || !!onCopyId || !!onTogglePin
5257
const hasEditSection = showEdit && !!onEdit
5358
const hasDestructiveSection = showDelete && !!onDelete
5459

@@ -96,6 +101,12 @@ export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({
96101
Copy ID
97102
</DropdownMenuItem>
98103
)}
104+
{onTogglePin && (
105+
<DropdownMenuItem onSelect={onTogglePin}>
106+
<Pin />
107+
{pinned ? 'Unpin' : 'Pin'}
108+
</DropdownMenuItem>
109+
)}
99110
{hasInfoSection && (hasEditSection || hasDestructiveSection) && <DropdownMenuSeparator />}
100111

101112
{showEdit && onEdit && (

apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { Database } from '@sim/emcn/icons'
77
import { createLogger } from '@sim/logger'
88
import { useParams, useRouter } from 'next/navigation'
99
import { useQueryStates } from 'nuqs'
10-
import { PinButton } from '@/components/pin-button'
1110
import type { KnowledgeBaseData } from '@/lib/knowledge/types'
1211
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
1312
import type {
@@ -44,7 +43,7 @@ import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sideb
4443
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
4544
import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge'
4645
import { useDeleteKnowledgeBase, useUpdateKnowledgeBase } from '@/hooks/queries/kb/knowledge'
47-
import { usePinnedIds } from '@/hooks/queries/pinned-items'
46+
import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items'
4847
import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
4948
import { useDebounce } from '@/hooks/use-debounce'
5049
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
@@ -146,6 +145,8 @@ export function Knowledge() {
146145
const { knowledgeBases, error } = useKnowledgeBasesList(workspaceId)
147146
const { data: members } = useWorkspaceMembersQuery(workspaceId)
148147
const pinnedBaseIds = usePinnedIds(workspaceId, 'knowledge_base')
148+
const pinItem = usePinItem()
149+
const unpinItem = useUnpinItem()
149150

150151
if (error) {
151152
logger.error('Failed to load knowledge bases:', error)
@@ -349,15 +350,6 @@ export function Knowledge() {
349350
name: {
350351
icon: KNOWLEDGE_BASE_ICON,
351352
label: kb.name,
352-
endAdornment: (
353-
<PinButton
354-
workspaceId={workspaceId}
355-
resourceType='knowledge_base'
356-
resourceId={kb.id}
357-
pinned={pinnedBaseIds.has(kb.id)}
358-
className='ml-2'
359-
/>
360-
),
361353
},
362354
documents: {
363355
label: String(kbWithCount.docCount || 0),
@@ -372,7 +364,7 @@ export function Knowledge() {
372364
},
373365
}
374366
}),
375-
[processedKBs, members, workspaceId, pinnedBaseIds]
367+
[processedKBs, members]
376368
)
377369

378370
const handleRowClick = useCallback(
@@ -437,6 +429,14 @@ export function Knowledge() {
437429
setIsEditModalOpen(true)
438430
}, [])
439431

432+
const handleTogglePin = useCallback(() => {
433+
const kb = activeKnowledgeBaseRef.current
434+
if (!kb) return
435+
const mutation = pinnedBaseIds.has(kb.id) ? unpinItem : pinItem
436+
mutation.mutate({ workspaceId, resourceType: 'knowledge_base', resourceId: kb.id })
437+
closeRowContextMenu()
438+
}, [workspaceId, pinnedBaseIds, closeRowContextMenu])
439+
440440
const handleDelete = useCallback(() => {
441441
setIsDeleteModalOpen(true)
442442
}, [])
@@ -645,6 +645,8 @@ export function Knowledge() {
645645
onOpenInNewTab={handleOpenInNewTab}
646646
onViewTags={handleViewTags}
647647
onCopyId={handleCopyId}
648+
onTogglePin={handleTogglePin}
649+
pinned={pinnedBaseIds.has(activeKnowledgeBase.id)}
648650
onEdit={handleEdit}
649651
onDelete={handleDelete}
650652
showOpenInNewTab

0 commit comments

Comments
 (0)