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 %}