Filters
Filters let users narrow search and folder results in the plugin UI. They are returned in every search and getFolder response and re-rendered after each call — you rebuild the full filter list every time.
How It Works
- Your handler returns
result.filters— an array of filter groups, each withoptions. - The UI renders them and sends back only the IDs of selected options in
requestData.query.filters[]. - You parse those IDs, apply them to your API call, and return updated filters with
isActiveset correctly on every option.
If you don't set isActive, the UI won't show what's selected after the next search.
parseFilters Utility
The SDK exports parseFilters from @ci-hub/integration-sdk — a simple utility for single-select filters:
import { parseFilters } from '@ci-hub/integration-sdk'
parseFilters(['status:active', 'order:newest'])
// → { status: 'active', order: 'newest' }parseFilters splits each string on : and builds a Record<string, string>. It works for single-select filters where each key has one value. If the same key appears multiple times, the last value wins.
multi$ Prefix
For multi-select filters, prefix the filter key with multi$ in the option ID. The SDK's built-in parseFilters does not handle multi$ — use the custom parser shown in the canonical example below. Strip multi$ from the key and collect values into an array.
Static Filters — Canonical Example
This is the standard pattern for filters with known, fixed options:
const activeFilters: string[] = requestData.query.filters || []
const parseFilters = (filters: string[]) =>
filters.reduce((acc: Record<string, string | string[]>, filter: string) => {
const [key, value] = filter.split(':')
if (key.startsWith('multi$')) {
const multiKey = key.replace('multi$', '')
acc[multiKey] = acc[multiKey] || []
;(acc[multiKey] as string[]).push(value)
} else {
acc[key] = value
}
return acc
}, {})
const parsedFilters = parseFilters(activeFilters)
const buildStaticFilters = (parsedFilters: Record<string, string | string[]>) => [
{
id: 'status',
name: 'Status',
type: 'SELECT',
options: [
{ id: 'status:active', name: 'Active', isActive: parsedFilters.status === 'active' },
{ id: 'status:archived', name: 'Archived', isActive: parsedFilters.status === 'archived' },
{ id: 'status:draft', name: 'Draft', isActive: parsedFilters.status === 'draft' },
],
},
{
id: 'mediaType',
name: 'Media Type',
type: 'SELECT',
options: [
{ id: 'multi$mediaType:image', name: 'Image', isActive: (parsedFilters.mediaType || []).includes('image') },
{ id: 'multi$mediaType:video', name: 'Video', isActive: (parsedFilters.mediaType || []).includes('video') },
{ id: 'multi$mediaType:document', name: 'Document', isActive: (parsedFilters.mediaType || []).includes('document') },
],
},
]Key points:
- Single-select (
status): option ID iskey:value, compare with=== - Multi-select (
mediaType): option ID ismulti$key:value, compare with.includes() - Every option has
isActiveset — even whenfalse
Dynamic Filters from API
When filter options come from API responses (facets, categories, etc.), build the filter array dynamically. This pattern from ExampleAdapter1 converts API facets into CI HUB filters:
const encodeId = (str) => {
if (!str) return ''
const encoded = str.replace(/[^A-Za-z0-9\-_:.]/g, char =>
`_x${char.charCodeAt(0).toString(16).padStart(4, '0')}_`
)
return `id_${encoded}`
}
const decodeId = (encodedStr) => {
return encodedStr
.replace(/^id_/, '')
.replace(/_x([0-9a-f]{4})_/g, (_, hex) =>
String.fromCharCode(parseInt(hex, 16))
)
}
const getFiltersFromFacets = (activeFiltersJson = {}, facets = {}) => {
const filters = []
Object.keys(facets)
.filter(key => facets[key].buckets?.length > 0)
.forEach(key => {
filters.push({
id: encodeId(key),
name: key,
options: facets[key].buckets.filter(bucket => bucket.key).map(bucket => ({
id: `${encodeId(key)}:${encodeId(bucket.key)}`,
name: bucket.key,
count: bucket.doc_count,
isActive: activeFiltersJson[encodeId(key)] === encodeId(bucket.key)
}))
})
})
return filters.filter(filter => filter.options.length > 0 && filter.id)
}This adapter also converts selected filters back into API query terms:
const filtersToTerms = (activeFiltersJson = {}) => {
const terms = []
Object.keys(activeFiltersJson).forEach(key => {
if (key.startsWith('id_')) {
const filterKey = decodeId(key)
const filterValue = decodeId(activeFiltersJson[key])
if (filterValue && filterKey) {
terms.push(`(${filterKey}:"${filterValue}")`)
}
}
})
return terms
}Notice the encodeId/decodeId pattern — because the API returns facet names with spaces and special characters, the adapter encodes them into safe HTML IDs and decodes them back when building API queries.
Conditional Filters
ExampleAdapter2 pre-defines all possible filters and uses guard functions to show/hide them based on context (search type, asset type, config flags):
const allFilters = [
{
...assetTypeFilter,
guard: (searchType) => searchType === 'text' || searchType === 'collection',
singleOption: true
},
{
id: 'order',
name: 'Sort Order',
type: 'SELECT',
options: [
{ id: 'order:best_match', name: 'Best Match' },
{ id: 'order:newest', name: 'Newest' },
{ id: 'order:oldest', name: 'Oldest' },
]
},
// ... more filters
]
// In search handler:
const filters = allFilters.filter(filter => {
if (filter.guard) return filter.guard(searchType, assetType)
return true
})
for (const filter of filters) {
for (const option of filter.options) {
const [key, value] = option.id.split(':')
option.isActive = filterOptions[key] === value
}
}
result.filters = filtersSome filter options are populated dynamically (from API artist/event facets) while the filter structure itself is predefined.
ID Rules
All filter and option IDs must be unique and valid HTML id attributes.
Avoid separator conflicts: if you use : as your ID separator (the standard pattern) but your API returns dynamic IDs containing :, your parsing will break. The encodeId/decodeId pattern from the dynamic filters example above solves this.
Pre-search Filters
Filters returned from the info handler are pre-search filters — set once when the adapter initializes, not updatable during search. Use them only for filters that apply universally (e.g., a global content-type toggle). For anything that depends on search context, return filters from search/getFolder instead.
Pagination and Subfolders
The more parameter in search and getFolder responses is for assets only. For subfolders, fetch all subfolders in a loop before returning — the UI does not paginate subfolders.