From 2315aee22964d7e3d14746a59a9b5ad93cb758a2 Mon Sep 17 00:00:00 2001 From: Rana Al-Khulaidi Date: Sun, 16 Aug 2026 03:53:30 -0400 Subject: [PATCH] fix(nimbus): Improve rollout card actions and save feedback Because * Edit actions should be replaced with a cancel action while a card is in edit mode * Successful saves should provide clear user feedback * Cards should be temporarily disabled while changes are being saved This commit * Updates card actions to show a cancel control during edit mode * Adds toast notifications for successful saves * Disables cards while save requests are in progress Fixes #16700 --- .../experimenter/nimbus_ui/constants.py | 16 ++++ .../experimenter/nimbus_ui/new/views.py | 33 +++++-- .../nimbus_ui/static/css/style.scss | 13 +++ .../nimbus_ui/static/js/new/rollout_cards.js | 52 +++++++++++ .../nimbus_ui/static/webpack.config.js | 1 + .../nimbus_ui/templates/new/common/base.html | 13 +++ .../templates/new/common/card_actions.html | 12 +++ .../new/common/card_cancel_button.html | 14 +++ .../new/common/card_form_actions.html | 13 +++ .../common/slack_notifications_toggle.html | 1 + .../templates/new/common/subscribe_bell.html | 2 + .../new/rollouts/audience/edit_form.html | 14 +-- .../templates/new/rollouts/detail_card.html | 90 ++++++++++--------- .../rollouts/overview/cancel_response.html | 2 +- .../new/rollouts/overview/edit_form.html | 14 +-- .../templates/new/rollouts/qa/edit_form.html | 14 +-- .../new/rollouts/risks/edit_form.html | 14 +-- .../new/rollouts/rollout_detail.html | 54 ++++------- .../rollouts/rollout_features/edit_form.html | 14 +-- .../new/rollouts/schedule/edit_form.html | 14 +-- .../new/rollouts/signoff/edit_form.html | 14 +-- .../nimbus_ui/tests/test_new_views.py | 60 +++++++++++++ 22 files changed, 316 insertions(+), 158 deletions(-) create mode 100644 experimenter/experimenter/nimbus_ui/static/js/new/rollout_cards.js create mode 100644 experimenter/experimenter/nimbus_ui/templates/new/common/card_actions.html create mode 100644 experimenter/experimenter/nimbus_ui/templates/new/common/card_cancel_button.html create mode 100644 experimenter/experimenter/nimbus_ui/templates/new/common/card_form_actions.html diff --git a/experimenter/experimenter/nimbus_ui/constants.py b/experimenter/experimenter/nimbus_ui/constants.py index 5b1b3cddad..294b92ee35 100644 --- a/experimenter/experimenter/nimbus_ui/constants.py +++ b/experimenter/experimenter/nimbus_ui/constants.py @@ -400,6 +400,22 @@ class NimbusUIConstants: "phase again, or cancel and add a new phase in the rollout schedule." ) + TOAST_SAVED = "toast-saved" + TOAST_SUBSCRIBED = "toast-subscribed" + TOAST_UNSUBSCRIBED = "toast-unsubscribed" + TOAST_SLACK_ENABLED = "toast-slack-enabled" + TOAST_SLACK_DISABLED = "toast-slack-disabled" + TOAST_EDIT_CANCELLED = "toast-edit-cancelled" + + TOASTS = { + TOAST_SAVED: "Changes saved", + TOAST_SUBSCRIBED: "Subscribed to this experiment", + TOAST_UNSUBSCRIBED: "Unsubscribed from this experiment", + TOAST_SLACK_ENABLED: "Review Slack notifications enabled", + TOAST_SLACK_DISABLED: "Review Slack notifications disabled", + TOAST_EDIT_CANCELLED: "Edit cancelled", + } + class RolloutPhaseStatus: NOT_STARTED = "not_started" IN_PROGRESS = "in_progress" diff --git a/experimenter/experimenter/nimbus_ui/new/views.py b/experimenter/experimenter/nimbus_ui/new/views.py index d1a037af1b..e2f0bf7630 100644 --- a/experimenter/experimenter/nimbus_ui/new/views.py +++ b/experimenter/experimenter/nimbus_ui/new/views.py @@ -1,3 +1,5 @@ +import json + from django import forms from django.conf import settings from django.http import HttpResponse, HttpResponseRedirect @@ -8,6 +10,7 @@ from experimenter.experiments.api.v5.serializers import NimbusRolloutReviewSerializer from experimenter.experiments.constants import EXTERNAL_URLS, RISK_QUESTIONS from experimenter.experiments.models import NimbusExperiment, Tag +from experimenter.nimbus_ui.constants import NimbusUIConstants from experimenter.nimbus_ui.filtersets import ( TagSearchFilterSet, UserSearchFilterSet, @@ -55,6 +58,11 @@ ) +def trigger_toast(response, toast_id): + response.headers["HX-Trigger"] = json.dumps({"showToast": {"id": toast_id}}) + return response + + class CloneExperimentFormMixin: def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -316,6 +324,7 @@ class NewCardUpdateView( UpdateView, ): display_template = None + save_toast_id = NimbusUIConstants.TOAST_SAVED def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -331,10 +340,13 @@ def form_valid(self, form): def render_valid_response(self): context = self.get_context_data() - return self.response_class( - request=self.request, - template=self.display_template, - context=context, + return trigger_toast( + self.response_class( + request=self.request, + template=self.display_template, + context=context, + ), + self.save_toast_id, ) @@ -730,14 +742,18 @@ class NewSubscribeView(NimbusExperimentViewMixin, RequestFormMixin, UpdateView): model = NimbusExperiment form_class = SubscribeForm template_name = "new/common/subscribe_bell.html" + toast_id = NimbusUIConstants.TOAST_SUBSCRIBED def form_valid(self, form): self.object = form.save() - return self.render_to_response(self.get_context_data()) + return trigger_toast( + self.render_to_response(self.get_context_data()), self.toast_id + ) class NewUnsubscribeView(NewSubscribeView): form_class = UnsubscribeForm + toast_id = NimbusUIConstants.TOAST_UNSUBSCRIBED class NewCloneView(NimbusExperimentViewMixin, RequestFormMixin, UpdateView): @@ -784,4 +800,9 @@ class NewToggleReviewSlackNotificationsView( def form_valid(self, form): self.object = form.save() - return self.render_to_response(self.get_context_data()) + toast_id = ( + NimbusUIConstants.TOAST_SLACK_ENABLED + if self.object.enable_review_slack_notifications + else NimbusUIConstants.TOAST_SLACK_DISABLED + ) + return trigger_toast(self.render_to_response(self.get_context_data()), toast_id) diff --git a/experimenter/experimenter/nimbus_ui/static/css/style.scss b/experimenter/experimenter/nimbus_ui/static/css/style.scss index a544407418..9eda0525ed 100644 --- a/experimenter/experimenter/nimbus_ui/static/css/style.scss +++ b/experimenter/experimenter/nimbus_ui/static/css/style.scss @@ -358,6 +358,19 @@ cursor: not-allowed; } +.card-edit-form .card-edit-spinner { + display: none; +} + +.card-edit-form.htmx-request { + opacity: 0.55; + pointer-events: none; + + .card-edit-spinner { + display: inline-block; + } +} + .rollout-page { .cm-editor .cm-scroller { font-family: var(--bs-font-monospace); diff --git a/experimenter/experimenter/nimbus_ui/static/js/new/rollout_cards.js b/experimenter/experimenter/nimbus_ui/static/js/new/rollout_cards.js new file mode 100644 index 0000000000..2a8215664e --- /dev/null +++ b/experimenter/experimenter/nimbus_ui/static/js/new/rollout_cards.js @@ -0,0 +1,52 @@ +const showToast = (toastId) => { + const toastEl = document.getElementById(toastId); + if (toastEl) { + window.bootstrap?.Toast.getOrCreateInstance(toastEl).show(); + } +}; + +const syncCardEditActions = () => { + document.querySelectorAll(".rollout-card").forEach((card) => { + const editing = card.querySelector(".card-edit-form") !== null; + card + .querySelectorAll("[data-card-action='edit']") + .forEach((button) => button.classList.toggle("d-none", editing)); + card + .querySelectorAll("[data-card-action='cancel']") + .forEach((button) => button.classList.toggle("d-none", !editing)); + }); +}; + +document.addEventListener("showToast", (event) => { + showToast(event.detail?.id); +}); + +document.addEventListener("click", (event) => { + const trigger = event.target.closest?.("[data-toast-id]"); + if (trigger) { + showToast(trigger.dataset.toastId); + } +}); + +let pendingCardId = null; + +document.addEventListener("htmx:beforeRequest", (event) => { + const card = event.detail.elt.closest?.(".rollout-card"); + pendingCardId = card ? card.id : null; +}); + +document.addEventListener("htmx:afterSwap", syncCardEditActions); + +document.addEventListener("htmx:afterSettle", () => { + const cardId = pendingCardId; + pendingCardId = null; + if (!cardId) { + return; + } + const card = document.getElementById(cardId); + if (card) { + card.scrollIntoView({ block: "nearest", behavior: "smooth" }); + } +}); + +document.addEventListener("DOMContentLoaded", syncCardEditActions); diff --git a/experimenter/experimenter/nimbus_ui/static/webpack.config.js b/experimenter/experimenter/nimbus_ui/static/webpack.config.js index ba8779e64e..08e6f2dd72 100644 --- a/experimenter/experimenter/nimbus_ui/static/webpack.config.js +++ b/experimenter/experimenter/nimbus_ui/static/webpack.config.js @@ -13,6 +13,7 @@ module.exports = { new_edit_branches: "./js/new/edit_branches.js", new_tooltips: "./js/new/tooltips.js", new_htmx_failure: "./js/new/htmx_failure.js", + new_rollout_cards: "./js/new/rollout_cards.js", experiment_detail: "./js/experiment_detail.js", branch_detail: "./js/branch_detail.js", features_page: "./js/features_page.js", diff --git a/experimenter/experimenter/nimbus_ui/templates/new/common/base.html b/experimenter/experimenter/nimbus_ui/templates/new/common/base.html index 52a25d1811..7465a0293f 100644 --- a/experimenter/experimenter/nimbus_ui/templates/new/common/base.html +++ b/experimenter/experimenter/nimbus_ui/templates/new/common/base.html @@ -14,6 +14,7 @@ + {% block extra_head %}{% endblock %} {{ exper JSON copied to clipboard! + {% for toast_id, toast_message in NimbusUIConstants.TOASTS.items %} + + {% endfor %}