fix: updated browser check action buttons (#13482)

Editing a browser check offered Cancel / Continue / Update check on the
Journey step, where neither save-bearing label matched its behaviour:
Continue advanced to Configure without saving, and Update check saved
but deliberately stayed on the page. There was no obvious save-and-leave
action.

Edit mode now shows Cancel / Save & Continue / Save & Exit on the
Journey step, and the Configure step's primary button becomes Save &
Exit. Create mode is unchanged — a new check has no name, URL or
locations yet, so saving from the Journey step would always fail
validation.
This commit is contained in:
Omkar Kesarkhane
2026-07-27 16:51:38 +05:30
committed by GitHub
parent 237fbca3af
commit c047dfe4cf
19 changed files with 359 additions and 50 deletions
+2
View File
@@ -9592,6 +9592,8 @@
"modalSubtitle": "Wählen Sie aus, was Sie überwachen möchten",
"modalTitle": "Eine Prüfung erstellen",
"pageTitle": "Neue {label}-Prüfung",
"saveAndContinue": "Speichern & fortfahren",
"saveAndExit": "Speichern & beenden",
"saveCheck": "Prüfung speichern",
"saveFailed": "Prüfung konnte nicht gespeichert werden.",
"saved": "Prüfung erfolgreich gespeichert.",
+2
View File
@@ -9660,6 +9660,8 @@
"modalSubtitle": "Choose what you want to monitor",
"modalTitle": "Create a Check",
"pageTitle": "New {label} Check",
"saveAndContinue": "Save & Continue",
"saveAndExit": "Save & Exit",
"saveCheck": "Save check",
"saveFailed": "Failed to save check.",
"saved": "Check saved successfully.",
+2
View File
@@ -9592,6 +9592,8 @@
"modalSubtitle": "Elija lo que desea monitorear",
"modalTitle": "Crear una verificación",
"pageTitle": "Nueva verificación {label}",
"saveAndContinue": "Guardar y continuar",
"saveAndExit": "Guardar y salir",
"saveCheck": "Guardar verificación",
"saveFailed": "Error al guardar la verificación.",
"saved": "Verificación guardada exitosamente.",
+2
View File
@@ -9592,6 +9592,8 @@
"modalSubtitle": "Choisissez ce que vous voulez surveiller",
"modalTitle": "Créer une vérification",
"pageTitle": "Nouvelle vérification {label}",
"saveAndContinue": "Enregistrer et continuer",
"saveAndExit": "Enregistrer et quitter",
"saveCheck": "Enregistrer la vérification",
"saveFailed": "Échec de l'enregistrement de la vérification.",
"saved": "Vérification enregistrée avec succès.",
+2
View File
@@ -9592,6 +9592,8 @@
"modalSubtitle": "Scegli cosa monitorare",
"modalTitle": "Crea un check",
"pageTitle": "Nuovo controllo {label}",
"saveAndContinue": "Salva e continua",
"saveAndExit": "Salva ed esci",
"saveCheck": "Salva controllo",
"saveFailed": "Salvataggio del controllo fallito.",
"saved": "Controllo salvato con successo.",
+2
View File
@@ -9592,6 +9592,8 @@
"modalSubtitle": "監視するものを選択",
"modalTitle": "チェックを作成",
"pageTitle": "新規 {label} チェック",
"saveAndContinue": "保存して続行",
"saveAndExit": "保存して終了",
"saveCheck": "チェックを保存",
"saveFailed": "チェックの保存に失敗しました。",
"saved": "チェックが正常に保存されました。",
+2
View File
@@ -9592,6 +9592,8 @@
"modalSubtitle": "모니터링할 항목 선택",
"modalTitle": "검사 생성",
"pageTitle": "새 {label} 검사",
"saveAndContinue": "저장 후 계속",
"saveAndExit": "저장 후 종료",
"saveCheck": "검사 저장",
"saveFailed": "검사 저장에 실패했습니다.",
"saved": "검사가 성공적으로 저장되었습니다.",
+2
View File
@@ -9592,6 +9592,8 @@
"modalSubtitle": "Kies wat u wilt monitoren",
"modalTitle": "Maak een Check",
"pageTitle": "Nieuwe {label}-controle",
"saveAndContinue": "Opslaan en doorgaan",
"saveAndExit": "Opslaan en afsluiten",
"saveCheck": "Controle opslaan",
"saveFailed": "Kan controle niet opslaan.",
"saved": "Controle succesvol opgeslagen.",
+2
View File
@@ -9592,6 +9592,8 @@
"modalSubtitle": "Wybierz, co chcesz monitorować",
"modalTitle": "Utwórz sprawdzenie",
"pageTitle": "Nowe sprawdzenie {label}",
"saveAndContinue": "Zapisz i kontynuuj",
"saveAndExit": "Zapisz i zakończ",
"saveCheck": "Zapisz sprawdzenie",
"saveFailed": "Nie udało się zapisać sprawdzenia.",
"saved": "Sprawdzenie zapisano pomyślnie.",
+2
View File
@@ -9592,6 +9592,8 @@
"modalSubtitle": "Escolha o que deseja monitorar",
"modalTitle": "Criar uma verificação",
"pageTitle": "Nova verificação {label}",
"saveAndContinue": "Salvar e continuar",
"saveAndExit": "Salvar e sair",
"saveCheck": "Salvar verificação",
"saveFailed": "Falha ao salvar a verificação.",
"saved": "Verificação salva com sucesso.",
+2
View File
@@ -9592,6 +9592,8 @@
"modalSubtitle": "Выберите, что вы хотите мониторить",
"modalTitle": "Создать проверку",
"pageTitle": "Новая проверка {label}",
"saveAndContinue": "Сохранить и продолжить",
"saveAndExit": "Сохранить и выйти",
"saveCheck": "Сохранить проверку",
"saveFailed": "Не удалось сохранить проверку.",
"saved": "Проверка успешно сохранена.",
+2
View File
@@ -9592,6 +9592,8 @@
"modalSubtitle": "İzlemek istediğiniz şeyi seçin",
"modalTitle": "Bir Kontrol Oluşturun",
"pageTitle": "Yeni {label} Kontrol",
"saveAndContinue": "Kaydet ve devam et",
"saveAndExit": "Kaydet ve çık",
"saveCheck": "Kontrolü kaydet",
"saveFailed": "Kontrol kaydedilemedi.",
"saved": "Kontrol başarıyla kaydedildi.",
+2
View File
@@ -9592,6 +9592,8 @@
"modalSubtitle": "Chọn những gì bạn muốn giám sát",
"modalTitle": "Tạo kiểm tra",
"pageTitle": "Kiểm tra {label} mới",
"saveAndContinue": "Lưu và tiếp tục",
"saveAndExit": "Lưu và thoát",
"saveCheck": "Lưu kiểm tra",
"saveFailed": "Lưu kiểm tra thất bại.",
"saved": "Đã lưu kiểm tra thành công.",
+2
View File
@@ -9592,6 +9592,8 @@
"modalSubtitle": "选择您要监控的内容",
"modalTitle": "创建检查",
"pageTitle": "新建 {label} 检查",
"saveAndContinue": "保存并继续",
"saveAndExit": "保存并退出",
"saveCheck": "保存检查",
"saveFailed": "保存检查失败。",
"saved": "检查已成功保存。",
+2
View File
@@ -9587,6 +9587,8 @@
"modalSubtitle": "選擇您要監控的內容",
"modalTitle": "建立檢查",
"pageTitle": "新增 {label} 檢查",
"saveAndContinue": "儲存並繼續",
"saveAndExit": "儲存並離開",
"saveCheck": "儲存檢查",
"saveFailed": "儲存檢查失敗。",
"saved": "檢查儲存成功。",
@@ -18,6 +18,7 @@
// Render tests for CreateBrowserTest.vue — browser test creation/editing page.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { nextTick } from "vue";
import { mount, VueWrapper, flushPromises } from "@vue/test-utils";
import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
@@ -127,8 +128,9 @@ const baseStubs = {
props: ["title", "subtitle", "back"],
},
OButton: {
// Slot names mirror the real OButton (icon-left / default / icon-right).
template:
'<button :data-test="$attrs[\'data-test\']" :disabled="disabled"><slot name="prefix" /><slot /></button>',
'<button :data-test="$attrs[\'data-test\']" :disabled="disabled"><slot name="icon-left" /><slot /><slot name="icon-right" /></button>',
props: ["variant", "size", "disabled", "loading", "class", "iconLeft"],
inheritAttrs: true,
},
@@ -235,6 +237,39 @@ function mountPage(props: Record<string, unknown> = {}) {
});
}
/** Mount in edit mode with a loaded check that passes the save schema. */
async function mountValidEdit() {
mockServiceGet.mockResolvedValue({
data: {
name: "Test Check",
url: "https://example.com",
folder: "folder-1",
journey: [],
},
});
const w = mountPage({ editId: "check-123" });
await flushPromises();
return w;
}
/**
* Mount in create mode and walk the gate → Journey → Configure flow so the
* step 2 footer (with the primary save button) is rendered.
*/
async function mountCreateAtConfigure(name = "Brand New Check") {
const w = mountPage();
await flushPromises();
await w.find('[data-test="synthetics-create-url-input"]').setValue("https://example.com");
await w.find('[data-test="synthetics-create-name-input"]').setValue(name);
await w.find('[data-test="synthetics-create-build-btn"]').trigger("click");
await flushPromises();
await w.find('[data-test="synthetics-create-continue-btn"]').trigger("click");
await flushPromises();
return w;
}
describe("CreateBrowserTest", () => {
let wrapper: VueWrapper;
@@ -325,7 +360,7 @@ describe("CreateBrowserTest", () => {
expect(mockServiceGet).not.toHaveBeenCalled();
});
it("should render 'Update Check' button in Journey step footer when editId is set", async () => {
it("should render 'Save & Continue' and 'Save & Exit' in the Journey footer when editId is set", async () => {
mockServiceGet.mockResolvedValue({
data: { name: "Test Check", url: "https://example.com", journey: [] },
});
@@ -333,12 +368,13 @@ describe("CreateBrowserTest", () => {
wrapper = mountPage({ editId: "check-123" });
await flushPromises();
expect(wrapper.find('[data-test="synthetics-create-save-from-journey-btn"]').exists()).toBe(
true,
);
expect(wrapper.find('[data-test="synthetics-create-save-continue-btn"]').exists()).toBe(true);
expect(wrapper.find('[data-test="synthetics-create-save-exit-btn"]').exists()).toBe(true);
// The plain (non-saving) Continue button is create-mode only
expect(wrapper.find('[data-test="synthetics-create-continue-btn"]').exists()).toBe(false);
});
it("should NOT render 'Update Check' button when editId is not set, even in editor phase", async () => {
it("should render only Cancel + Continue in the Journey footer when editId is not set", async () => {
wrapper = mountPage();
await flushPromises();
@@ -351,13 +387,223 @@ describe("CreateBrowserTest", () => {
await flushPromises();
// We are now in the editor phase on step 1 — verify footer buttons
// Continue and Cancel should be present
expect(wrapper.find('[data-test="synthetics-create-continue-btn"]').exists()).toBe(true);
expect(wrapper.find('[data-test="synthetics-create-cancel-btn"]').exists()).toBe(true);
// Update Check button should NOT appear (no editId)
expect(wrapper.find('[data-test="synthetics-create-save-from-journey-btn"]').exists()).toBe(
// Neither save action appears without an editId — there is nothing to update yet
expect(wrapper.find('[data-test="synthetics-create-save-continue-btn"]').exists()).toBe(
false,
);
expect(wrapper.find('[data-test="synthetics-create-save-exit-btn"]').exists()).toBe(false);
});
});
describe("edit mode — Journey footer save actions", () => {
it("'Save & Exit' should update the check and navigate back to the list", async () => {
wrapper = await mountValidEdit();
await wrapper.find('[data-test="synthetics-create-save-exit-btn"]').trigger("click");
await flushPromises();
expect(mockServiceUpdate).toHaveBeenCalledTimes(1);
expect(mockServiceUpdate).toHaveBeenCalledWith(
"default",
"check-123",
expect.anything(),
"folder-1",
);
expect(mockRouterPush).toHaveBeenCalledWith({
name: "synthetics",
query: { folder: "folder-1" },
});
});
it("'Save & Continue' should update the check and advance to Configure without navigating", async () => {
wrapper = await mountValidEdit();
await wrapper.find('[data-test="synthetics-create-save-continue-btn"]').trigger("click");
await flushPromises();
expect(mockServiceUpdate).toHaveBeenCalledTimes(1);
expect(mockRouterPush).not.toHaveBeenCalled();
// Step 2 footer is now rendered — Go Back only exists on the Configure step
expect(wrapper.find('[data-test="synthetics-create-back-to-journey-btn"]').exists()).toBe(
true,
);
});
it("should not navigate when the update request fails", async () => {
mockServiceUpdate.mockRejectedValue({ response: { status: 500, data: {} } });
wrapper = await mountValidEdit();
await wrapper.find('[data-test="synthetics-create-save-exit-btn"]').trigger("click");
await flushPromises();
expect(mockServiceUpdate).toHaveBeenCalledTimes(1);
expect(mockRouterPush).not.toHaveBeenCalled();
expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ variant: "error" }));
});
it("should not navigate when validation fails", async () => {
mockServiceGet.mockResolvedValue({
data: { name: "", url: "https://example.com", journey: [] },
});
wrapper = mountPage({ editId: "check-123" });
await flushPromises();
await wrapper.find('[data-test="synthetics-create-save-exit-btn"]').trigger("click");
await flushPromises();
expect(mockServiceUpdate).not.toHaveBeenCalled();
expect(mockRouterPush).not.toHaveBeenCalled();
});
});
describe("edit mode — check deleted while editing (404)", () => {
const notFound = { response: { status: 404, data: {} } };
it("'Save & Exit' should navigate to the list once, without a folder query", async () => {
mockServiceUpdate.mockRejectedValue(notFound);
wrapper = await mountValidEdit();
await wrapper.find('[data-test="synthetics-create-save-exit-btn"]').trigger("click");
await flushPromises();
// persist() already navigated for the 404 case — onSaveAndExit must bail
// out instead of pushing a second, folder-scoped route on top of it.
expect(mockRouterPush).toHaveBeenCalledTimes(1);
expect(mockRouterPush).toHaveBeenCalledWith({ name: "synthetics" });
});
it("'Save & Exit' should warn (not error) when the check no longer exists", async () => {
mockServiceUpdate.mockRejectedValue(notFound);
wrapper = await mountValidEdit();
await wrapper.find('[data-test="synthetics-create-save-exit-btn"]').trigger("click");
await flushPromises();
expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ variant: "warning" }));
expect(mockToast).not.toHaveBeenCalledWith(expect.objectContaining({ variant: "error" }));
});
it("'Save & Continue' should not advance to Configure", async () => {
mockServiceUpdate.mockRejectedValue(notFound);
wrapper = await mountValidEdit();
await wrapper.find('[data-test="synthetics-create-save-continue-btn"]').trigger("click");
await flushPromises();
// Still on the Journey step: Go Back only exists on Configure.
expect(wrapper.find('[data-test="synthetics-create-back-to-journey-btn"]').exists()).toBe(
false,
);
expect(mockRouterPush).toHaveBeenCalledTimes(1);
expect(mockRouterPush).toHaveBeenCalledWith({ name: "synthetics" });
});
it("should clear the saving state after the 404 early return", async () => {
mockServiceUpdate.mockRejectedValue(notFound);
wrapper = await mountValidEdit();
await wrapper.find('[data-test="synthetics-create-save-exit-btn"]').trigger("click");
await flushPromises();
expect(
wrapper.findComponent('[data-test="synthetics-create-save-exit-btn"]').props("loading"),
).toBe(false);
});
});
describe("saving state", () => {
it("should mark the save buttons as loading while the request is in flight", async () => {
let resolveUpdate: (value: unknown) => void = () => {};
mockServiceUpdate.mockImplementation(
() =>
new Promise((resolve) => {
resolveUpdate = resolve;
}),
);
wrapper = await mountValidEdit();
const saveExitBtn = () =>
wrapper.findComponent('[data-test="synthetics-create-save-exit-btn"]');
expect(saveExitBtn().props("loading")).toBe(false);
await wrapper.find('[data-test="synthetics-create-save-exit-btn"]').trigger("click");
await nextTick();
expect(saveExitBtn().props("loading")).toBe(true);
resolveUpdate({});
await flushPromises();
expect(saveExitBtn().props("loading")).toBe(false);
});
});
describe("Configure footer save action", () => {
it("should update and navigate back to the list in edit mode", async () => {
wrapper = await mountValidEdit();
// The only way to reach Configure in edit mode is Save & Continue.
await wrapper.find('[data-test="synthetics-create-save-continue-btn"]').trigger("click");
await flushPromises();
mockServiceUpdate.mockClear();
mockRouterPush.mockClear();
await wrapper.find('[data-test="synthetics-create-save-btn"]').trigger("click");
await flushPromises();
expect(mockServiceUpdate).toHaveBeenCalledTimes(1);
expect(mockRouterPush).toHaveBeenCalledWith({
name: "synthetics",
query: { folder: "folder-1" },
});
});
it("should create (not update) and navigate back to the list in create mode", async () => {
wrapper = await mountCreateAtConfigure();
await wrapper.find('[data-test="synthetics-create-save-btn"]').trigger("click");
await flushPromises();
expect(mockServiceUpdate).not.toHaveBeenCalled();
expect(mockServiceCreate).toHaveBeenCalledTimes(1);
expect(mockServiceCreate).toHaveBeenCalledWith(
"default",
expect.objectContaining({ name: "Brand New Check" }),
"default",
);
expect(mockRouterPush).toHaveBeenCalledWith({
name: "synthetics",
query: { folder: "default" },
});
});
it("should not create or navigate when the check has no name", async () => {
wrapper = await mountCreateAtConfigure("");
await wrapper.find('[data-test="synthetics-create-save-btn"]').trigger("click");
await flushPromises();
expect(mockServiceCreate).not.toHaveBeenCalled();
expect(mockRouterPush).not.toHaveBeenCalled();
expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ variant: "error" }));
});
});
describe("create mode — Continue to Configure", () => {
it("should advance to Configure without persisting anything", async () => {
wrapper = await mountCreateAtConfigure();
// Configure-only footer buttons are rendered…
expect(wrapper.find('[data-test="synthetics-create-back-to-journey-btn"]').exists()).toBe(
true,
);
expect(wrapper.find('[data-test="synthetics-create-save-btn"]').exists()).toBe(true);
// …and the Journey-step Continue button is gone.
expect(wrapper.find('[data-test="synthetics-create-continue-btn"]').exists()).toBe(false);
// Continue is pure navigation — nothing is written until the user saves.
expect(mockServiceCreate).not.toHaveBeenCalled();
expect(mockServiceUpdate).not.toHaveBeenCalled();
expect(mockRouterPush).not.toHaveBeenCalled();
});
});
@@ -373,7 +619,7 @@ describe("CreateBrowserTest", () => {
wrapper = mountPage({ editId: "check-123" });
await flushPromises();
const saveBtn = wrapper.find('[data-test="synthetics-create-save-from-journey-btn"]');
const saveBtn = wrapper.find('[data-test="synthetics-create-save-exit-btn"]');
expect(saveBtn.exists()).toBe(true);
await saveBtn.trigger("click");
@@ -394,7 +640,7 @@ describe("CreateBrowserTest", () => {
wrapper = mountPage({ editId: "check-123" });
await flushPromises();
const saveBtn = wrapper.find('[data-test="synthetics-create-save-from-journey-btn"]');
const saveBtn = wrapper.find('[data-test="synthetics-create-save-exit-btn"]');
await saveBtn.trigger("click");
await flushPromises();
+54 -35
View File
@@ -406,7 +406,13 @@ function beforeUnloadHandler(e: BeforeUnloadEvent) {
e.preventDefault();
}
async function saveCheck() {
/**
* Validates and persists the check. Owns validation, the API call and all
* toasts — but deliberately NOT navigation, so the footer buttons can decide
* where to go afterwards (stay on Configure vs. return to the checks list).
* Returns true only when the check was actually written.
*/
async function persist(): Promise<boolean> {
// ── Pre-save validation ───────────────────────────────────────────
validationErrors.value = {};
const toValidate = {
@@ -435,7 +441,7 @@ async function saveCheck() {
variant: "error",
message: t("synthetics.validation.fixHighlightedFields"),
});
return;
return false;
}
isSaving.value = true;
@@ -451,32 +457,28 @@ async function saveCheck() {
await syntheticsService.update(org, props.editId, apiPayload.value, check.value.folder);
dismiss();
toast({ variant: "success", message: t("synthetics.newCheck.updated") });
isDirty.value = false;
if (currentStep.value !== 1) {
router.push({ name: "synthetics", query: { folder: check.value.folder } });
}
} else {
const res = await syntheticsService.create(org, apiPayload.value, check.value.folder);
const savedId = res.data?.id ?? crypto.randomUUID();
await syntheticsService.create(org, apiPayload.value, check.value.folder);
dismiss();
toast({ variant: "success", message: t("synthetics.newCheck.saved") });
isDirty.value = false;
router.push({ name: "synthetics", query: { folder: check.value.folder } });
}
isDirty.value = false;
return true;
} catch (err: any) {
dismiss();
if (err?.response?.status === 404) {
// Already navigated away — the caller must not push on top of this.
forceLeave = true;
router.push({ name: "synthetics" });
toast({ variant: "warning", message: t("synthetics.newCheck.notFoundInOrg") });
isSaving.value = false;
return;
return false;
}
toast({
variant: "error",
message: err?.response?.data?.message || t("synthetics.newCheck.saveFailed"),
});
console.error("[synthetics] save failed", err);
return false;
} finally {
isSaving.value = false;
}
@@ -500,6 +502,19 @@ function onContinueToConfigure() {
currentStep.value = 2;
}
/** Edit mode, Journey step: persist, then move on to Configure. */
async function onSaveAndContinue() {
if (!(await persist())) return;
journeyStepDone.value = true;
currentStep.value = 2;
}
/** Persist, then return to the checks list. */
async function onSaveAndExit() {
if (!(await persist())) return;
router.push({ name: "synthetics", query: { folder: check.value.folder } });
}
// ── Replay — uses the composable's phase-based state machine ────────────────
/** Local unwraps so the template can read these without `.value`. */
const replayPhase = computed(() => recorder.replayPhase.value);
@@ -621,7 +636,7 @@ function onClearResults() {
data-test="synthetics-create-record-btn"
@click="onRecordClick"
>
<template #prefix>
<template #icon-left>
<OIcon name="smart-display" size="sm" />
</template>
{{ t("synthetics.journey.recordJourney") }}
@@ -632,7 +647,7 @@ function onClearResults() {
data-test="synthetics-create-build-btn"
@click="buildManually"
>
<template #prefix>
<template #icon-left>
<OIcon name="edit" size="sm" />
</template>
{{ t("synthetics.createBrowserTest.buildManually") }}
@@ -857,7 +872,7 @@ function onClearResults() {
<div
class="border-border-default bg-surface-base flex shrink-0 items-center gap-2 border-t px-3 py-2.5"
>
<!-- Journey step: Cancel | Selection actions (left) | Replay status + Continue (right) -->
<!-- Journey step: Selection actions (left) | Cancel + save/continue actions (right) -->
<template v-if="currentStep === 1">
<!-- Selection actions — moved from BrowserJourney, kept on the left -->
<template v-if="journeySelectionState.count > 0 && !journeySelectionState.isRecording">
@@ -884,26 +899,36 @@ function onClearResults() {
>
{{ t("common.cancel") }}
</OButton>
<!-- Create mode: nothing to save yet — Configure holds the required fields -->
<OButton
v-if="!props.editId"
variant="outline"
size="sm"
data-test="synthetics-create-continue-btn"
@click="onContinueToConfigure"
>
{{ t("synthetics.createBrowserTest.continue") }}
<template #suffix><OIcon name="chevron-right" size="sm" /></template>
</OButton>
<OButton
v-if="props.editId"
variant="primary"
size="sm"
:loading="isSaving"
data-test="synthetics-create-save-from-journey-btn"
@click="saveCheck"
>
{{ t("synthetics.newCheck.updateCheck") }}
<template #suffix><OIcon name="save" size="sm" /></template>
</OButton>
<template v-else>
<OButton
variant="outline"
size="sm"
:loading="isSaving"
data-test="synthetics-create-save-continue-btn"
@click="onSaveAndContinue"
>
{{ t("synthetics.newCheck.saveAndContinue") }}
</OButton>
<OButton
variant="primary"
size="sm"
:loading="isSaving"
data-test="synthetics-create-save-exit-btn"
@click="onSaveAndExit"
>
{{ t("synthetics.newCheck.saveAndExit") }}
</OButton>
</template>
</template>
<!-- Configure step: Cancel | Back + Save -->
@@ -923,7 +948,6 @@ function onClearResults() {
data-test="synthetics-create-back-to-journey-btn"
@click="currentStep = 1"
>
<template #prefix><OIcon name="chevron-left" size="sm" /></template>
{{ t("common.goBack") }}
</OButton>
<OButton
@@ -931,14 +955,9 @@ function onClearResults() {
size="sm"
:loading="isSaving"
data-test="synthetics-create-save-btn"
@click="saveCheck"
@click="onSaveAndExit"
>
{{
props.editId
? t("synthetics.newCheck.updateCheck")
: t("synthetics.newCheck.saveCheck")
}}
<template #suffix><OIcon name="save" size="sm" /></template>
{{ t("synthetics.newCheck.saveAndExit") }}
</OButton>
</template>
</div>
@@ -93,14 +93,18 @@ const baseStubs = {
template: '<div data-test="synthetics-header"><slot name="title" /><slot /></div>',
props: ["title", "subtitle", "back"],
},
// Mirrors the real OButton slot contract (OButton.vue): only `icon-left`,
// default and `icon-right` are rendered. Any other slot name (e.g. `suffix`)
// is silently dropped — which is exactly the bug this stub must surface.
OButton: {
template:
'<button :data-test="$attrs[\'data-test\']" :disabled="disabled"><slot name="prefix" /><slot name="suffix" /><slot /></button>',
'<button :data-test="$attrs[\'data-test\']" :disabled="disabled"><slot name="icon-left" /><slot /><slot name="icon-right" /></button>',
props: ["variant", "size", "disabled", "loading", "class", "iconLeft"],
inheritAttrs: true,
},
// Renders an identifiable marker per icon name so slot placement is assertable.
OIcon: {
template: "<span />",
template: '<span :data-test="`icon-${name}`" />',
props: ["name", "size", "class"],
},
ODialog: {
@@ -185,6 +189,17 @@ describe("CreateProtocolCheck", () => {
expect(wrapper.find('[data-test="synthetics-create-save-btn"]').exists()).toBe(true);
});
// The footer actions are deliberately text-only — no leading or trailing
// icons. The stub renders OButton's real slots, so a stray icon would show.
it("should render the footer save button without an icon", async () => {
wrapper = mountPage("http");
await flushPromises();
const saveBtn = wrapper.find('[data-test="synthetics-create-save-btn"]');
expect(saveBtn.exists()).toBe(true);
expect(saveBtn.find('[data-test^="icon-"]').exists()).toBe(false);
});
it("should render the Beta badge in the page title", async () => {
wrapper = mountPage("http");
await flushPromises();
@@ -331,8 +331,7 @@ async function saveCheck() {
data-test="synthetics-create-save-btn"
@click="saveCheck"
>
{{ editId ? t("synthetics.newCheck.updateCheck") : t("synthetics.newCheck.saveCheck") }}
<template #suffix><OIcon name="save" size="sm" /></template>
{{ t("synthetics.newCheck.saveAndExit") }}
</OButton>
</div>