Merge pull request #22639 from ahmedhamidawan/fix_invocation_view_header_wrapping

Minor styling fixes for invocation view header and history list cards
This commit is contained in:
Ahmed Hamid Awan
2026-05-21 20:05:07 -05:00
committed by GitHub
17 changed files with 148 additions and 77 deletions
@@ -83,10 +83,12 @@ function revertToOriginal() {
<component
:is="props.component || 'label'"
v-else
v-g-tooltip.onoverflow
role="button"
for="click-to-edit-input"
class="click-to-edit-label text-break"
tabindex="0"
:title="computedValue || title"
@keyup.enter="editable = true"
@click.stop="editable = true">
<span v-if="computedValue">{{ computedValue }}</span>
+31 -13
View File
@@ -268,6 +268,17 @@ const emit = defineEmits<{
const bookmarkLoading = ref(false);
// Track open state of the extra-actions dropdown so the card can raise its
// stacking context while open. Without this, the open menu can visually drop
// over the next card row but get pixel-intercepted by that card's primary
// action buttons (see issue surfaced by Selenium test_delete_and_undelete_history).
const extraActionsOpen = ref(false);
function onExtraDropdown(open: boolean) {
extraActionsOpen.value = open;
emit("dropdown", open);
}
/**
* Toggles bookmark status with loading state
*/
@@ -316,6 +327,7 @@ function onKeyDown(event: KeyboardEvent) {
{ 'g-card-published': published },
{ 'g-card-clickable': props.clickable },
{ 'g-card-dim': props.dimWhenUnselected && !props.selected },
{ 'g-card-dropdown-open': extraActionsOpen },
containerClass,
]"
:tabindex="props.clickable ? 0 : undefined"
@@ -329,7 +341,7 @@ function onKeyDown(event: KeyboardEvent) {
<div class="d-flex flex-column flex-gapy-1">
<div
:id="`g-card-${props.id}-header`"
class="d-flex flex-wrap flex-gapy-1 flex-gapx-1 justify-content-between">
class="d-flex flex-gapy-1 flex-gapx-1 justify-content-between">
<div class="d-flex flex-column flex-grow-1 g-card-title-section">
<div class="d-flex">
<div v-if="selectable">
@@ -349,7 +361,6 @@ function onKeyDown(event: KeyboardEvent) {
:id="getElementId(props.id, 'title')"
bold
inline
class="d-block"
:size="props.titleSize">
<FontAwesomeIcon
v-if="props.titleIcon?.icon"
@@ -371,7 +382,7 @@ function onKeyDown(event: KeyboardEvent) {
<template v-else>
<span
:id="getElementId(props.id, 'title-text')"
v-g-tooltip.hover
v-g-tooltip.onoverflow
:title="localize(title)"
:class="{ 'g-card-title-truncate': props.titleNLines }">
{{ title }}
@@ -465,8 +476,8 @@ function onKeyDown(event: KeyboardEvent) {
title="More options"
toggle-class="inline-icon-button"
variant="link"
@show="() => emit('dropdown', true)"
@hide="() => emit('dropdown', false)">
@show="() => onExtraDropdown(true)"
@hide="() => onExtraDropdown(false)">
<template v-slot:button-content>
<FontAwesomeIcon :icon="faCaretDown" fixed-width />
</template>
@@ -694,6 +705,20 @@ function onKeyDown(event: KeyboardEvent) {
container: g-card / inline-size;
width: 100%;
// While the extra-actions dropdown is open, raise this card above its
// siblings so the menu does not get pixel-intercepted by a neighboring
// card's primary action buttons drawn at the same screen coordinates,
// and relax the content's overflow:hidden so popper-positioned menu
// items (especially when flipped above the toggle) are not clipped.
&.g-card-dropdown-open {
position: relative;
z-index: 2;
.g-card-content {
overflow: visible;
}
}
&.g-card-grid-view {
width: calc(100% / 3);
@@ -757,14 +782,7 @@ function onKeyDown(event: KeyboardEvent) {
}
.g-card-rename {
visibility: hidden;
}
&:hover,
&:focus-within {
.g-card-rename {
visibility: visible;
}
align-self: flex-start;
}
.g-card-content {
+10 -7
View File
@@ -29,7 +29,6 @@
import { storeToRefs } from "pinia";
import { computed } from "vue";
import { useRouter } from "vue-router/composables";
import { userOwnsHistory } from "@/api";
import type { AnyHistoryEntry } from "@/api/histories";
@@ -120,8 +119,6 @@ const props = withDefaults(defineProps<Props>(), {
highlighted: false,
});
const router = useRouter();
const historyStore = useHistoryStore();
const userStore = useUserStore();
@@ -143,6 +140,12 @@ const emit = defineEmits<{
*/
(e: "titleClick", history: AnyHistoryEntry["id"]): void;
/**
* Emitted when the rename action is triggered
* @event rename
*/
(e: "rename", id: string, name: string): void;
/**
* Emitted when a tag is clicked for filtering
* @event tagClick
@@ -178,8 +181,8 @@ const emit = defineEmits<{
* Handles clicking on the history title to navigate to the history view
* @function onTitleClick
*/
function onTitleClick() {
router.push(`/histories/view?id=${props.history.id}`);
async function onTitleClick() {
await historyStore.setCurrentHistory(props.history.id);
}
/**
@@ -189,7 +192,7 @@ function onTitleClick() {
const historyCardTitle = computed(() => {
return {
label: props.history.name,
title: localize("Click to view this history"),
title: localize("Click to set as current"),
handler: onTitleClick,
};
});
@@ -270,7 +273,7 @@ function onKeyDown(event: KeyboardEvent) {
:clickable="props.clickable"
:highlighted="props.highlighted"
@titleClick="onTitleClick"
@rename="() => router.push(`/histories/rename?id=${history.id}`)"
@rename="emit('rename', history.id, history.name)"
@select="isMyHistory(history) && emit('select', history)"
@tagsUpdate="(tags) => onTagsUpdate(history.id, tags)"
@tagClick="(tag) => emit('tagClick', tag)"
@@ -23,11 +23,13 @@
* @tagClick="onTagClick" />
*/
import type { Ref } from "vue";
import { reactive, type Ref, ref } from "vue";
import type { AnyHistoryEntry, MyHistory } from "@/api/histories";
import { isMyHistory } from "@/api/histories";
import { useHistoryStore } from "@/stores/historyStore";
import RenameModal from "@/components/Common/RenameModal.vue";
import HistoryCard from "@/components/History/HistoryCard.vue";
interface Props {
@@ -150,6 +152,28 @@ const emit = defineEmits<{
*/
(e: "on-history-card-click", history: AnyHistoryEntry, event: Event): void;
}>();
const historyStore = useHistoryStore();
const modalOptions = reactive({
rename: {
id: "",
name: "",
},
});
const showRename = ref(false);
function onRenameClose() {
showRename.value = false;
emit("refreshList", true, true);
}
function onRename(id: string, name: string) {
modalOptions.rename.id = id;
modalOptions.rename.name = name;
showRename.value = true;
}
</script>
<template>
@@ -173,8 +197,16 @@ const emit = defineEmits<{
@tagClick="(...args) => emit('tagClick', ...args)"
@refreshList="(...args) => emit('refreshList', ...args)"
@updateFilter="(...args) => emit('updateFilter', ...args)"
@rename="onRename"
@on-key-down="(...args) => emit('on-key-down', ...args)"
@on-history-card-click="(...args) => emit('on-history-card-click', ...args)" />
<RenameModal
v-if="showRename"
item-type="history"
:name="modalOptions.rename.name"
:rename-action="(newName) => historyStore.updateHistory(modalOptions.rename.id, { name: newName })"
@close="onRenameClose" />
</div>
</template>
@@ -3,7 +3,7 @@ import { faPen, faSave, faUndo } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BButton, BFormInput, BFormTextarea } from "bootstrap-vue";
import { storeToRefs } from "pinia";
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { computed, ref } from "vue";
import { useUserStore } from "@/stores/userStore";
import l from "@/utils/localization";
@@ -41,8 +41,6 @@ const userStore = useUserStore();
const { isAnonymous } = storeToRefs(userStore);
const nameRef = ref<HTMLInputElement | null>(null);
const clickToEditRef = ref<InstanceType<typeof ClickToEdit> | null>(null);
const clickToEditClamped = ref(false);
const editing = ref(false);
const textSelected = ref(false);
@@ -107,19 +105,6 @@ function onToggle() {
}
}
function checkClickToEditClamped() {
const el = clickToEditRef.value?.$el;
if (el) {
clickToEditClamped.value = el.scrollHeight > el.clientHeight;
}
}
onMounted(checkClickToEditClamped);
watch(
() => props.name,
() => nextTick(checkClickToEditClamped),
);
function selectText() {
if (!textSelected.value) {
nameRef.value?.select();
@@ -137,11 +122,8 @@ function selectText() {
<template v-if="!summarized && !editing">
<ClickToEdit
v-if="renameable"
ref="clickToEditRef"
v-model="clickToEditName"
v-g-tooltip.hover="clickToEditClamped ? name : ''"
component="h3"
title="..."
data-description="name display"
no-save-on-blur
class="name-display my-2 w-100" />
@@ -194,7 +176,7 @@ function selectText() {
v-if="tags"
:class="{
'mt-2': !summarized,
tags: ['both', 'tags'].includes(summarized),
tags: ['both', 'tags'].includes(summarized || ''),
hidden: summarized === 'hidden',
}"
:value="tags"
@@ -68,9 +68,10 @@ const toolPanelHeader = computed(() => {
}
});
const headingClass = computed(() =>
currentPanelView.value !== "default" && !isFavoritesView.value ? "font-italic" : "",
);
const headingClass = computed<Record<string, boolean>>(() => ({
"font-italic": currentPanelView.value !== "default" && !isFavoritesView.value,
"text-left": true,
}));
const showPanelIcon = computed(() => !!panelIcon.value && !loading.value);
const groupedPanelViews = computed(() => {
@@ -136,10 +137,10 @@ async function updatePanelView(panel: Panel) {
<template v-slot:button-content>
<span class="sr-only">View all tool panel configurations</span>
<div class="d-flex panel-view-selector justify-content-between flex-gapx-1">
<div>
<div class="d-flex flex-gapx-1">
<FontAwesomeIcon
v-if="showPanelIcon && !isFavoritesView"
class="mr-1"
class="mr-1 mt-1"
:icon="panelIcon"
data-description="panel view header icon" />
<Heading id="toolbox-heading" :class="headingClass" h2 inline size="sm">
@@ -128,6 +128,9 @@ initializePanel();
:deep(.activity-panel-header) {
margin-right: 1rem;
margin-left: 1rem;
.activity-panel-header-top {
align-items: flex-start;
}
}
}
</style>
@@ -178,7 +178,6 @@ function createNew(event: Event) {
:filterable="false"
:current-workflow-id="props.currentWorkflowId"
editor-view
compact
@insertWorkflow="(...args) => emit('insertWorkflow', ...args)"
@insertWorkflowSteps="(...args) => emit('insertWorkflowSteps', ...args)"
@refreshList="refresh" />
+1 -1
View File
@@ -26,7 +26,7 @@ const props = withDefaults(defineProps<Props>(), {
<template>
<div class="my-1 progress-container">
<small v-if="props.note" class="progress-note">
<small v-if="props.note" v-g-tooltip.onoverflow class="progress-note" :title="props.note">
{{ props.note }}<span v-if="props.loading">.<span class="blinking">..</span></span>
</small>
<BProgress :max="props.total">
@@ -93,7 +93,7 @@ function getInvocationBadges(invocation: WorkflowInvocation) {
{
id: "state",
label: invocation.state,
title: invocation.state,
title: "",
class: stateClass(invocation.state),
visible: true,
},
@@ -18,7 +18,6 @@ interface Props {
filterable?: boolean;
publishedView?: boolean;
editorView?: boolean;
compact?: boolean;
current?: boolean;
selected?: boolean;
selectable?: boolean;
@@ -32,7 +31,6 @@ const props = withDefaults(defineProps<Props>(), {
hideRuns: false,
filterable: true,
editorView: false,
compact: false,
current: false,
selected: false,
selectable: false,
@@ -131,10 +129,10 @@ function onKeyDown(event: KeyboardEvent) {
<GCard
:id="workflow.id"
class="workflow-card"
can-rename-title
:can-rename-title="!props.workflow.deleted"
:title="workflowCardTitle"
:title-badges="workflowCardTitleBadges"
:title-n-lines="props.compact ? 2 : undefined"
:title-n-lines="2"
:description="description || ''"
:grid-view="props.gridView"
:badges="workflowCardBadges"
@@ -19,7 +19,6 @@ interface Props {
filterable?: boolean;
publishedView?: boolean;
editorView?: boolean;
compact?: boolean;
currentWorkflowId?: string;
selectedWorkflowIds?: SelectedWorkflow[];
itemRefs?: Record<string, Ref<InstanceType<typeof WorkflowCard> | null>>;
@@ -33,7 +32,6 @@ const props = withDefaults(defineProps<Props>(), {
filterable: true,
publishedView: false,
editorView: false,
compact: false,
currentWorkflowId: "",
selectedWorkflowIds: () => [],
itemRefs: () => ({}),
@@ -107,7 +105,6 @@ const workflowPublished = ref<InstanceType<typeof WorkflowPublished>>();
:filterable="props.filterable"
:published-view="props.publishedView"
:editor-view="props.editorView"
:compact="props.compact"
:current="workflow.id === props.currentWorkflowId"
:clickable="props.clickable"
:highlighted="props.rangeSelectAnchor?.id === workflow.id"
@@ -136,9 +136,9 @@ async function rerunWorkflow() {
<div class="position-relative">
<div v-if="workflow" class="bg-secondary px-2 py-1 rounded d-flex flex-gapx-1 justify-content-between">
<div class="py-1 d-flex flex-wrap align-items-center flex-gapx-1" data-description="workflow heading">
<div class="py-1 align-items-center" data-description="workflow heading">
<slot name="before-icon" />
<FontAwesomeIcon :icon="faSitemap" fixed-width />
<FontAwesomeIcon class="mr-1" :icon="faSitemap" fixed-width />
<b> {{ props.invocation ? "Invoked " : "" }}Workflow: {{ getWorkflowName() }} </b>
<span>(Version: {{ workflow.version + 1 }})</span>
</div>
@@ -5,7 +5,6 @@ import {
faExclamation,
faSpinner,
faSquare,
faTimes,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BAlert, BBadge, BNav, BNavItem } from "bootstrap-vue";
@@ -450,17 +449,6 @@ async function onCancel() {
<BBadge v-if="isPolling" v-g-tooltip.hover title="Polling for updates" variant="link">
<FontAwesomeIcon :icon="faSpinner" spin />
</BBadge>
<GButton
v-if="!invocationAndJobTerminal"
tooltip
class="my-1"
title="Cancel scheduling of workflow invocation"
data-description="cancel invocation button"
size="small"
@click="onCancel">
<FontAwesomeIcon :icon="faTimes" fixed-width />
Cancel Workflow
</GButton>
</div>
</BNav>
@@ -582,7 +570,6 @@ async function onCancel() {
// progress bar shrinks to fit divs on either side
flex-grow: 1;
flex-shrink: 1;
max-width: 50%;
.steps-progress,
.jobs-progress {
+31
View File
@@ -7,6 +7,7 @@
* Trigger: .hover (default), .focus
* Content: .html (innerHTML instead of textContent)
* Styling: .v-danger
* Behavior: .onoverflow (only show tooltip when text overflows with ellipsis)
*
* Value forms:
* v-g-tooltip → reads element's title attribute
@@ -37,6 +38,8 @@ interface TooltipState {
placement: Placement;
isHtml: boolean;
showDelay: ReturnType<typeof useDelayedAction>;
onOverflow: boolean;
resizeObserver: ResizeObserver | null;
}
const stateMap = new WeakMap<HTMLElement, TooltipState>();
@@ -219,6 +222,15 @@ function showTooltip(el: HTMLElement) {
return;
}
// If onOverflow is enabled, only show tooltip when text is overflowing
if (state.onOverflow) {
// Use a threshold of 1px to account for line clamped text
const isOverflowing = el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1;
if (!isOverflowing) {
return;
}
}
suppressNativeTooltip(el);
if (!state.tooltipEl.isConnected) {
@@ -339,6 +351,7 @@ export const vGTooltip: ObjectDirective<HTMLElement> = {
const modifiers = binding.modifiers || {};
const isDanger = !!modifiers["v-danger"];
const isHtml = !!modifiers.html;
const onOverflow = !!modifiers.onoverflow;
const placement = getPlacement(modifiers, binding.value);
const { tooltipEl, arrowEl, contentEl } = createTooltipEl(isDanger);
@@ -348,6 +361,21 @@ export const vGTooltip: ObjectDirective<HTMLElement> = {
tooltipEl.id = uid;
el.setAttribute("aria-describedby", uid);
let resizeObserver: ResizeObserver | null = null;
if (onOverflow) {
// Watch for size changes to re-evaluate overflow state
resizeObserver = new ResizeObserver(() => {
// If tooltip is showing but no longer overflowing, hide it
if (tooltipEl.isConnected) {
const isOverflowing = el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1;
if (!isOverflowing) {
hideTooltip(el);
}
}
});
resizeObserver.observe(el);
}
const state: TooltipState = {
tooltipEl,
arrowEl,
@@ -357,6 +385,8 @@ export const vGTooltip: ObjectDirective<HTMLElement> = {
placement,
isHtml,
showDelay: useDelayedAction(DEFAULT_TOOLTIP_HOVER_DELAY_MS),
onOverflow,
resizeObserver,
};
stateMap.set(el, state);
@@ -386,6 +416,7 @@ export const vGTooltip: ObjectDirective<HTMLElement> = {
state.cleanupListeners();
clearPendingShow(el);
state.cleanupAutoUpdate?.();
state.resizeObserver?.disconnect();
state.tooltipEl.remove();
restoreNativeTooltip(el);
el.removeAttribute("aria-describedby");
+9 -2
View File
@@ -778,12 +778,19 @@ class NavigatesGalaxy(HasDriverProxy[WaitType]):
target_card = self.get_history_card(card_name)
if is_in_extra:
# Open the extra-actions dropdown and click the menu item directly.
# Bootstrap-vue keeps menu items in the DOM for every card, so the
# action lookup must be scoped to target_card to avoid picking a
# match from a closed dropdown on another card.
target_card.find_element(By.CSS_SELECTOR, '[id^="g-card-extra-actions-history-"]').click()
self.sleep_for(self.wait_types.UX_RENDER)
target_card.find_element(By.CSS_SELECTOR, action_selector).click()
return
action_selector = target_card.find_element(By.CSS_SELECTOR, action_selector)
action_element = target_card.find_element(By.CSS_SELECTOR, action_selector)
# Hover over parent card first to activate hover state in headless mode
self.action_chains().move_to_element(target_card).perform()
self.move_to_and_click(action_selector)
self.move_to_and_click(action_element)
def edit_dataset_dbkey(self, dbkey_text):
# precondition: need to be on the dataset edit component
@@ -65,12 +65,19 @@ class TestSavedHistories(SharedStateSeleniumTestCase):
self.select_history_card_operation("Unnamed history", '[id^="g-card-rename-history-"]')
# Rename the history
history_name_input = self.wait_for_selector(".ui-form-element input.ui-input")
history_name_input.clear()
# Rename the history using the RenameModal.
# Clear via JS + dispatch Vue-compatible input event so nameModel is updated to empty,
# then type the new name so each keystroke fires input events and updates nameModel.
history_name_input = self.wait_for_selector("#history-name-input")
self.execute_script(
"arguments[0].value = ''; arguments[0].dispatchEvent(new Event('input', {bubbles: true}));",
history_name_input,
)
history_name_input.send_keys(self.history1_name)
self.wait_for_and_click_selector("button#submit")
self.wait_for_and_click_selector(".g-modal-confirm-buttons button:last-child")
# Wait for the rename API call to complete (modal closes in the finally block)
self.wait_for_selector_absent_or_hidden("#history-name-input")
self.navigate_to_histories_page()
@@ -368,3 +375,7 @@ class TestSavedHistories(SharedStateSeleniumTestCase):
def create_history(self, name):
self.home()
self.history_panel_create_new_with_name(name)
# Wait for the panel label to reflect the new name, confirming the
# rename XHR has completed before any subsequent home() navigation
# that would otherwise cancel the still-in-flight request.
self.wait_for_selector(f'[data-description="name display"][title="{name}"]')