Beyond Permissions: Customizing Asset Action Visibility in Sitecore Content Hub
Hello everyone, Welcome to another blogpost. This blog will focus on Sitecore Content Hub development. We will discuss a use case that is highly relevant to practical development and reflects real-world business scenario.
Let's get started without wasting any more time. Sitecore Content Hub provides a powerful security model through User Groups, Policies, Permissions, and Conditions. While these controls determine what users can do within the platform, they do not always control what users see in the user interface. Our blog will be exactly around this functionality.
We received a requirement to restrict the download of certain assets from the Asset Search page. Specifically, any asset tagged with a particular taxonomy value should not be available for download by users.
We explored using policies and permissions, particularly DRM rights in Content Hub, to address this requirement. However, none of the available configuration options provided a way to hide the Download button on the asset card for assets tagged with the specified taxonomy value. At this time, Sitecore Content Hub external component came into picture. We will discuss the exact steps that we followed and how we accomplish the same using react external component.
Before moving on with implemenation part, would like to tell you what exactly is mentioned regarding permission for operation in content hub documentation
Understanding the Limitation of Permissions
According to the Content Hub documentation:Permissions are always positive. You cannot deny permissions using user group policies.This means:Permissions determine whether an action can be executed.Permissions do not always determine whether an action is displayed.UI actions such as Download may still appear even when the user cannot successfully execute them.
Solution Overview
When there is restriction from product end, we have our trump card that is Sitecore Content Hub external component.
Before we take a closer look at the solution, I'd like to explain the thought process and steps I followed while designing it
- Step1 : I wanted to execute my external component once the search operation is finished
- Step2 : I wanted to keep an eye on my document node whenever pagination happens or any filter query is executed for that search
- Step3 : I wanted an indicator on my asset card which i can use to perform some action on the operation.
Let's focus on solution now
- In Content Hub, whenever the Search component finishes loading results, the SEARCH_FINISHED event is triggered. We can register a window event listener to listen for this event and execute custom logic once the search results are rendered. Open your search component on the page in Content Hub and get the identifier of that component. This will be used to compare the identifer what we get from SEARCH_FINISHED event as there are chances, a single page can contain multiple search component. This will bind our react component execution to the exact search component what we need. Add it as configuration.
- Next, we can implement a MutationObserver to monitor the Search component's DOM node. This allows us to detect and respond to any changes made to the search results dynamically.
- Add a taxonomy field to the Asset card that contains a unique identifier associated with the Asset entity. In the Search Results Grid layout, use this taxonomy value rule to add an indicator which we will use to hide or show our download operation. The taxonomy itself remains hidden from users and is used solely for conditional rendering logic.
I won't cover the steps for creating and registering an External Component in Content Hub in this article. I've included a reference blog below that walks through the process in detail. You can also explore the community documentation for additional examples and implementation patterns
Create a react component in your repo. Add a index.tsx file and actual component file HideDownLoadButtonOnAssetPage.tsx file. Code is shared below. The selector might different from your css implementation but the base logic will remain same.
// index.tsx
import { createRoot, type Root } from 'react-dom/client';
import HideDownloadButtonOnAssetPage, { type Context } from './HideDownLoadButtonOnAssetPage';
export default function createExternalRoot(container: HTMLElement) {
let root: Root | null = null;
const host = document.createElement('div');
container.innerHTML = '';
container.style.display = 'none';
return {
render(context: Context) {
if (!host.isConnected) document.body.appendChild(host);
if (!root) root = createRoot(host);
root.render(
<HideDownloadButtonOnAssetPage
config={context?.config ?? {}}
context={context}
/>
);
},
unmount() {
root?.unmount();
root = null;
host.remove();
container.innerHTML = '';
},
};
}
// HideDownLoadButtonOnAssetPage.tsx.tsx
import { useCallback, useEffect, useRef } from 'react';
const ITEM_SELECTOR = "div.selectable-content-hub-item[data-definition-name='M.Asset']";
const FIELD_SELECTOR = "span[data-testid='multi-condition-indicator'] span:last-child";
const BUTTON_SELECTOR = "button[aria-label='Download']";
const HIDE_FOR_VALUES = ['Kol'];
export interface Config {
searchIdentifier?: string;
}
export interface Context {
config?: Config;
api?: {
search?: {
getEventSearchIdentifier?: (searchIdentifier: string) => string;
};
};
}
// ─── Component ────────────────────────────────────────────────────────────────
const HideDownloadButtonOnAssetPage = ({ config, context }: { config: Config; context: Context }) => {
const { searchIdentifier = '' } = config;
const containerSelector = searchIdentifier
? `[id^="search-scroll-wrapper-${searchIdentifier}"]`
: '#search-main-area';
const observerRef = useRef<MutationObserver | null>(null);
const rafRef = useRef<number | null>(null);
const retryRef1 = useRef<number | null>(null);
const retryRef2 = useRef<number | null>(null);
// Resolve the event search identifier using the Content Hub API
const resolvedSearchIdentifier = (() => {
if (!searchIdentifier) return '';
try {
return context?.api?.search?.getEventSearchIdentifier?.(searchIdentifier) ?? searchIdentifier;
} catch {
return searchIdentifier;
}
})();
const processItems = useCallback(() => {
const container = document.querySelector(containerSelector);
if (!container) return;
container.querySelectorAll(ITEM_SELECTOR).forEach((item) => {
if (!(item instanceof HTMLElement)) return;
const value = item.querySelector(FIELD_SELECTOR)?.textContent?.trim().toLowerCase() ?? '';
const button = item.querySelector<HTMLElement>(BUTTON_SELECTOR);
if (button) {
const shouldHide = HIDE_FOR_VALUES.includes(value);
button.style.display = shouldHide ? 'none' : '';
button.style.visibility = shouldHide ? 'hidden' : '';
button.style.width = shouldHide ? '0' : '';
button.style.overflow = shouldHide ? 'hidden' : '';
}
});
}, [containerSelector]);
useEffect(() => {
const clearRaf = () => {
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
const clearAll = () => {
observerRef.current?.disconnect();
observerRef.current = null;
if (retryRef1.current != null) { window.clearTimeout(retryRef1.current); retryRef1.current = null; }
if (retryRef2.current != null) { window.clearTimeout(retryRef2.current); retryRef2.current = null; }
clearRaf();
};
const schedule = () => {
clearRaf();
rafRef.current = window.requestAnimationFrame(() => {
rafRef.current = window.requestAnimationFrame(() => {
processItems();
// run again after short delays to catch late-rendered buttons
retryRef1.current = window.setTimeout(processItems, 300);
retryRef2.current = window.setTimeout(processItems, 800);
rafRef.current = null;
});
});
};
// Start a persistent observer that stays alive for the component lifetime.
// Applying a filter facet triggers a new SEARCH_FINISHED and re-renders items;
// the observer must keep watching so buttons are hidden after every re-render.
const ensureObserver = () => {
if (observerRef.current) return;
observerRef.current = new MutationObserver(schedule);
const observeTarget = document.querySelector(containerSelector) ?? document.body;
observerRef.current.observe(observeTarget, { childList: true, subtree: true });
};
const onSearchFinished = (event: Event) => {
const detail = (event as CustomEvent<{ searchIdentifier: string }>).detail;
const eventId = detail?.searchIdentifier ?? '';
// Match against resolved identifier
if (resolvedSearchIdentifier) {
const stripped = eventId.substring(0, eventId.lastIndexOf('-')) || eventId;
if (
eventId !== resolvedSearchIdentifier &&
stripped !== resolvedSearchIdentifier &&
eventId !== searchIdentifier &&
stripped !== searchIdentifier
) {
return;
}
}
schedule();
ensureObserver();
};
// Listen for Content Hub search finished events
window.addEventListener('SEARCH_FINISHED', onSearchFinished);
// Also run on initial load with a delay to catch already-rendered results
const initialTimeout = window.setTimeout(() => {
schedule();
ensureObserver();
}, 500);
return () => {
window.removeEventListener('SEARCH_FINISHED', onSearchFinished);
window.clearTimeout(initialTimeout);
clearAll();
};
}, [processItems, resolvedSearchIdentifier, searchIdentifier]);
return null;
}
export default HideDownloadButtonOnAssetPage;
Note: The css selector might be different which we can adjust according to our need but the core logic remain the same. The solution include a schedule function which get triggered after some time, then Window Request animation frame for smooth transistion with mutation observer.
And this way, you can extend your OOTB search component in Sitecore content hub.
Thanks for reading and keep learning !!!
You can check my other blogs too if interested. Blog Website
References
- https://sitecoreforu.blogspot.com/2024/05/understanding-external-component-in-content-hub-with-use-case-using-react.html
- https://sitecoreforu.blogspot.com/2023/10/creating-custom-component-in-content-hub-using-external-component.html
- https://doc.sitecore.com/ch/en/users/content-hub/permissions.html




Comments
Post a Comment