Stand with Ukraine 🇺🇦

Custom Dropdown Menu — Confluence Cloud MacroNew

confluence-content

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.

Try for free

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">&#9660;</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>