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.
#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: 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;
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);
}
.status-default {
background: var(--ds-background-neutral, #dcdfe4);
color: var(--ds-text-subtle, #44546f);
}
.status-stop {
background: var(--ds-background-danger, #ffeceb);
color: var(--ds-text-danger, #ae2e24);
}
.status-go_if_possible {
background: var(--ds-background-warning, #fff3db);
color: var(--ds-text-warning, #a54800);
}
.status-go {
background: var(--ds-background-success, #dcfff1);
color: var(--ds-text-success, #216e4e);
}
.status-ready {
background: var(--ds-background-information, #e9f2ff);
color: var(--ds-text-information, #0055cc);
}
.status-postponed {
background: var(--ds-background-neutral, #dcdfe4);
color: var(--ds-text-subtle, #44546f);
}
.status-inprogress {
background: var(--ds-background-information, #e9f2ff);
color: var(--ds-text-information, #0055cc);
}
.status-review {
background: var(--ds-background-discovery, #f3f0ff);
color: var(--ds-text-discovery, #5e4db2);
}
.status-done {
background: var(--ds-background-success, #dcfff1);
color: var(--ds-text-success, #216e4e);
}
.status-waiting_for_customer {
background: var(--ds-background-warning, #fff3db);
color: var(--ds-text-warning, #a54800);
}
.status-investigating {
background: var(--ds-background-discovery, #f3f0ff);
color: var(--ds-text-discovery, #5e4db2);
}
.status-escalated {
background: var(--ds-background-danger, #ffeceb);
color: var(--ds-text-danger, #ae2e24);
}
.status-cancelled {
background: var(--ds-background-neutral, #dcdfe4);
color: var(--ds-text-subtle, #44546f);
}
.status-resolved {
background: var(--ds-background-success, #dcfff1);
color: var(--ds-text-success, #216e4e);
}
.status-waiting_for_email {
background: var(--ds-background-warning, #fff3db);
color: var(--ds-text-warning, #a54800);
}
.status-reply_to_email {
background: var(--ds-background-information, #e9f2ff);
color: var(--ds-text-information, #0055cc);
}
.status-email_sent {
background: var(--ds-background-success, #dcfff1);
color: var(--ds-text-success, #216e4e);
}
.status-created_draft_email {
background: var(--ds-background-discovery, #f3f0ff);
color: var(--ds-text-discovery, #5e4db2);
}
.status-email_deleted {
background: var(--ds-background-danger, #ffeceb);
color: var(--ds-text-danger, #ae2e24);
}
.status-junk_email {
background: var(--ds-background-neutral, #dcdfe4);
color: var(--ds-text-subtle, #44546f);
}
</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>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, multiselect support, and permission-based access controls
Fetch a random quote from DummyJSON API and display it in a stylized blockquote format
Convert a specified amount of cryptocurrency to a fiat currency
Get the latest cryptocurrency exchange rates for major coins
Generate IDs in Base32 format
Display the top comments from a selected Confluence page, or from the current page by default
Display a "Delete me" message only for editors while editing a page
Filter Confluence groups based on a specified filter value and display the results in different formats
Generate a list of all content created by the current user by default, or by a specified user, across your Confluence site