Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion client/src/components/ActivityBar/ActivitySettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useActivityStore } from "@/stores/activityStore";
import type { Activity } from "@/stores/activityStoreTypes";
import { useUnprivilegedToolStore } from "@/stores/unprivilegedToolStore";

import GAlert from "@/components/BaseComponents/GAlert.vue";
import GButton from "@/components/BaseComponents/GButton.vue";

const props = defineProps<{
Expand Down Expand Up @@ -143,7 +144,7 @@ function executeActivity(activity: Activity) {
</button>
</div>
<div v-else>
<b-alert v-localize class="py-1 px-2" show> No matching activities found. </b-alert>
<GAlert v-localize class="py-1 px-2" show> No matching activities found. </GAlert>
</div>
</div>
</template>
Expand Down
7 changes: 5 additions & 2 deletions client/src/components/Alert.vue
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
<template>
<b-alert :variant="galaxyKwdToBootstrap" :show="showP" v-bind="$props">
<GAlert :variant="galaxyKwdToBootstrap" :show="showP" v-bind="$props">
<!-- @slot Message to display in alert -->
<slot> {{ message }} </slot>
</b-alert>
</GAlert>
</template>

<script>
import GAlert from "@/components/BaseComponents/GAlert.vue";

export default {
components: { GAlert },
props: {
/**
* Message to display in the alert
Expand Down
78 changes: 78 additions & 0 deletions client/src/components/BaseComponents/GAlert.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
149 changes: 135 additions & 14 deletions client/src/components/BaseComponents/GAlert.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,24 @@
* `<div class="alert alert-{variant}">` 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";
// `string & {}` keeps IDE autocomplete for the recommended variants while still accepting arbitrary
// strings (and null) -- mirrors BAlert's permissive prop typing so existing call sites don't have
// to cast. Null/empty variants fall back to the default `info` style.
type AlertVariantProp = AlertVariant | (string & {}) | null;
type AlertShow = boolean | number | string | null;

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;
variant?: AlertVariantProp;
/** Render a close button */
dismissible?: boolean;
/** Aria label for the dismiss button */
Expand All @@ -24,6 +33,8 @@ interface Props {

const props = withDefaults(defineProps<Props>(), {
show: true,
value: undefined,
modelValue: undefined,
variant: "info",
dismissible: false,
dismissLabel: "Close",
Expand All @@ -32,28 +43,138 @@ const props = withDefaults(defineProps<Props>(), {

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}`);
const variantClass = computed(() => `alert-${props.variant || "info"}`);

// 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<boolean>(props.show);
const boundShow = computed<AlertShow>(() => {
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<typeof setTimeout> | undefined;

function parseCountDown(show: AlertShow | undefined) {
if (show === "" || show === null || show === undefined || typeof show === "boolean") {
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;
}

if (show === null || show === undefined || show === false) {
return false;
}

const count = Number.parseInt(String(show), 10);
if (!Number.isFinite(count) || count < 1) {
return false;
}

return Boolean(show);
}

function hasNumericShow(show: AlertShow | undefined) {
if (show === "" || show === null || show === undefined || typeof show === "boolean") {
return false;
}
return 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(
() => props.show,
boundShow,
(next) => {
localShow.value = next;
const nextCountDown = parseCountDown(next);
countDown.value = nextCountDown;
setLocalShow(parseShow(next));

if (!hasNumericShow(next)) {
clearCountDownTimeout();
}
},
{ immediate: true },
);

watch(
countDown,
(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);
</script>

<script lang="ts">
export default {
name: "GAlert",
};
</script>

<template>
Expand Down
7 changes: 4 additions & 3 deletions client/src/components/Collections/BuildFileSetWizard.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BAlert, BCardGroup } from "bootstrap-vue";
import { BCardGroup } from "bootstrap-vue";
import { computed, ref } from "vue";

import { GalaxyApi } from "@/api";
Expand Down Expand Up @@ -36,6 +36,7 @@ import SourceFromPastedData from "./wizard/SourceFromPastedData.vue";
import SourceFromRemoteFiles from "./wizard/SourceFromRemoteFiles.vue";
import SourceFromWorkbook from "./wizard/SourceFromWorkbook.vue";
import UploadFetchWorkbook from "./wizard/UploadFetchWorkbook.vue";
import GAlert from "@/components/BaseComponents/GAlert.vue";
import GenericWizard from "@/components/Common/Wizard/GenericWizard.vue";
import RuleCollectionBuilder from "@/components/RuleCollectionBuilder.vue";

Expand Down Expand Up @@ -265,14 +266,14 @@ const {
class="rule-based-import-wizard"
@submit="submit">
<template v-slot:header>
<BAlert
<GAlert
:show="!!uploadErrorMessage"
variant="danger"
class="my-2"
dismissible
@dismissed="uploadErrorMessage = ''">
{{ uploadErrorMessage }}
</BAlert>
</GAlert>
<h2 data-galaxy-file-drop-target>
{{ title }}
<a v-g-tooltip.hover aria-label="Upload Completed Workbook" :title="dropWorkbookTitle" href="#">
Expand Down
27 changes: 14 additions & 13 deletions client/src/components/Collections/CollectionCreatorIndex.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { faCheckCircle, faUndo } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BAlert, BLink } from "bootstrap-vue";
import { BLink } from "bootstrap-vue";
import { computed, ref, watch } from "vue";

import { type CreateNewCollectionPayload, type HDCASummary, type HistoryItemSummary, isHDCA } from "@/api";
Expand All @@ -21,6 +21,7 @@ import ListCollectionCreator from "./ListCollectionCreator.vue";
import PairCollectionCreator from "./PairCollectionCreator.vue";
import PairedOrUnpairedListCollectionCreator from "./PairedOrUnpairedListCollectionCreator.vue";
import SampleSheetCollectionCreator from "./SampleSheetCollectionCreator.vue";
import GAlert from "@/components/BaseComponents/GAlert.vue";
import Heading from "@/components/Common/Heading.vue";
import GenericItem from "@/components/History/Content/GenericItem.vue";
import LoadingSpan from "@/components/LoadingSpan.vue";
Expand Down Expand Up @@ -222,28 +223,28 @@ defineExpose({ redrawCreator });
</Heading>
</template>

<BAlert v-if="isFetchingItems && !initialFetch" variant="info" show>
<GAlert v-if="isFetchingItems && !initialFetch" variant="info" show>
<LoadingSpan :message="localize('Loading items')" />
</BAlert>
<BAlert v-else-if="!fromSelection && historyItemsError" variant="danger" show>
</GAlert>
<GAlert v-else-if="!fromSelection && historyItemsError" variant="danger" show>
{{ historyItemsError }}
</BAlert>
<BAlert v-else-if="creatingCollection" variant="info" show>
</GAlert>
<GAlert v-else-if="creatingCollection" variant="info" show>
<LoadingSpan :message="localize('Creating collection')" />
</BAlert>
<BAlert v-else-if="createCollectionError" variant="danger" show>
</GAlert>
<GAlert v-else-if="createCollectionError" variant="danger" show>
{{ createCollectionError }}
<BLink class="text-decoration-none" @click.stop.prevent="resetCreator">
<FontAwesomeIcon :icon="faUndo" fixed-width />
{{ localize("Try again") }}
</BLink>
</BAlert>
</GAlert>
<div v-else-if="createdCollection">
<BAlert v-if="!createdCollectionInReadyState" variant="info" show>
<GAlert v-if="!createdCollectionInReadyState" variant="info" show>
<LoadingSpan :message="localize('Waiting for collection to be ready')" />
</BAlert>
</GAlert>
<template v-else>
<BAlert variant="success" show>
<GAlert variant="success" show>
<FontAwesomeIcon :icon="faCheckCircle" class="text-success" fixed-width />
{{ localize("Collection created successfully.") }}
{{ localize("It might still not be a valid input based on individual element properties.") }}
Expand All @@ -252,7 +253,7 @@ defineExpose({ redrawCreator });
<FontAwesomeIcon :icon="faUndo" fixed-width />
{{ localize("Create another collection") }}
</BLink>
</BAlert>
</GAlert>

<GenericItem
v-if="createdCollection.history_content_type === 'dataset_collection'"
Expand Down
Loading
Loading