Folder Navigation
getFolder returns the contents of a folder — subfolders, assets, filters, and a pagination cursor. CI HUB calls it when the user clicks a folder in the plugin UI, starting with folderId = "root" at the top level.
Inputs
| Field | Description |
|---|---|
requestData.query.folderId | Folder ID to open. "root" for top level |
requestData.query.size | Page size (max assets per page) |
requestData.query.more | Pagination cursor from previous response |
requestData.query.filters | Active filter selections (same format as search) |
Response
Same shape as search:
{
id: string, // echo back the folderId
name: string, // display name
folders: Folder[], // subfolders
assets: Asset[], // assets in this folder
filters: Filter[], // available filters for this folder
more: number | string | undefined, // pagination cursor for next page, undefined = last page
capabilities: FolderCapabilities, // what operations are allowed
totalAssetsCount?: number
}more applies to assets only. Always return all subfolders in a single response (see Subfolder Pagination).
Hardcoded Root Folders
Some adapters define virtual root folders that map to different API endpoints or content types.
Example - boards, collections, and license categories:
getFolder: async (locals, requestData) => {
try {
const folderNames = {
'all': 'All content',
'downloads': 'Downloaded content',
'boards': 'Boards',
'collections': 'Collections',
'licensed': 'Licensed Assets',
'videos:creative': 'Creative',
'videos:editorial': 'Editorial',
'images:creative': 'Creative',
'images:editorial': 'Editorial'
}
const folderId = requestData.query.folderId || 'root'
const page = Number.parseInt(requestData.query.more || 0, 10)
const size = Math.min(Number.parseInt(requestData.query.size || 50, 10), config.get('maxQuerySize'))
let result = {
folders: [],
assets: [],
more: undefined,
capabilities: defaultFolderCapabilities
}
if (folderId === 'root') {
result.folders = Object.keys(folderNames)
.filter(folder => !['videos:creative', 'videos:editorial', 'images:creative', 'images:editorial'].includes(folder))
.map(folder => ({ id: folder, name: folderNames[folder], uploadUrl: '' }))
}
// ... handle each folder type
}
}Example - DAM with collections, assets, work-in-progress, projects, and entities:
getFolder: async (locals, requestData) => {
try {
let size = Math.min(parseInt(requestData.query.size || 50, 10), config.get('maxQuerySize'))
const more = parseInt(requestData.query.more || 0, 10) || 0
const facetQuery = getFilterQuery(requestData.query)
const folderCapabilities = await getFolderCapabilities(locals)
const { canAddAsset, canAddFolder, canDeleteFolder } = folderCapabilities
const result = {
id: `${requestData.query.folderId}`,
name: `${requestData.query.folderId}`,
more: undefined,
assets: [],
folders: [],
filters: [],
capabilities: { ...defaultFolderCapabilities, canAddAsset },
totalAssetsCount: 0
}
if (requestData.query.folderId === 'root') {
result.folders = [
{ id: 'collections', name: 'Collections' },
{ id: 'assets', name: 'Assets' },
{ id: 'wip', name: 'Work In Progress' },
{ id: 'projects', name: 'Projects' },
{ id: 'entities', name: 'Entities' }
]
}
// ... handle each folder type with different API calls and query configs
}
}Folder ID Prefixing
When a single adapter exposes multiple content types (libraries, collections, boards), use prefixed IDs to route to the correct API in subsequent getFolder calls.
Example - DAM with libraries and collections:
getFolder: async (locals, requestData) => {
try {
const folderId = (requestData.query.folderId as string).toString()
const size = Math.min(parseInt((requestData.query.size as string) || '25', 10), config.get('maxQuerySize'))
const more = parseInt((requestData.query.more as string) || '1', 10)
const { brandId } = locals.adapterData
const result: any = {
id: folderId, assets: [], folders: [], filters: [],
capabilities: { ...defaultFolderCapabilities, ...defaultAssetCapabilities },
more: undefined, totalAssetsCount: 0
}
if (folderId === 'root') {
const libraries = await callApi(locals, {
query: queries.getGetBrandLibrariesQuery,
variables: { brandId, page: more, limit: size }
})
// Prefix library IDs so getFolder can route back to the library API
result.folders = libraries.data.brand.libraries.items.map(
(library: any) => ({ id: `library:${library.id}`, name: library.name })
)
result.more = libraries.data.brand.libraries.hasNextPage ? more + 1 : undefined
} else if (folderId.startsWith('library:')) {
const libraryId = folderId.replace('library:', '')
const assetsRes = await callApi(locals, {
query: queries.getGetLibraryAssetsQuery,
variables: { libraryId, page: more, limit: size }
})
const { library } = assetsRes.data
const { browse, collections, currentUserPermissions } = library
result.assets = extractAssetsData(locals, browse.assets.items)
result.more = browse.assets.hasNextPage ? more + 1 : undefined
result.folders = browse.folders.items?.map(
(folder: any) => ({ id: `${folder.id}`, name: folder.name })
)
if (collections.total > 0) {
result.folders.push({ id: `collections:${libraryId}`, name: 'Collections' })
}
result.capabilities.canAddFolder = true
result.capabilities.canAddAsset = currentUserPermissions.canCreateAssets
} else if (folderId.startsWith('collections:')) {
const libraryId = folderId.replace('collections:', '')
const collections = await callApi(locals, {
query: queries.getGetLibraryCollectionsQuery,
variables: { libraryId }
})
result.folders = collections.data.library.collections.items.map(
(collection: any) => ({ id: `collection:${collection.id}`, name: collection.name })
)
} else if (folderId.startsWith('collection:')) {
const collectionId = folderId.replace('collection:', '')
const assets = await callApi(locals, {
query: queries.getCollectionAssetsQuery,
variables: { collectionId, page: more, limit: size }
})
const canDeleteAsset = assets.data.node.currentUserPermissions.canRemoveAssets || false
result.assets = extractAssetsData(locals, assets.data.node.assets.items, { canDeleteAsset })
result.more = assets.data.node.assets.hasNextPage ? more + 1 : undefined
}
// ...
return send(result)
}
}The prefix → API routing pattern: library:abc → Libraries API, collection:xyz → Collections API, board:123 → Boards API.
Example - uses the same approach for boards and collections:
} else if (folderId === 'boards') {
const boards = await getBoards(system, locals)
for (const board of boards.boards) {
result.folders.push({
id: `board:${board.id}`,
name: board.name,
uploadUrl: ''
})
}
} else if (folderId === 'collections') {
const collections = await getCollections(system, locals)
collections.collections.forEach(collection => {
result.folders.push({
id: `collection:${collection.code};${collection.asset_family}`,
name: collection.name,
uploadUrl: ''
})
})
} else if (folderId.startsWith('board:')) {
const id = folderId.substring(6)
const board = await getBoard(system, locals, id)
if (board.ids.length > 0) {
result = await getAssets(system, locals, board.ids, undefined, undefined, undefined, `/Boards/${board.name}`, page, size)
}
} else if (folderId.startsWith('collection:')) {
const id = folderId.substring(11)
result = await getAssets(system, locals, undefined, id, undefined, undefined, `/Collections/${id.split(';')[0]}`, page, size)
}Subfolder Pagination
more in the response only applies to assets. Always fetch all subfolders — use a while-loop if the platform paginates folder listings:
let allFolders = []
let folderPage = 1
let hasMore = true
while (hasMore) {
const res = await callApi(locals, {
query: queries.getSubfolders,
variables: { folderId, page: folderPage, limit: 100 }
})
allFolders = allFolders.concat(res.data.folders.items)
hasMore = res.data.folders.hasNextPage
folderPage++
}
result.folders = allFolders.map(f => ({ id: f.id, name: f.name }))
result.more = assetsResponse.hasNextPage ? nextAssetCursor : undefinedIf the API requests to get all subfolders are costly and slow, you can return all subfolders only in the first getFolder call.
in the first getFolder call you will have more = undefined.
example:
getFolder: async (locals, requestData) => {
try {
const more = requestData.query.more
if (!more) {
// get all subfolders
}
return send(result)
}
}This works because the CI HUB plugin will merge the results of the first getFolder call with the results of the subsequent getFolder calls.
Per-Folder Capabilities
Spread defaultFolderCapabilities and override per folder type. The defaults are all false:
import { defaultFolderCapabilities, defaultAssetCapabilities } from '@ci-hub/integration-sdk'
// defaultFolderCapabilities = { canDeleteFolder: false, canAddFolder: false, canAddAsset: false }
// defaultAssetCapabilities = { canDeleteAsset: false, canUpdateAsset: false, canLockAsset: false, canUnlockAsset: false }Override capabilities based on the folder type and user permissions:
// Read-only folder (boards listing)
result.capabilities = defaultFolderCapabilities
// Writable folder with upload
result.capabilities = {
...defaultFolderCapabilities,
canAddFolder: true,
canAddAsset: currentUserPermissions.canCreateAssets
}
// Full permissions subfolder
result.capabilities = {
...defaultFolderCapabilities,
canAddFolder: true,
canAddAsset: true,
canDeleteFolder: true,
canDeleteAsset: true,
canUpdateAsset: true
}UI Behavior
folderId = "root"→ CI HUB shows the top-level folder list after login- Clicking a folder → calls
getFolderwith that folder'sid foldersarray → rendered as clickable folder itemsassetsarray → rendered as asset thumbnails/list itemsmoredefined → "Load More" button appears, clicks sendmorevalue backcapabilities.canAddAsset = true→ upload button appears in foldercapabilities.canAddFolder = true→ "New Folder" option appears