This Confluence user macro provides a permission-based status selector that saves the selected value as a page property via the Confluence Cloud REST API.
It checks edit access using either page update restrictions (onlyEdit=true) or optional allowedGroups validation.
The macro renders a dropdown with predefined status sets and loads the current value from /api/v2/pages/{id}/properties.
When changed, it creates or updates the property with proper versioning.
#set ($contentId = $page.id)
#set ($propertyKey = $macroId)
#set ($canEdit = true)
#if ($parameters.get("onlyEdit") == "true")
## onlyEdit = true: check page edit restrictions ONLY, allowedGroups is ignored entirely
#set ($resp = $ConfluenceManager.get("/wiki/rest/api/content/${page.id}/restriction/byOperation/update"))
#if ($resp && $resp.restrictions)
#set ($userResults = $resp.restrictions.user.results)
#set ($groupResults = $resp.restrictions.group.results)
#if ($userResults.size() > 0 || $groupResults.size() > 0)
#set ($canEdit = false)
#foreach ($u in $userResults)
#if ($u.accountId == $user.accountId)
#set ($canEdit = true)
#end
#end
#end
#end
#else
## onlyEdit = false: check allowedGroups if provided
#if ($parameters.get("allowedGroups") && $parameters.get("allowedGroups") != "")
#set ($canEdit = false)
#set ($memberResp = $ConfluenceManager.get("/wiki/rest/api/user/memberof?accountId=${user.accountId}"))
#if ($memberResp && $memberResp.results)
#set ($allowedList = $StringUtils.split($parameters.get("allowedGroups"), ","))
#foreach ($group in $memberResp.results)
#foreach ($allowed in $allowedList)
#if ($group.name == $StringUtils.trim($allowed))
#set ($canEdit = true)
#end
#end
#end
#end
#end
#end
#set ($multiselect = $parameters.get("multiselect") == "true")
#set ($allowBlank = $parameters.get("blank") == "true")
<style>
.sw-${macroId} {
position: relative;
display: inline-block;
min-width: fit-content;
font-weight: 600;
visibility: hidden;
}
.sw-${macroId} .status-box {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
border-radius: 6px;
cursor: pointer;
transition: 0.15s ease;
user-select: none;
}
.sw-${macroId} .status-box.readonly {
cursor: default;
}
.sw-${macroId} .status-arrow {
margin-left: 10px;
transition: transform 0.15s ease;
flex-shrink: 0;
}
.sw-${macroId} .status-arrow.open {
transform: rotate(180deg);
}
.sw-${macroId} .status-menu {
position: absolute;
top: 110%;
left: 0;
background: var(--ds-surface-overlay, #ffffff);
border-radius: 6px;
box-shadow: var(--ds-shadow-overlay, 0 4px 8px rgba(9, 30, 66, 0.25));
overflow: hidden;
display: none;
z-index: 100;
max-height: 260px;
overflow-y: auto;
border: 1px solid var(--ds-border, #d0d4db);
}
.sw-${macroId} .status-option {
padding: 10px 14px;
cursor: pointer;
color: var(--ds-text, #172b4d);
background: var(--ds-surface-overlay, #ffffff);
}
.sw-${macroId} .status-option:hover {
background: var(--ds-surface-hovered, #f1f2f4);
}
.sw-${macroId} .status-option.selected {
background: var(--ds-background-selected, #e9f2ff);
}
.sw-${macroId} .status-option.selected::before {
content: "✓ ";
font-weight: bold;
}
.sw-${macroId} .status-option.blank-option {
min-height: 38px;
border-bottom: 1px solid var(--ds-border, #d0d4db);
}
.status-default {
background: var(--ds-background-neutral, #dcdfe4);
color: var(--ds-text-subtle, #44546f);
}
.status-grey {
background: var(--ds-background-neutral, #dcdfe4);
color: var(--ds-text-subtle, #44546f);
}
.sw-${macroId} .multi-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
flex: 1;
}
.sw-${macroId} .multi-tag {
background: var(--ds-background-neutral-subtle, rgba(9, 30, 66, 0.08));
color: var(--ds-text-subtle, #44546f);
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
}
</style>
<div class="sw-${macroId}" id="sw-${macroId}">
<div id="box-${macroId}" class="status-box status-default">
<span id="txt-${macroId}">Select status</span>
<span id="arr-${macroId}" class="status-arrow" style="#if(!$canEdit) display:none; #end">▼</span>
</div>
<div id="menu-${macroId}" class="status-menu"></div>
</div>
<script>
AJS.toInit(() => {
const contentId = "$contentId";
const propertyKey = "$propertyKey";
const statusSet = "$!parameters.get('set')" || "Months";
const canEdit = $canEdit;
const multiselect = $multiselect;
const allowBlank = $allowBlank;
const mid = "$macroId";
const STATUS_SETS = {
"Months": {
grey: true,
options: [{
value: "january", label: "JANUARY"
},
{
value: "february", label: "FEBRUARY"
},
{
value: "march", label: "MARCH"
},
{
value: "april", label: "APRIL"
},
{
value: "may", label: "MAY"
},
{
value: "june", label: "JUNE"
},
{
value: "july", label: "JULY"
},
{
value: "august", label: "AUGUST"
},
{
value: "september", label: "SEPTEMBER"
},
{
value: "october", label: "OCTOBER"
},
{
value: "november", label: "NOVEMBER"
},
{
value: "december", label: "DECEMBER"
}]
},
"Contacts": {
grey: true,
options: [{
value: "ceo", label: "CEO"
},
{
value: "cfo", label: "CFO"
},
{
value: "cmo", label: "CMO"
},
{
value: "coo", label: "COO"
},
{
value: "cto", label: "CTO"
},
{
value: "fulltime", label: "FULL-TIME EMPLOYEE"
},
{
value: "parttime", label: "PART-TIME EMPLOYEE"
},
{
value: "trainee", label: "TRAINEE"
}]
},
"File Extensions": {
grey: true,
options: [{
value: "doc", label: ".DOC"
},
{
value: "pdf", label: ".PDF"
},
{
value: "txt", label: ".TXT"
},
{
value: "jpg", label: ".JPG"
},
{
value: "gif", label: ".GIF"
},
{
value: "mp3", label: ".MP3"
},
{
value: "html", label: ".HTML"
},
{
value: "exe", label: ".EXE"
}]
},
"Days": {
grey: true,
options: [{
value: "monday", label: "MONDAY"
},
{
value: "tuesday", label: "TUESDAY"
},
{
value: "wednesday", label: "WEDNESDAY"
},
{
value: "thursday", label: "THURSDAY"
},
{
value: "friday", label: "FRIDAY"
},
{
value: "saturday", label: "SATURDAY"
},
{
value: "sunday", label: "SUNDAY"
}]
}
};
const setDef = STATUS_SETS[statusSet] || STATUS_SETS["Months"];
const isGrey = setDef.grey || false;
const options = setDef.options;
const wrapper = document.getElementById("sw-" + mid);
const box = document.getElementById("box-" + mid);
const text = document.getElementById("txt-" + mid);
const menu = document.getElementById("menu-" + mid);
const arrow = document.getElementById("arr-" + mid);
let propertyId = null;
let propertyVersion = null;
let isOpen = false;
let selected = multiselect ? []: null;
let resizeTimer = null;
if (canEdit) {
if (allowBlank) {
const blankDiv = document.createElement("div");
blankDiv.className = "status-option blank-option";
blankDiv.dataset.value = "";
blankDiv.textContent = "\u200B";
blankDiv.addEventListener("click", () => {
if (multiselect) {
selected = [];
updateMenuSelection();
applyStatus(selected);
saveProperty(selected);
} else {
applyStatus("");
toggleMenu(false);
saveProperty("");
}
});
menu.appendChild(blankDiv);
}
options.forEach(opt => {
const div = document.createElement("div");
div.className = "status-option";
div.dataset.value = opt.value;
div.textContent = opt.label;
div.addEventListener("click", () => {
if (multiselect) {
const idx = selected.indexOf(opt.value);
if (idx > -1) selected.splice(idx, 1);
else selected.push(opt.value);
updateMenuSelection();
applyStatus(selected);
saveProperty(selected);
} else {
applyStatus(opt.value);
toggleMenu(false);
saveProperty(opt.value);
}
});
menu.appendChild(div);
});
}
const updateMenuSelection = () => {
if (!multiselect) return;
menu.querySelectorAll(".status-option:not(.blank-option)").forEach(el => {
el.classList.toggle("selected", selected.includes(el.dataset.value));
});
};
const resize = () => {
requestAnimationFrame(() => {
const boxH = box.offsetHeight;
const menuH = isOpen ? menu.offsetHeight: 0;
const total = boxH + menuH + 24;
AP.resize("100%", total);
});
};
const toggleMenu = (forceState) => {
if (!canEdit) return;
isOpen = forceState !== undefined ? forceState: !isOpen;
if (isOpen) {
const rect = box.getBoundingClientRect();
menu.style.display = "block";
menu.style.position = "fixed";
menu.style.top = rect.bottom + "px";
menu.style.left = rect.left + "px";
menu.style.width = rect.width + "px";
} else {
menu.style.display = "none";
menu.style.position = "absolute";
menu.style.width = "100%";
}
arrow.classList.toggle("open", isOpen);
resize();
};
const applyStatus = (value) => {
box.className = "status-box";
if (!canEdit) box.classList.add("readonly");
if (multiselect) {
box.classList.add("status-grey");
text.innerHTML = "";
if (!value || value.length === 0) {
text.textContent = "Select status";
box.className = "status-box status-default";
if (!canEdit) box.classList.add("readonly");
} else {
const tagsDiv = document.createElement("span");
tagsDiv.className = "multi-tags";
value.forEach(v => {
const tag = document.createElement("span");
tag.className = "multi-tag";
const match = options.find(o => o.value === v);
tag.textContent = match ? match.label: v.replace(/_/g, " ").toUpperCase();
tagsDiv.appendChild(tag);
});
text.innerHTML = "";
text.appendChild(tagsDiv);
}
resize();
return;
}
if (!value && value !== "") {
box.classList.add("status-default");
text.textContent = "Select status";
return;
}
if (value === "") {
box.classList.add("status-grey");
text.textContent = "\u200B";
return;
}
box.classList.add(isGrey ? "status-grey": "status-" + value);
const match = options.find(o => o.value === value);
text.textContent = match ? match.label: value.replace(/_/g, " ").toUpperCase();
};
const loadProperty = () => {
AP.request({
url: "/api/v2/pages/" + contentId + "/properties?key=" + propertyKey,
type: "GET",
success: (respText) => {
const resp = JSON.parse(respText);
if (resp.results && resp.results.length > 0) {
const prop = resp.results[0];
propertyId = prop.id;
propertyVersion = prop.version.number;
const stored = prop.value.status;
if (multiselect) {
if (Array.isArray(stored)) {
selected = stored;
} else if (typeof stored === "string" && stored !== "") {
selected = [stored];
} else {
selected = [];
}
updateMenuSelection();
applyStatus(selected);
} else {
applyStatus(stored);
}
}
wrapper.style.visibility = "visible";
resize();
},
error: () => {
wrapper.style.visibility = "visible";
resize();
}
});
};
const saveProperty = (value) => {
const payload = {
status: value
};
if (propertyId) {
AP.request({
url: "/api/v2/pages/" + contentId + "/properties/" + propertyId,
type: "PUT",
contentType: "application/json",
data: JSON.stringify({
key: propertyKey,
value: payload,
version: {
number: propertyVersion + 1, message: "Status updated"
}
}),
error: (statusText) => {
const flag = AP.flag.create({
title: 'An error has occurred',
body: statusText,
close: 'auto',
type: 'error',
});
},
success: (respText) => {
const resp = JSON.parse(respText);
propertyVersion = resp.version.number;
const flag = AP.flag.create({
title: 'Successfully set an option',
close: 'auto',
type: 'success',
});
}
});
} else {
AP.request({
url: "/api/v2/pages/" + contentId + "/properties",
type: "POST",
contentType: "application/json",
data: JSON.stringify({
key: propertyKey, value: payload
}),
error: (statusText) => {
const flag = AP.flag.create({
title: 'An error has occurred',
body: statusText,
close: 'auto',
type: 'error',
});
},
success: (respText) => {
const resp = JSON.parse(respText);
propertyId = resp.id;
propertyVersion = resp.version.number;
const flag = AP.flag.create({
title: 'Successfully set an option',
close: 'auto',
type: 'success',
});
}
});
}
};
if (canEdit) box.addEventListener("click", (e) => {
e.stopPropagation();
if (!menu.contains(e.target)) toggleMenu();
});
document.addEventListener("click",
(e) => {
if (!wrapper.contains(e.target)) toggleMenu(false);
});
loadProperty();
const measure = document.createElement("span");
measure.style.cssText =
"position:absolute;visibility:hidden;white-space:nowrap;" +
"font-weight:600;font-size:14px;padding:0;";
document.body.appendChild(measure);
let maxTextW = 0;
options.forEach(opt => {
measure.textContent = opt.label;
maxTextW = Math.max(maxTextW, measure.offsetWidth);
});
measure.textContent = "Select status";
maxTextW = Math.max(maxTextW,
measure.offsetWidth);
document.body.removeChild(measure);
const pad = 28 + (canEdit ? 22: 0);
const natural = maxTextW + pad;
wrapper.style.width = Math.min(natural,
150) + "px";
});
</script>Create dropdown menus in Confluence Cloud with custom option sets, multiselect support, and permission-based access controls
Create dropdown menus in Confluence Cloud with predefined option sets and permission-based access controls
Parse a table containing cost and currency columns, convert each row's value into a selected target currency, and append a footer row showing the total amount. Useful for tracking multi-currency expenses and reporting totals in a unified currency.
Generate an alphabetical list of labels from all spaces, linking each label to its corresponding content pages
Display a custom list of recently updated content
Show H1 headings for child topics to enable easy navigation
Displays a list of pages in a specific space with a specified title or label
Get the latest cryptocurrency exchange rates for major coins
Display a mention of the Confluence page's creator in a specific color
List all Confluence spaces with details such as space name, author, and creation date