From 93f956a6d142908897cf6aa2806ecb03b29b08c9 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Thu, 14 May 2026 10:14:57 -0400 Subject: [PATCH 01/15] Teach GAlert the remaining BAlert visibility tricks --- .../components/BaseComponents/GAlert.test.ts | 78 +++++++++++ .../src/components/BaseComponents/GAlert.vue | 128 ++++++++++++++++-- 2 files changed, 194 insertions(+), 12 deletions(-) create mode 100644 client/src/components/BaseComponents/GAlert.test.ts diff --git a/client/src/components/BaseComponents/GAlert.test.ts b/client/src/components/BaseComponents/GAlert.test.ts new file mode 100644 index 000000000000..d1e46c84b4c7 --- /dev/null +++ b/client/src/components/BaseComponents/GAlert.test.ts @@ -0,0 +1,78 @@ +import { getLocalVue } from "@tests/vitest/helpers"; +import { mount } from "@vue/test-utils"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import GAlert from "./GAlert.vue"; + +const localVue = getLocalVue(); + +describe("GAlert", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("self-dismisses when closed", async () => { + const wrapper = mount(GAlert as object, { + localVue, + propsData: { + dismissible: true, + }, + slots: { + default: "Dismiss me", + }, + }); + + expect(wrapper.find(".alert").exists()).toBe(true); + + await wrapper.find("button.close").trigger("click"); + + expect(wrapper.find(".alert").exists()).toBe(false); + expect(wrapper.emitted("dismissed")).toHaveLength(1); + expect(wrapper.emitted("input")?.[0]).toEqual([false]); + expect(wrapper.emitted("update:show")?.[0]).toEqual([false]); + }); + + it("uses default v-model value before the show default", async () => { + const wrapper = mount(GAlert as object, { + localVue, + propsData: { + value: false, + }, + }); + + expect(wrapper.find(".alert").exists()).toBe(false); + + await wrapper.setProps({ value: true }); + + expect(wrapper.find(".alert").exists()).toBe(true); + }); + + it("counts down numeric show values", async () => { + const wrapper = mount(GAlert as object, { + localVue, + propsData: { + show: 2, + }, + }); + + expect(wrapper.find(".alert").exists()).toBe(true); + expect(wrapper.emitted("dismiss-count-down")?.map(([count]) => count)).toEqual([2]); + + vi.advanceTimersByTime(1000); + await wrapper.vm.$nextTick(); + + expect(wrapper.find(".alert").exists()).toBe(true); + expect(wrapper.emitted("dismiss-count-down")?.map(([count]) => count)).toEqual([2, 1]); + + vi.advanceTimersByTime(1000); + await wrapper.vm.$nextTick(); + + expect(wrapper.find(".alert").exists()).toBe(false); + expect(wrapper.emitted("dismiss-count-down")?.map(([count]) => count)).toEqual([2, 1, 0]); + expect(wrapper.emitted("dismissed")).toHaveLength(1); + }); +}); diff --git a/client/src/components/BaseComponents/GAlert.vue b/client/src/components/BaseComponents/GAlert.vue index ed05016b4965..c2e1ed63cc28 100644 --- a/client/src/components/BaseComponents/GAlert.vue +++ b/client/src/components/BaseComponents/GAlert.vue @@ -5,13 +5,18 @@ * `
` markup the existing bootstrap CSS expects. */ -import { computed, ref, watch } from "vue"; +import { computed, onBeforeUnmount, ref, watch } from "vue"; type AlertVariant = "info" | "warning" | "danger" | "success" | "primary" | "secondary" | "light" | "dark"; +type AlertShow = boolean | number | string; interface Props { /** Controls alert visibility */ - show?: boolean; + show?: AlertShow; + /** Vue 2 default v-model value */ + value?: AlertShow; + /** Vue 3 default v-model value */ + modelValue?: AlertShow; /** Bootstrap contextual variant */ variant?: AlertVariant; /** Render a close button */ @@ -24,6 +29,8 @@ interface Props { const props = withDefaults(defineProps(), { show: true, + value: undefined, + modelValue: undefined, variant: "info", dismissible: false, dismissLabel: "Close", @@ -32,28 +39,125 @@ const props = withDefaults(defineProps(), { const emit = defineEmits<{ (e: "dismissed"): void; - (e: "update:show", show: boolean): void; + (e: "dismiss-count-down", count: number): void; + (e: "input", show: AlertShow): void; + (e: "update:modelValue", show: AlertShow): void; + (e: "update:show", show: AlertShow): void; }>(); const variantClass = computed(() => `alert-${props.variant}`); -// Mirror BAlert's localShow behavior so `dismissible` works without a parent -// handler -- the close button hides the alert locally, and a subsequent -// `show` prop change re-syncs. -const localShow = ref(props.show); +const boundShow = computed(() => { + if (props.value !== undefined) { + return props.value; + } + if (props.modelValue !== undefined) { + return props.modelValue; + } + return props.show; +}); + +const countDown = ref(0); +const localShow = ref(parseShow(boundShow.value)); +let countDownTimeout: ReturnType | undefined; + +function parseCountDown(show: AlertShow | undefined) { + if (show === "" || typeof show === "boolean" || show === undefined) { + return 0; + } + + const count = Number.parseInt(String(show), 10); + return count > 0 ? count : 0; +} + +function parseShow(show: AlertShow | undefined) { + if (show === "" || show === true) { + return true; + } + + const count = Number.parseInt(String(show), 10); + if (!Number.isFinite(count) || count < 1) { + return false; + } + + return Boolean(show); +} + +function hasNumericShow(show: AlertShow | undefined) { + return show !== "" && typeof show !== "boolean" && show !== undefined && Number.isFinite(Number(show)); +} + +function emitModel(show: AlertShow) { + emit("input", show); + emit("update:modelValue", show); + emit("update:show", show); +} + +function clearCountDownTimeout() { + if (countDownTimeout) { + clearTimeout(countDownTimeout); + countDownTimeout = undefined; + } +} + +function setLocalShow(show: boolean) { + const wasShown = localShow.value; + localShow.value = show; + + if (!show && wasShown && (props.dismissible || hasNumericShow(boundShow.value))) { + emit("dismissed"); + } +} + +watch( + boundShow, + (next) => { + const nextCountDown = parseCountDown(next); + countDown.value = nextCountDown; + setLocalShow(parseShow(next)); + + if (!hasNumericShow(next)) { + clearCountDownTimeout(); + } + }, + { immediate: true }, +); watch( - () => props.show, + countDown, (next) => { - localShow.value = next; + clearCountDownTimeout(); + + if (!hasNumericShow(boundShow.value)) { + return; + } + + emit("dismiss-count-down", next); + emitModel(next); + + if (next > 0) { + setLocalShow(true); + countDownTimeout = setTimeout(() => { + countDown.value -= 1; + }, 1000); + } else { + setLocalShow(false); + } }, + { immediate: true }, ); function onDismiss() { - localShow.value = false; - emit("update:show", false); - emit("dismissed"); + clearCountDownTimeout(); + if (hasNumericShow(boundShow.value)) { + countDown.value = 0; + } else { + emitModel(false); + } + setLocalShow(false); } + +onBeforeUnmount(clearCountDownTimeout);