mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(search): disambiguate tables and knowledge bases by folder (#6192)
* fix(search): disambiguate tables and knowledge bases by folder The Cmd-K search modal listed tables and knowledge bases without the folder breadcrumb workflows and files already showed, and the table, knowledge base, and search-and-replace pickers in the workflow editor rendered bare names -- so two resources sharing a name in different folders were indistinguishable. Extracts the disambiguation the workflow selector already did into shared collectDuplicateNames + disambiguateLabelByFolder, and shares the search row's folder breadcrumb and its memo comparator, which were duplicated between the workflow and file rows. Also routes folder text through filterAndCap's secondary-rank parameter rather than concatenating it into the name, so an exact name match can no longer be outranked by a folder that happens to fuzzy-match. * fix(zoho-desk): use the real Zoho Desk mark on a white tile The icon was a generic headset placeholder drawn in currentColor, so it never resembled Zoho at all. Replaces it with the mark from Zoho's official logo -- wordmark stripped, viewBox set to the mark's own bounding box so it centers -- and moves the tile to white, matching the other brand-mark integrations.
This commit is contained in:
@@ -8932,14 +8932,14 @@ export function ZohoDeskIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
viewBox='0 0 24 24'
|
||||
viewBox='-24 -6 308 308'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
aria-hidden='true'
|
||||
>
|
||||
<path
|
||||
d='M12 2.75c-4.28 0-7.75 3.47-7.75 7.75v3.1A2.6 2.6 0 0 0 3 16.35v1.3A2.6 2.6 0 0 0 5.6 20.25h1.15a.9.9 0 0 0 .9-.9v-4.9a.9.9 0 0 0-.9-.9H6.05v-2.15a5.95 5.95 0 0 1 11.9 0v2.15h-.7a.9.9 0 0 0-.9.9v4.9c0 .17.05.33.13.47-.5.6-1.24.98-2.08.98h-1.02a1.4 1.4 0 0 0-1.31-.9h-1a1.4 1.4 0 0 0 0 2.8h1a1.4 1.4 0 0 0 1.31-.9h1.02c2.06 0 3.74-1.63 3.83-3.67a2.6 2.6 0 0 0 1.44-2.33v-1.3a2.6 2.6 0 0 0-1.25-2.22v-3.1c0-4.28-3.47-7.75-7.75-7.75Z'
|
||||
fill='currentColor'
|
||||
d='M22.3,293c-1.6,0-3.3-0.5-4.7-1.4c-2.8-1.8-4.2-5.1-3.7-8.4l24.3-149.3c1.3-8,5.4-15.2,11.5-20.4 c6.1-5.2,14-8.1,22-8.1h100.1l5.6-34.3H168c-4.2,0-8.1-1.8-10.8-5c-2.7-3.2-3.9-7.3-3.2-11.4l6.4-39.7c1.1-6.9,7-11.9,14-11.9h37.3 c10,0,19.4,4.4,25.9,12s9.3,17.6,7.7,27.5l-15,92.4c-0.7,4-3,7.6-6.5,9.8L140,196.5c-5.1,3.1-11.6,2.8-16.3-1l-39-31.1 c-3.7-2.9-4.3-8.3-1.3-11.9c2.9-3.7,8.3-4.3,11.9-1.3l37.5,29.9l81-50.2l14.8-91.1c0.8-4.9-0.6-9.9-3.8-13.7c-3.2-3.8-7.9-6-12.9-6 h-34.9l-5.5,34h9.3c4.2,0,8.1,1.8,10.8,5c2.7,3.2,3.9,7.3,3.2,11.4l-6.5,39.9c-1.1,6.9-7,11.9-14,11.9H71.7 c-8.4,0-15.4,6-16.8,14.3L33.6,267.5L63.5,249c2.2-1.4,4.8-2.1,7.5-2.1h109.5c8.4,0,15.4-6,16.8-14.3l6.8-41.9 c0.8-4.6,5.1-7.8,9.7-7c4.6,0.8,7.8,5.1,7,9.7l-6.8,41.9c-1.3,8-5.4,15.2-11.5,20.4c-6.1,5.2-14,8.1-22,8.1H71.7l-45,27.9 C25.4,292.6,23.8,293,22.3,293z'
|
||||
fill='#089949'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="zoho_desk"
|
||||
color="#E42527"
|
||||
color="#FFFFFF"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
|
||||
+37
-12
@@ -13,7 +13,9 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
|
||||
import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge'
|
||||
import { useFolderMap } from '@/hooks/queries/folders'
|
||||
import { fetchKnowledgeBase } from '@/hooks/queries/kb/knowledge'
|
||||
import { collectDuplicateNames, disambiguateLabelByFolder } from '@/hooks/queries/utils/folder-tree'
|
||||
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
|
||||
|
||||
interface KnowledgeBaseSelectorProps {
|
||||
@@ -43,6 +45,8 @@ export function KnowledgeBaseSelector({
|
||||
error,
|
||||
} = useKnowledgeBasesList(workspaceId)
|
||||
|
||||
const { data: knowledgeBaseFolders = {} } = useFolderMap(workspaceId, 'knowledge_base')
|
||||
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
|
||||
const value = isPreview ? previewValue : storeValue
|
||||
@@ -90,13 +94,32 @@ export function KnowledgeBaseSelector({
|
||||
return Array.from(merged.values())
|
||||
}, [knowledgeBases, selectedKnowledgeBaseQueries])
|
||||
|
||||
const options = useMemo<ComboboxOption[]>(() => {
|
||||
return combinedKnowledgeBases.map((kb) => ({
|
||||
label: kb.name,
|
||||
value: kb.id,
|
||||
icon: PackageSearchIcon,
|
||||
}))
|
||||
}, [combinedKnowledgeBases])
|
||||
/**
|
||||
* Display names, with the folder path appended when two knowledge bases share
|
||||
* a name — otherwise the dropdown rows and the selected chips are
|
||||
* indistinguishable from one another. Built in the same pass as the options so
|
||||
* the chips and the dropdown can never disagree.
|
||||
*/
|
||||
const { options, labelById } = useMemo(() => {
|
||||
const duplicateNames = collectDuplicateNames(combinedKnowledgeBases.map((kb) => kb.name))
|
||||
const labelById = new Map<string, string>()
|
||||
const options: ComboboxOption[] = combinedKnowledgeBases.map((kb) => {
|
||||
const label = disambiguateLabelByFolder(
|
||||
kb.name,
|
||||
kb.folderId,
|
||||
knowledgeBaseFolders,
|
||||
duplicateNames
|
||||
)
|
||||
labelById.set(kb.id, label)
|
||||
return { label, value: kb.id, icon: PackageSearchIcon }
|
||||
})
|
||||
return { options, labelById }
|
||||
}, [combinedKnowledgeBases, knowledgeBaseFolders])
|
||||
|
||||
const labelOf = useCallback(
|
||||
(kb: KnowledgeBaseData) => labelById.get(kb.id) ?? kb.name,
|
||||
[labelById]
|
||||
)
|
||||
|
||||
/**
|
||||
* Compute selected knowledge bases for tag display
|
||||
@@ -172,7 +195,7 @@ export function KnowledgeBaseSelector({
|
||||
blockId,
|
||||
subBlockId: subBlock.id,
|
||||
valuePath: [index],
|
||||
label: kb.name,
|
||||
label: labelOf(kb),
|
||||
})
|
||||
return (
|
||||
<div
|
||||
@@ -181,14 +204,14 @@ export function KnowledgeBaseSelector({
|
||||
>
|
||||
<PackageSearchIcon className='mr-1 size-3 text-[var(--brand-knowledge)]' />
|
||||
<span className='font-medium text-[var(--brand-knowledge)]'>
|
||||
{formatDisplayText(kb.name, { workflowSearchHighlight })}
|
||||
{formatDisplayText(labelOf(kb), { workflowSearchHighlight })}
|
||||
</span>
|
||||
{!disabled && !isPreview && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => handleRemoveKnowledgeBase(kb.id)}
|
||||
className='ml-1 text-[color-mix(in_srgb,var(--brand-knowledge)_60%,transparent)] hover-hover:text-[var(--brand-knowledge)]'
|
||||
aria-label={`Remove ${kb.name}`}
|
||||
aria-label={`Remove ${labelOf(kb)}`}
|
||||
>
|
||||
<X className='size-3' />
|
||||
</button>
|
||||
@@ -220,11 +243,13 @@ export function KnowledgeBaseSelector({
|
||||
blockId,
|
||||
subBlockId: subBlock.id,
|
||||
valuePath: [],
|
||||
label: selectedKnowledgeBases[0].name,
|
||||
label: labelOf(selectedKnowledgeBases[0]),
|
||||
})
|
||||
return workflowSearchHighlight ? (
|
||||
<span className='truncate text-[var(--text-primary)]'>
|
||||
{formatDisplayText(selectedKnowledgeBases[0].name, { workflowSearchHighlight })}
|
||||
{formatDisplayText(labelOf(selectedKnowledgeBases[0]), {
|
||||
workflowSearchHighlight,
|
||||
})}
|
||||
</span>
|
||||
) : undefined
|
||||
})()
|
||||
|
||||
+22
-2
@@ -8,7 +8,9 @@ import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w
|
||||
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
|
||||
import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useFolderMap } from '@/hooks/queries/folders'
|
||||
import { useTablesList } from '@/hooks/queries/tables'
|
||||
import { collectDuplicateNames, disambiguateLabelByFolder } from '@/hooks/queries/utils/folder-tree'
|
||||
|
||||
interface TableSelectorProps {
|
||||
blockId: string
|
||||
@@ -45,15 +47,33 @@ export function TableSelector({
|
||||
error,
|
||||
} = useTablesList(isPreview || disabled ? undefined : workspaceId)
|
||||
|
||||
const { data: tableFolders = {} } = useFolderMap(
|
||||
isPreview || disabled ? undefined : workspaceId,
|
||||
'table'
|
||||
)
|
||||
|
||||
const value = isPreview ? previewValue : storeValue
|
||||
const tableId = typeof value === 'string' ? value : null
|
||||
|
||||
/**
|
||||
* Two tables can share a name in different folders, so a colliding name is
|
||||
* suffixed with its folder path. Table names are lowercased for display (the
|
||||
* pre-existing styling here), and collisions are detected on that same
|
||||
* lowercased form so `Leads` and `leads` — identical once displayed — are
|
||||
* disambiguated too. The folder path keeps its authored casing.
|
||||
*/
|
||||
const options = useMemo<ComboboxOption[]>(() => {
|
||||
const duplicateNames = collectDuplicateNames(tables.map((table) => table.name.toLowerCase()))
|
||||
return tables.map((table) => ({
|
||||
label: table.name.toLowerCase(),
|
||||
label: disambiguateLabelByFolder(
|
||||
table.name.toLowerCase(),
|
||||
table.folderId,
|
||||
tableFolders,
|
||||
duplicateNames
|
||||
),
|
||||
value: table.id,
|
||||
}))
|
||||
}, [tables])
|
||||
}, [tables, tableFolders])
|
||||
|
||||
const handleChange = useCallback(
|
||||
(selectedValue: string) => {
|
||||
|
||||
+45
-35
@@ -78,6 +78,35 @@ export const MemoizedActionItem = memo(
|
||||
prev.shortcut === next.shortcut
|
||||
)
|
||||
|
||||
/**
|
||||
* Right-aligned folder breadcrumb. All but the last segment collapse first so a
|
||||
* deep path degrades to the immediate parent rather than truncating the whole
|
||||
* trail. Renders nothing at the workspace root.
|
||||
*/
|
||||
function FolderPathSuffix({ folderPath }: { folderPath?: string[] }) {
|
||||
if (!folderPath || folderPath.length === 0) return null
|
||||
return (
|
||||
<span className='ml-auto flex min-w-0 pl-2 text-[var(--text-subtle)] text-small'>
|
||||
{folderPath.length > 1 && (
|
||||
<>
|
||||
<span className='min-w-0 truncate [flex-shrink:9999]'>
|
||||
{folderPath.slice(0, -1).join(' / ')}
|
||||
</span>
|
||||
<span className='flex-shrink-0 whitespace-pre'> / </span>
|
||||
</>
|
||||
)}
|
||||
<span className='min-w-0 truncate'>{folderPath[folderPath.length - 1]}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Element-wise compare so a rebuilt-but-identical path array skips the re-render. */
|
||||
function sameFolderPath(a?: string[], b?: string[]): boolean {
|
||||
if (a === b) return true
|
||||
if (a?.length !== b?.length) return false
|
||||
return (a ?? []).every((segment, i) => segment === b?.[i])
|
||||
}
|
||||
|
||||
export const MemoizedWorkflowItem = memo(
|
||||
function WorkflowItem({
|
||||
value,
|
||||
@@ -101,19 +130,7 @@ export const MemoizedWorkflowItem = memo(
|
||||
<span className='truncate'>{name}</span>
|
||||
{isCurrent && <span className='flex-shrink-0 whitespace-pre'> (current)</span>}
|
||||
</span>
|
||||
{folderPath && folderPath.length > 0 && (
|
||||
<span className='ml-auto flex min-w-0 pl-2 text-[var(--text-subtle)] text-small'>
|
||||
{folderPath.length > 1 && (
|
||||
<>
|
||||
<span className='min-w-0 truncate [flex-shrink:9999]'>
|
||||
{folderPath.slice(0, -1).join(' / ')}
|
||||
</span>
|
||||
<span className='flex-shrink-0 whitespace-pre'> / </span>
|
||||
</>
|
||||
)}
|
||||
<span className='min-w-0 truncate'>{folderPath[folderPath.length - 1]}</span>
|
||||
</span>
|
||||
)}
|
||||
<FolderPathSuffix folderPath={folderPath} />
|
||||
</Command.Item>
|
||||
)
|
||||
},
|
||||
@@ -121,9 +138,7 @@ export const MemoizedWorkflowItem = memo(
|
||||
prev.value === next.value &&
|
||||
prev.name === next.name &&
|
||||
prev.isCurrent === next.isCurrent &&
|
||||
(prev.folderPath === next.folderPath ||
|
||||
(prev.folderPath?.length === next.folderPath?.length &&
|
||||
(prev.folderPath ?? []).every((segment, i) => segment === next.folderPath?.[i])))
|
||||
sameFolderPath(prev.folderPath, next.folderPath)
|
||||
)
|
||||
|
||||
export const MemoizedFileItem = memo(
|
||||
@@ -143,31 +158,17 @@ export const MemoizedFileItem = memo(
|
||||
<div className='relative flex size-[16px] flex-shrink-0 items-center justify-center'>
|
||||
<File className='size-[14px] text-[var(--text-icon)]' />
|
||||
</div>
|
||||
<span className='flex min-w-0 max-w-[75%] flex-shrink-0 font-base text-[var(--text-body)]'>
|
||||
<span className='flex min-w-0 max-w-[75%] flex-shrink-0 text-[var(--text-body)]'>
|
||||
<span className='truncate'>{name}</span>
|
||||
</span>
|
||||
{folderPath && folderPath.length > 0 && (
|
||||
<span className='ml-auto flex min-w-0 pl-2 font-base text-[var(--text-subtle)] text-small'>
|
||||
{folderPath.length > 1 && (
|
||||
<>
|
||||
<span className='min-w-0 truncate [flex-shrink:9999]'>
|
||||
{folderPath.slice(0, -1).join(' / ')}
|
||||
</span>
|
||||
<span className='flex-shrink-0 whitespace-pre'> / </span>
|
||||
</>
|
||||
)}
|
||||
<span className='min-w-0 truncate'>{folderPath[folderPath.length - 1]}</span>
|
||||
</span>
|
||||
)}
|
||||
<FolderPathSuffix folderPath={folderPath} />
|
||||
</Command.Item>
|
||||
)
|
||||
},
|
||||
(prev, next) =>
|
||||
prev.value === next.value &&
|
||||
prev.name === next.name &&
|
||||
(prev.folderPath === next.folderPath ||
|
||||
(prev.folderPath?.length === next.folderPath?.length &&
|
||||
(prev.folderPath ?? []).every((segment, i) => segment === next.folderPath?.[i])))
|
||||
sameFolderPath(prev.folderPath, next.folderPath)
|
||||
)
|
||||
|
||||
export const MemoizedTaskItem = memo(
|
||||
@@ -253,18 +254,27 @@ export const MemoizedIconItem = memo(
|
||||
onSelect,
|
||||
name,
|
||||
icon: Icon,
|
||||
folderPath,
|
||||
}: {
|
||||
value: string
|
||||
onSelect: () => void
|
||||
name: string
|
||||
icon: ComponentType<{ className?: string }>
|
||||
folderPath?: string[]
|
||||
}) {
|
||||
return (
|
||||
<Command.Item value={value} onSelect={onSelect} className={COMMAND_ITEM_CLASSNAME}>
|
||||
<Icon className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='truncate text-[var(--text-body)]'>{name}</span>
|
||||
<span className='flex min-w-0 max-w-[75%] flex-shrink-0 text-[var(--text-body)]'>
|
||||
<span className='truncate'>{name}</span>
|
||||
</span>
|
||||
<FolderPathSuffix folderPath={folderPath} />
|
||||
</Command.Item>
|
||||
)
|
||||
},
|
||||
(prev, next) => prev.value === next.value && prev.name === next.name && prev.icon === next.icon
|
||||
(prev, next) =>
|
||||
prev.value === next.value &&
|
||||
prev.name === next.name &&
|
||||
prev.icon === next.icon &&
|
||||
sameFolderPath(prev.folderPath, next.folderPath)
|
||||
)
|
||||
|
||||
+5
-3
@@ -17,6 +17,7 @@ import {
|
||||
import type {
|
||||
ActionItem,
|
||||
FileItem,
|
||||
FolderedItem,
|
||||
IntegrationSearchItem,
|
||||
PageItem,
|
||||
TaskItem,
|
||||
@@ -343,8 +344,8 @@ function createIconGroup(
|
||||
items,
|
||||
onSelect,
|
||||
}: {
|
||||
items: TaskItem[]
|
||||
onSelect: (item: TaskItem) => void
|
||||
items: FolderedItem[]
|
||||
onSelect: (item: FolderedItem) => void
|
||||
}) {
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
@@ -352,10 +353,11 @@ function createIconGroup(
|
||||
{items.map((item) => (
|
||||
<MemoizedIconItem
|
||||
key={item.id}
|
||||
value={`${item.name} ${prefix}-${item.id}`}
|
||||
value={`${item.name} ${item.folderPath?.join(' / ') ?? ''} ${prefix}-${item.id}`}
|
||||
onSelect={() => onSelect(item)}
|
||||
name={item.name}
|
||||
icon={icon}
|
||||
folderPath={item.folderPath}
|
||||
/>
|
||||
))}
|
||||
</Command.Group>
|
||||
|
||||
+27
-4
@@ -621,21 +621,44 @@ export function SearchModal({
|
||||
}, [isOnWorkflowPage, docs, deferredSearch])
|
||||
|
||||
const filteredTables = useMemo(
|
||||
() => filterAndCap(tables, (t) => t.name, deferredSearch),
|
||||
() =>
|
||||
filterAndCap(
|
||||
tables,
|
||||
(t) => t.name,
|
||||
deferredSearch,
|
||||
(t) => t.folderPath?.join(' ')
|
||||
),
|
||||
[tables, deferredSearch]
|
||||
)
|
||||
const filteredFiles = useMemo(
|
||||
() => filterAndCap(files, (f) => `${f.name} ${f.folderPath?.join(' ') ?? ''}`, deferredSearch),
|
||||
() =>
|
||||
filterAndCap(
|
||||
files,
|
||||
(f) => f.name,
|
||||
deferredSearch,
|
||||
(f) => f.folderPath?.join(' ')
|
||||
),
|
||||
[files, deferredSearch]
|
||||
)
|
||||
const filteredKnowledgeBases = useMemo(
|
||||
() => filterAndCap(knowledgeBases, (kb) => kb.name, deferredSearch),
|
||||
() =>
|
||||
filterAndCap(
|
||||
knowledgeBases,
|
||||
(kb) => kb.name,
|
||||
deferredSearch,
|
||||
(kb) => kb.folderPath?.join(' ')
|
||||
),
|
||||
[knowledgeBases, deferredSearch]
|
||||
)
|
||||
|
||||
const filteredWorkflows = useMemo(
|
||||
() =>
|
||||
filterAndCap(workflows, (w) => `${w.name} ${w.folderPath?.join(' ') ?? ''}`, deferredSearch),
|
||||
filterAndCap(
|
||||
workflows,
|
||||
(w) => w.name,
|
||||
deferredSearch,
|
||||
(w) => w.folderPath?.join(' ')
|
||||
),
|
||||
[workflows, deferredSearch]
|
||||
)
|
||||
const filteredChats = useMemo(
|
||||
|
||||
+11
-12
@@ -14,11 +14,15 @@ export interface TaskItem {
|
||||
href: string
|
||||
}
|
||||
|
||||
export interface WorkflowItem {
|
||||
id: string
|
||||
name: string
|
||||
href: string
|
||||
/**
|
||||
* A {@link TaskItem} that lives in a folder tree, so the row can show which
|
||||
* folder it came from — a name is only unique within its folder.
|
||||
*/
|
||||
export interface FolderedItem extends TaskItem {
|
||||
folderPath?: string[]
|
||||
}
|
||||
|
||||
export interface WorkflowItem extends FolderedItem {
|
||||
isCurrent?: boolean
|
||||
}
|
||||
|
||||
@@ -39,12 +43,7 @@ export interface PageItem {
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
export interface FileItem {
|
||||
id: string
|
||||
name: string
|
||||
href: string
|
||||
folderPath?: string[]
|
||||
}
|
||||
export type FileItem = FolderedItem
|
||||
|
||||
/** Where an {@link ActionItem} (a verb) is available. */
|
||||
export type ActionContext = 'global' | 'workflow' | 'integrations'
|
||||
@@ -72,9 +71,9 @@ export interface SearchModalProps {
|
||||
workflows?: WorkflowItem[]
|
||||
workspaces?: WorkspaceItem[]
|
||||
chats?: TaskItem[]
|
||||
tables?: TaskItem[]
|
||||
tables?: FolderedItem[]
|
||||
files?: FileItem[]
|
||||
knowledgeBases?: TaskItem[]
|
||||
knowledgeBases?: FolderedItem[]
|
||||
integrations?: IntegrationSearchItem[]
|
||||
connectedAccounts?: IntegrationSearchItem[]
|
||||
isOnWorkflowPage?: boolean
|
||||
|
||||
@@ -44,7 +44,7 @@ import { useSession } from '@/lib/auth/auth-client'
|
||||
import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
|
||||
import { isChatEnabled } from '@/lib/core/config/env-flags'
|
||||
import { isMacPlatform } from '@/lib/core/utils/platform'
|
||||
import { buildFolderTree, getFolderPath } from '@/lib/folders/tree'
|
||||
import { buildFolderTree, getFolderPathNames } from '@/lib/folders/tree'
|
||||
import { captureEvent } from '@/lib/posthog/client'
|
||||
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
@@ -111,6 +111,7 @@ import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
|
||||
import { SIDEBAR_WIDTH } from '@/stores/constants'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import type { WorkflowFolder } from '@/stores/folders/types'
|
||||
import { useSearchModalStore } from '@/stores/modals/search/store'
|
||||
import { useProvidersStore } from '@/stores/providers'
|
||||
import { useSettingsDirtyStore } from '@/stores/settings/dirty/store'
|
||||
@@ -124,6 +125,8 @@ const logger = createLogger('Sidebar')
|
||||
* invalidate every memo downstream of it.
|
||||
*/
|
||||
const EMPTY_CHATS: MothershipChatMetadata[] = []
|
||||
/** Stable identity while a folder list loads, so the search-row memos don't churn. */
|
||||
const EMPTY_FOLDER_MAP: Record<string, WorkflowFolder> = {}
|
||||
|
||||
const SLACK_COMMUNITY_URL =
|
||||
'https://join.slack.com/t/sim-ott9864/shared_invite/zt-43lp8tc5v-0qrrqHGBKUsvQlpoouH~TA'
|
||||
@@ -538,7 +541,17 @@ export const Sidebar = memo(function Sidebar({
|
||||
})
|
||||
|
||||
useFolders(workspaceId)
|
||||
const { data: folderMap = {} } = useFolderMap(workspaceId)
|
||||
const { data: folderMap = EMPTY_FOLDER_MAP } = useFolderMap(workspaceId)
|
||||
// Tables and knowledge bases keep their folders in the generic folder tree,
|
||||
// keyed by resource type, so each needs its own map to resolve a path.
|
||||
const { data: tableFolderMap = EMPTY_FOLDER_MAP } = useFolderMap(
|
||||
permissionConfig.hideTablesTab ? undefined : workspaceId,
|
||||
'table'
|
||||
)
|
||||
const { data: knowledgeBaseFolderMap = EMPTY_FOLDER_MAP } = useFolderMap(
|
||||
permissionConfig.hideKnowledgeBaseTab ? undefined : workspaceId,
|
||||
'knowledge_base'
|
||||
)
|
||||
const updateWorkflowMutation = useUpdateWorkflow()
|
||||
|
||||
const folderTree = useMemo(
|
||||
@@ -712,18 +725,13 @@ export const Sidebar = memo(function Sidebar({
|
||||
|
||||
const searchModalWorkflows = useMemo(
|
||||
() =>
|
||||
regularWorkflows.map((workflow) => {
|
||||
const folderPath = workflow.folderId
|
||||
? getFolderPath(folderMap, workflow.folderId).map((folder) => folder.name)
|
||||
: []
|
||||
return {
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
href: `/workspace/${workspaceId}/w/${workflow.id}`,
|
||||
folderPath: folderPath.length > 0 ? folderPath : undefined,
|
||||
isCurrent: workflow.id === workflowId,
|
||||
}
|
||||
}),
|
||||
regularWorkflows.map((workflow) => ({
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
href: `/workspace/${workspaceId}/w/${workflow.id}`,
|
||||
folderPath: getFolderPathNames(folderMap, workflow.folderId),
|
||||
isCurrent: workflow.id === workflowId,
|
||||
})),
|
||||
[regularWorkflows, folderMap, workspaceId, workflowId]
|
||||
)
|
||||
|
||||
@@ -872,8 +880,9 @@ export const Sidebar = memo(function Sidebar({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
href: `/workspace/${workspaceId}/tables/${t.id}`,
|
||||
folderPath: getFolderPathNames(tableFolderMap, t.folderId),
|
||||
})),
|
||||
[fetchedTables, workspaceId, permissionConfig.hideTablesTab]
|
||||
[fetchedTables, tableFolderMap, workspaceId, permissionConfig.hideTablesTab]
|
||||
)
|
||||
|
||||
const searchModalFiles = useMemo(
|
||||
@@ -897,8 +906,14 @@ export const Sidebar = memo(function Sidebar({
|
||||
id: kb.id,
|
||||
name: kb.name,
|
||||
href: `/workspace/${workspaceId}/knowledge/${kb.id}`,
|
||||
folderPath: getFolderPathNames(knowledgeBaseFolderMap, kb.folderId),
|
||||
})),
|
||||
[fetchedKnowledgeBases, workspaceId, permissionConfig.hideKnowledgeBaseTab]
|
||||
[
|
||||
fetchedKnowledgeBases,
|
||||
knowledgeBaseFolderMap,
|
||||
workspaceId,
|
||||
permissionConfig.hideKnowledgeBaseTab,
|
||||
]
|
||||
)
|
||||
|
||||
const chatIds = useMemo(() => chats.map((t) => t.id), [chats])
|
||||
|
||||
@@ -29,7 +29,7 @@ export const ZohoDeskBlock: BlockConfig<ZohoDeskResponse> = {
|
||||
docsLink: 'https://docs.sim.ai/integrations/zoho_desk',
|
||||
category: 'tools',
|
||||
integrationType: IntegrationType.Support,
|
||||
bgColor: '#E42527',
|
||||
bgColor: '#FFFFFF',
|
||||
icon: ZohoDeskIcon,
|
||||
subBlocks: [
|
||||
{
|
||||
|
||||
@@ -8932,14 +8932,14 @@ export function ZohoDeskIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
viewBox='0 0 24 24'
|
||||
viewBox='-24 -6 308 308'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
aria-hidden='true'
|
||||
>
|
||||
<path
|
||||
d='M12 2.75c-4.28 0-7.75 3.47-7.75 7.75v3.1A2.6 2.6 0 0 0 3 16.35v1.3A2.6 2.6 0 0 0 5.6 20.25h1.15a.9.9 0 0 0 .9-.9v-4.9a.9.9 0 0 0-.9-.9H6.05v-2.15a5.95 5.95 0 0 1 11.9 0v2.15h-.7a.9.9 0 0 0-.9.9v4.9c0 .17.05.33.13.47-.5.6-1.24.98-2.08.98h-1.02a1.4 1.4 0 0 0-1.31-.9h-1a1.4 1.4 0 0 0 0 2.8h1a1.4 1.4 0 0 0 1.31-.9h1.02c2.06 0 3.74-1.63 3.83-3.67a2.6 2.6 0 0 0 1.44-2.33v-1.3a2.6 2.6 0 0 0-1.25-2.22v-3.1c0-4.28-3.47-7.75-7.75-7.75Z'
|
||||
fill='currentColor'
|
||||
d='M22.3,293c-1.6,0-3.3-0.5-4.7-1.4c-2.8-1.8-4.2-5.1-3.7-8.4l24.3-149.3c1.3-8,5.4-15.2,11.5-20.4 c6.1-5.2,14-8.1,22-8.1h100.1l5.6-34.3H168c-4.2,0-8.1-1.8-10.8-5c-2.7-3.2-3.9-7.3-3.2-11.4l6.4-39.7c1.1-6.9,7-11.9,14-11.9h37.3 c10,0,19.4,4.4,25.9,12s9.3,17.6,7.7,27.5l-15,92.4c-0.7,4-3,7.6-6.5,9.8L140,196.5c-5.1,3.1-11.6,2.8-16.3-1l-39-31.1 c-3.7-2.9-4.3-8.3-1.3-11.9c2.9-3.7,8.3-4.3,11.9-1.3l37.5,29.9l81-50.2l14.8-91.1c0.8-4.9-0.6-9.9-3.8-13.7c-3.2-3.8-7.9-6-12.9-6 h-34.9l-5.5,34h9.3c4.2,0,8.1,1.8,10.8,5c2.7,3.2,3.9,7.3,3.2,11.4l-6.5,39.9c-1.1,6.9-7,11.9-14,11.9H71.7 c-8.4,0-15.4,6-16.8,14.3L33.6,267.5L63.5,249c2.2-1.4,4.8-2.1,7.5-2.1h109.5c8.4,0,15.4-6,16.8-14.3l6.8-41.9 c0.8-4.6,5.1-7.8,9.7-7c4.6,0.8,7.8,5.1,7,9.7l-6.8,41.9c-1.3,8-5.4,15.2-11.5,20.4c-6.1,5.2-14,8.1-22,8.1H71.7l-45,27.9 C25.4,292.6,23.8,293,22.3,293z'
|
||||
fill='#089949'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
collectDuplicateNames,
|
||||
disambiguateLabelByFolder,
|
||||
findLockedAncestorFolder,
|
||||
getFolderPath,
|
||||
isFolderEffectivelyLocked,
|
||||
@@ -203,3 +205,50 @@ describe('isFolderEffectivelyLocked', () => {
|
||||
expect(isFolderEffectivelyLocked({ locked: false, parentId: null }, {})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectDuplicateNames', () => {
|
||||
it('returns only names seen more than once', () => {
|
||||
expect(collectDuplicateNames(['a', 'b', 'a', 'c'])).toEqual(new Set(['a']))
|
||||
})
|
||||
|
||||
it('returns an empty set when every name is unique', () => {
|
||||
expect(collectDuplicateNames(['a', 'b', 'c']).size).toBe(0)
|
||||
})
|
||||
|
||||
it('reports a name once no matter how many times it repeats', () => {
|
||||
expect(collectDuplicateNames(['a', 'a', 'a'])).toEqual(new Set(['a']))
|
||||
})
|
||||
|
||||
it('is case sensitive, so callers normalize before collecting', () => {
|
||||
expect(collectDuplicateNames(['Leads', 'leads']).size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('disambiguateLabelByFolder', () => {
|
||||
const folders = {
|
||||
sales: makeFolder({ id: 'sales', name: 'Sales' }),
|
||||
emea: makeFolder({ id: 'emea', name: 'EMEA', parentId: 'sales' }),
|
||||
}
|
||||
|
||||
it('leaves a unique name untouched', () => {
|
||||
expect(disambiguateLabelByFolder('Leads', 'emea', folders, new Set())).toBe('Leads')
|
||||
})
|
||||
|
||||
it('appends the full folder path to a colliding name', () => {
|
||||
expect(disambiguateLabelByFolder('Leads', 'emea', folders, new Set(['Leads']))).toBe(
|
||||
'Leads (Sales / EMEA)'
|
||||
)
|
||||
})
|
||||
|
||||
it('labels a colliding name at the workspace root as Root', () => {
|
||||
expect(disambiguateLabelByFolder('Leads', null, folders, new Set(['Leads']))).toBe(
|
||||
'Leads (Root)'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to Root when the folder is unknown', () => {
|
||||
expect(disambiguateLabelByFolder('Leads', 'deleted', folders, new Set(['Leads']))).toBe(
|
||||
'Leads (Root)'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -54,6 +54,37 @@ export function getFolderPath(
|
||||
return segments.length > 0 ? segments.join(separator) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Names that appear more than once in the list, so callers can disambiguate
|
||||
* only the entries that actually collide.
|
||||
*/
|
||||
export function collectDuplicateNames(names: Iterable<string>): Set<string> {
|
||||
const seen = new Set<string>()
|
||||
const duplicates = new Set<string>()
|
||||
for (const name of names) {
|
||||
if (seen.has(name)) duplicates.add(name)
|
||||
else seen.add(name)
|
||||
}
|
||||
return duplicates
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends the folder path to a name that another item in the same list shares,
|
||||
* e.g. `Leads (Sales / EMEA)` — or `Leads (Root)` at the workspace root. Names
|
||||
* that are already unique are returned untouched so the common case stays
|
||||
* readable.
|
||||
*/
|
||||
export function disambiguateLabelByFolder(
|
||||
name: string,
|
||||
folderId: string | null | undefined,
|
||||
folders: Record<string, WorkflowFolder>,
|
||||
duplicateNames: Set<string>
|
||||
): string {
|
||||
if (!duplicateNames.has(name)) return name
|
||||
const folderPath = getFolderPath(folderId, folders)
|
||||
return folderPath ? `${name} (${folderPath})` : `${name} (Root)`
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the closest locked ancestor folder for the given folderId, or `null`
|
||||
* when neither the folder nor any of its ancestors are locked. Cycles or
|
||||
|
||||
@@ -28,13 +28,19 @@ import type {
|
||||
WorkflowSearchMatch,
|
||||
WorkflowSearchReplacementOption,
|
||||
} from '@/lib/workflows/search-replace/types'
|
||||
import { useFolderMap } from '@/hooks/queries/folders'
|
||||
import { fetchKnowledgeBase, fetchKnowledgeBases } from '@/hooks/queries/kb/knowledge'
|
||||
import {
|
||||
fetchOAuthCredentialDetail,
|
||||
fetchOAuthCredentials,
|
||||
} from '@/hooks/queries/oauth/oauth-credentials'
|
||||
import { collectDuplicateNames, disambiguateLabelByFolder } from '@/hooks/queries/utils/folder-tree'
|
||||
import { getSelectorDefinition, loadAllSelectorOptions } from '@/hooks/selectors/registry'
|
||||
import type { SelectorKey, SelectorOption } from '@/hooks/selectors/types'
|
||||
import type { WorkflowFolder } from '@/stores/folders/types'
|
||||
|
||||
/** Stable identity while a folder list loads, so `select` isn't re-keyed on it. */
|
||||
const EMPTY_FOLDER_MAP: Record<string, WorkflowFolder> = {}
|
||||
|
||||
export interface WorkflowSearchResolvedResource {
|
||||
matchRawValue: string
|
||||
@@ -452,6 +458,10 @@ export function useWorkflowSearchKnowledgeReplacementOptions(
|
||||
matches: WorkflowSearchMatch[],
|
||||
workspaceId?: string
|
||||
) {
|
||||
const { data: knowledgeBaseFolders = EMPTY_FOLDER_MAP } = useFolderMap(
|
||||
workspaceId,
|
||||
'knowledge_base'
|
||||
)
|
||||
const knowledgeGroups = useMemo(
|
||||
() => uniqueResourceOptionGroups(matches, 'knowledge-base'),
|
||||
[matches]
|
||||
@@ -466,15 +476,22 @@ export function useWorkflowSearchKnowledgeReplacementOptions(
|
||||
enabled: Boolean(workspaceId && knowledgeGroups.length > 0),
|
||||
staleTime: WORKFLOW_SEARCH_KNOWLEDGE_REPLACEMENT_STALE_TIME,
|
||||
placeholderData: (previous: KnowledgeBaseData[] | undefined) => previous,
|
||||
select: (knowledgeBases: KnowledgeBaseData[]): WorkflowSearchReplacementOption[] =>
|
||||
knowledgeGroups.flatMap((match) =>
|
||||
select: (knowledgeBases: KnowledgeBaseData[]): WorkflowSearchReplacementOption[] => {
|
||||
const duplicateNames = collectDuplicateNames(knowledgeBases.map((kb) => kb.name))
|
||||
return knowledgeGroups.flatMap((match) =>
|
||||
knowledgeBases.map((knowledgeBase) => ({
|
||||
kind: 'knowledge-base',
|
||||
value: knowledgeBase.id,
|
||||
label: knowledgeBase.name,
|
||||
label: disambiguateLabelByFolder(
|
||||
knowledgeBase.name,
|
||||
knowledgeBase.folderId,
|
||||
knowledgeBaseFolders,
|
||||
duplicateNames
|
||||
),
|
||||
resourceGroupKey: match.resource?.resourceGroupKey,
|
||||
}))
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -485,6 +502,7 @@ export function useWorkflowSearchTableReplacementOptions(
|
||||
workspaceId?: string
|
||||
) {
|
||||
const tableGroups = useMemo(() => uniqueResourceOptionGroups(matches, 'table'), [matches])
|
||||
const { data: tableFolders = EMPTY_FOLDER_MAP } = useFolderMap(workspaceId, 'table')
|
||||
|
||||
return useQueries({
|
||||
queries: [
|
||||
@@ -497,15 +515,23 @@ export function useWorkflowSearchTableReplacementOptions(
|
||||
}),
|
||||
enabled: Boolean(workspaceId && tableGroups.length > 0),
|
||||
staleTime: WORKFLOW_SEARCH_TABLE_REPLACEMENT_STALE_TIME,
|
||||
select: (response: ListTablesResponse): WorkflowSearchReplacementOption[] =>
|
||||
tableGroups.flatMap((match) =>
|
||||
response.data.tables.map((table) => ({
|
||||
select: (response: ListTablesResponse): WorkflowSearchReplacementOption[] => {
|
||||
const tables = response.data.tables
|
||||
const duplicateNames = collectDuplicateNames(tables.map((table) => table.name))
|
||||
return tableGroups.flatMap((match) =>
|
||||
tables.map((table) => ({
|
||||
kind: 'table',
|
||||
value: table.id,
|
||||
label: table.name,
|
||||
label: disambiguateLabelByFolder(
|
||||
table.name,
|
||||
table.folderId,
|
||||
tableFolders,
|
||||
duplicateNames
|
||||
),
|
||||
resourceGroupKey: match.resource?.resourceGroupKey,
|
||||
}))
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { getColumnId } from '@/lib/table/column-keys'
|
||||
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
|
||||
import { getTableDetailQueryOptions } from '@/hooks/queries/tables'
|
||||
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
|
||||
import { getFolderPath } from '@/hooks/queries/utils/folder-tree'
|
||||
import { collectDuplicateNames, disambiguateLabelByFolder } from '@/hooks/queries/utils/folder-tree'
|
||||
import { getWorkflowById, getWorkflows } from '@/hooks/queries/utils/workflow-cache'
|
||||
import { getWorkflowListQueryOptions } from '@/hooks/queries/utils/workflow-list-query'
|
||||
import { SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
|
||||
@@ -13,37 +13,11 @@ import type {
|
||||
SelectorOption,
|
||||
SelectorQueryArgs,
|
||||
} from '@/hooks/selectors/types'
|
||||
import type { WorkflowFolder } from '@/stores/folders/types'
|
||||
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
|
||||
|
||||
/**
|
||||
* Builds a label for a workflow option, appending the folder path when another
|
||||
* workflow in the workspace shares the same display name. Avoids suffixing
|
||||
* every workflow so the dropdown stays readable for the common case.
|
||||
*/
|
||||
function buildDisambiguatedLabel(
|
||||
workflow: WorkflowMetadata,
|
||||
duplicateNames: Set<string>,
|
||||
folders: Record<string, WorkflowFolder>
|
||||
): string {
|
||||
const baseLabel = workflow.name || `Workflow ${workflow.id.slice(0, 8)}`
|
||||
if (!duplicateNames.has(baseLabel)) return baseLabel
|
||||
|
||||
const folderPath = getFolderPath(workflow.folderId, folders)
|
||||
return folderPath ? `${baseLabel} (${folderPath})` : `${baseLabel} (Root)`
|
||||
}
|
||||
|
||||
function collectDuplicateNames(workflows: WorkflowMetadata[]): Set<string> {
|
||||
const counts = new Map<string, number>()
|
||||
for (const workflow of workflows) {
|
||||
const label = workflow.name || `Workflow ${workflow.id.slice(0, 8)}`
|
||||
counts.set(label, (counts.get(label) ?? 0) + 1)
|
||||
}
|
||||
const duplicates = new Set<string>()
|
||||
for (const [label, count] of counts) {
|
||||
if (count > 1) duplicates.add(label)
|
||||
}
|
||||
return duplicates
|
||||
/** Matches the workflow list's own fallback for an unnamed workflow. */
|
||||
function workflowBaseLabel(workflow: WorkflowMetadata): string {
|
||||
return workflow.name || `Workflow ${workflow.id.slice(0, 8)}`
|
||||
}
|
||||
|
||||
export const simSelectors = {
|
||||
@@ -60,12 +34,17 @@ export const simSelectors = {
|
||||
await getQueryClient().ensureQueryData(getWorkflowListQueryOptions(context.workspaceId))
|
||||
const workflows = getWorkflows(context.workspaceId)
|
||||
const folders = getFolderMap(context.workspaceId)
|
||||
const duplicateNames = collectDuplicateNames(workflows)
|
||||
const duplicateNames = collectDuplicateNames(workflows.map(workflowBaseLabel))
|
||||
return workflows
|
||||
.filter((w) => w.id !== context.excludeWorkflowId)
|
||||
.map((w) => ({
|
||||
id: w.id,
|
||||
label: buildDisambiguatedLabel(w, duplicateNames, folders),
|
||||
label: disambiguateLabelByFolder(
|
||||
workflowBaseLabel(w),
|
||||
w.folderId,
|
||||
folders,
|
||||
duplicateNames
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
},
|
||||
@@ -80,10 +59,15 @@ export const simSelectors = {
|
||||
if (!workflow) return null
|
||||
const workflows = getWorkflows(context.workspaceId)
|
||||
const folders = getFolderMap(context.workspaceId)
|
||||
const duplicateNames = collectDuplicateNames(workflows)
|
||||
const duplicateNames = collectDuplicateNames(workflows.map(workflowBaseLabel))
|
||||
return {
|
||||
id: detailId,
|
||||
label: buildDisambiguatedLabel(workflow, duplicateNames, folders),
|
||||
label: disambiguateLabelByFolder(
|
||||
workflowBaseLabel(workflow),
|
||||
workflow.folderId,
|
||||
folders,
|
||||
duplicateNames
|
||||
),
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -65,3 +65,17 @@ export function getFolderPath(
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
/**
|
||||
* Ancestor folder names, root-first — `undefined` at the workspace root. Search
|
||||
* rows key their memo comparison on this, so the empty case must be `undefined`
|
||||
* rather than an empty array.
|
||||
*/
|
||||
export function getFolderPathNames(
|
||||
folders: Record<string, WorkflowFolder>,
|
||||
folderId: string | null | undefined
|
||||
): string[] | undefined {
|
||||
if (!folderId) return undefined
|
||||
const names = getFolderPath(folders, folderId).map((folder) => folder.name)
|
||||
return names.length > 0 ? names : undefined
|
||||
}
|
||||
|
||||
@@ -21818,7 +21818,7 @@
|
||||
"name": "Zoho Desk",
|
||||
"description": "Manage Zoho Desk tickets, comments, threads, and contacts",
|
||||
"longDescription": "Read and update Zoho Desk tickets, manage comments and threads, look up contacts, and download attachments. Can also trigger workflows from Zoho Desk webhook events.",
|
||||
"bgColor": "#E42527",
|
||||
"bgColor": "#FFFFFF",
|
||||
"iconName": "ZohoDeskIcon",
|
||||
"docsUrl": "https://docs.sim.ai/integrations/zoho_desk",
|
||||
"operations": [
|
||||
|
||||
Reference in New Issue
Block a user