Stand with Ukraine 🇺🇦

Dropdown Menu — Confluence Cloud MacroNew

confluence-content

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.

Try for free

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

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