Dropdown Menu — Confluence Cloud MacroNew
This Confluence user macro provides a permission-aware status selector that saves the selected value as a page content property via the Confluence Cloud REST API.
Server-side, it checks whether the user can edit: if onlyEdit=true, it validates update restrictions; otherwise, it optionally verifies membership in specified allowedGroups.
Client-side, it renders a styled dropdown built with AUI (AJS.toInit) and predefined status sets (e.g., Traffic Light, Sprint, Issue, Email).
On load, it fetches the existing property from /api/v2/pages/{id}/properties and applies the saved status if present.
If a selection changes, it creates or updates the property using proper version incrementing.
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
<style>
.sw-${macroId} {
position: relative;
display: inline-block;
font-weight: 600;
min-width: fit-content;
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;
}
.sw-${macroId} .status-arrow.open {
transform: rotate(180deg);
}
.sw-${macroId} .status-menu {
position: absolute;
top: 110%;
left: 0;
right: 0;
background: white;
border-radius: 6px;
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
overflow: hidden;
display: none;
z-index: 100;
}
.sw-${macroId} .status-option {
padding: 10px 14px;
cursor: pointer;
}
.sw-${macroId} .status-option:hover {
background: #f4f5f7;
}
html[data-color-mode="dark"] .status-option {
color: grey !important;
}
.status-default {
background: #f4f5f7;
color: #42526e;
}
.status-stop {
background: #ffebe6;
color: #bf2600;
}
.status-go_if_possible {
background: #fff7d6;
color: #7a5d00;
}
.status-go {
background: #e3fcef;
color: #006644;
}
.status-ready {
background: #deebff;
color: #0747a6;
}
.status-postponed {
background: #f4f5f7;
color: #42526e;
}
.status-inprogress {
background: #deebff;
color: #0065ff;
}
.status-review {
background: #eae6ff;
color: #403294;
}
.status-done {
background: #e3fcef;
color: #006644;
}
.status-waiting_for_customer {
background: #fff7d6;
color: #7a5d00;
}
.status-investigating {
background: #eae6ff;
color: #403294;
}
.status-escalated {
background: #ffebe6;
color: #bf2600;
}
.status-cancelled {
background: #f4f5f7;
color: #42526e;
}
.status-resolved {
background: #e3fcef;
color: #006644;
}
.status-waiting_for_email {
background: #fff7d6;
color: #7a5d00;
}
.status-reply_to_email {
background: #deebff;
color: #0065ff;
}
.status-email_sent {
background: #e3fcef;
color: #006644;
}
.status-created_draft_email {
background: #eae6ff;
color: #403294;
}
.status-email_deleted {
background: #ffebe6;
color: #bf2600;
}
.status-junk_email {
background: #f4f5f7;
color: #42526e;
}
</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')" || "Traffic Light";
const canEdit = $canEdit;
const mid = "$macroId";
const STATUS_SETS = {
"Traffic Light": [{
value: "go", label: "GO"
},
{
value: "go_if_possible", label: "GO IF POSSIBLE"
},
{
value: "stop", label: "STOP"
}],
"Active Sprint": [{
value: "ready", label: "READY"
},
{
value: "postponed", label: "POSTPONED"
},
{
value: "inprogress", label: "IN PROGRESS"
},
{
value: "review", label: "REVIEW"
},
{
value: "done", label: "DONE"
}],
"Customer Issue": [{
value: "waiting_for_customer", label: "WAITING FOR CUSTOMER"
},
{
value: "inprogress", label: "IN PROGRESS"
},
{
value: "investigating", label: "INVESTIGATING"
},
{
value: "escalated", label: "ESCALATED"
},
{
value: "cancelled", label: "CANCELLED"
},
{
value: "resolved", label: "RESOLVED"
}],
"Email Status": [{
value: "waiting_for_email", label: "WAITING FOR EMAIL"
},
{
value: "reply_to_email", label: "REPLY TO EMAIL"
},
{
value: "email_sent", label: "EMAIL SENT"
},
{
value: "created_draft_email", label: "CREATED DRAFT EMAIL"
},
{
value: "email_deleted", label: "EMAIL DELETED"
},
{
value: "junk_email", label: "JUNK EMAIL"
}]
};
const options = STATUS_SETS[statusSet] || STATUS_SETS["Traffic Light"];
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 resizeTimer = null;
if (canEdit) {
options.forEach(opt => {
const div = document.createElement("div");
div.className = "status-option";
div.dataset.value = opt.value;
div.textContent = opt.label;
div.addEventListener("click", () => {
applyStatus(opt.value);
toggleMenu(false);
propertyId ? updateProperty(opt.value): createProperty(opt.value);
});
menu.appendChild(div);
});
}
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;
menu.style.display = isOpen ? "block": "none";
arrow.classList.toggle("open", isOpen);
if (isOpen) {
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";
}
resize();
};
const applyStatus = (value) => {
box.className = "status-box";
if (!canEdit) box.classList.add("readonly");
if (!value) {
box.classList.add("status-default");
text.textContent = "Select status";
return;
}
box.classList.add("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?.length > 0) {
const prop = resp.results[0];
propertyId = prop.id;
propertyVersion = prop.version.number;
applyStatus(prop.value.status);
}
wrapper.style.visibility = "visible";
resize();
},
error: () => {
wrapper.style.visibility = "visible";
resize();
}
});
};
const createProperty = (value) => {
AP.request({
url: "/api/v2/pages/" + ${contentId} + "/properties",
type: "POST",
contentType: "application/json",
data: JSON.stringify({
key: propertyKey, value: {
status: value
}
}),
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',
});
}
});
};
const updateProperty = (value) => {
AP.request({
url: "/api/v2/pages/" + contentId + "/properties/" + propertyId,
type: "PUT",
contentType: "application/json",
data: JSON.stringify({
key: propertyKey,
value: {
status: value
},
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',
});
}
});
};
if (canEdit) box.addEventListener("click", (e) => {
e.stopPropagation();
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>User Parameters
Set
Choose a set of options
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
You May Also Like
Create dropdown menus in Confluence Cloud with custom sets of options, multiselect features, and permission gates
Create dropdown menus in Confluence Cloud with predefined sets of options, multiselect features, and permission gates
Macro for generating ID in base32 format
Fetch a random quote from the DummyJSON API and display it in a stylized blockquote format.
Convert a specified amount of cryptocurrency to fiat currency.
Get the latest cryptocurrency rates for major coins.
Display the top comments from a selected Confluence page or the current page by default.
Macro that will show a "Delete me" message only for editors (in the Page Edit mode)
Filter Confluence groups based on a specified filter value and display the results in different formats.
Generate a list of all the content created by a current user by default or a specified user across your Confluence site.