Custom Dropdown Menu — Confluence Cloud MacroNew
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 page update restrictions (onlyEdit=true) or optional allowedGroups validation.
The dropdown options are defined by the user through macro parameters rather than predefined sets, and the current value is loaded from /api/v2/pages/{id}/properties.
When changed, the macro creates or updates the property with proper versioning.
User Parameters
Custom Set
Enter comma-separated options
Multiselect
Allow a dropdown menu with multiple selection
Blank
Allow including a blank option that can be selected
Only Edit (higher priority over group permissions)
Allow status changes for users with edit rights only
Allowed Groups
Enter groups (comma-separated) with status change permission
Template
<script>
window.AP.theming.initializeTheming();
</script>
#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")
#set ($rawSet = "$!parameters.get('customSet')")
#set ($customItems = $StringUtils.split($rawSet, ","))
<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: white;
border-radius: 6px;
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
overflow: hidden;
display: none;
z-index: 100;
max-height: 260px;
overflow-y: auto;
}
.sw-${macroId} .status-option {
padding: 10px 14px;
cursor: pointer;
}
.sw-${macroId} .status-option:hover {
background: #e8e8e8;
}
.sw-${macroId} .status-option.selected {
background: #e6e6e6;
}
.sw-${macroId} .status-option.selected::before {
content: "✓ ";
font-weight: bold;
}
.sw-${macroId} .status-option.blank-option {
min-height: 38px;
border-bottom: 1px solid #eee;
}
html[data-color-mode="dark"] .sw-${macroId} .status-option {
color: grey !important;
}
.status-default {
background: #f4f5f7;
color: #42526e;
}
.status-grey {
background: #f4f5f7;
color: #42526e;
}
.sw-${macroId} .multi-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
flex: 1;
}
.sw-${macroId} .multi-tag {
background: rgba(0,0,0,0.08);
color: #42526e;
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 canEdit = $canEdit;
const multiselect = $multiselect;
const allowBlank = $allowBlank;
const mid = "$macroId";
const options = [
#foreach ($item in $customItems)
#set ($trimmed = $StringUtils.trim($item))
#set ($val = $StringUtils.lowerCase($StringUtils.replace($trimmed, " ", "_"))) {
value: "$val", label: "$trimmed"
}
#if ($foreach.hasNext),
#end
#end
];
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 _ = menu.offsetHeight;
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) {
menu.style.display = "block";
const rect = box.getBoundingClientRect();
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.top = "110%";
menu.style.left = "0";
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("status-grey");
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 = natural + "px";
wrapper.style.minWidth = natural + "px";
wrapper.style.maxWidth = natural + "px";
});
</script>Recommended Macros
Create dropdown menus in Confluence Cloud with predefined sets of options and permission gates
Create dropdown menus in Confluence Cloud with predefined sets of options, multiselect features, and permission gates
Compare unique and common groups between two users
Get the latest cryptocurrency rates for major coins.
Macro for generating ID in base32 format
Display information about how many users have access to each space
Improve default Confluence tables with sorting, filtering, pagination, and CSV export features
Show confetti bursts and a motivational message upon saving an edited page