Multiselect 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 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.
User Parameters
Set
Choose a set of 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")
<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 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>Recommended Macros
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 and permission gates
The content of this macro will not go to the printing page
Shows H1 headings directly to children's topics for easy navigation.
Add a configurable floating panel to a Confluence page
Displays a list of pages in specific space with certain title or label
Filter Confluence groups based on a specified filter value and display the results in different formats.
Retrieve and display labels from a specified space.