Merge pull request #22982 from mvdbeek/merge_release_26.1_into_dev

Merge release_26.1 into dev
This commit is contained in:
Marius van den Beek
2026-06-21 08:03:59 +02:00
committed by GitHub
277 changed files with 11232 additions and 4556 deletions
+171 -127
View File
@@ -11110,6 +11110,8 @@ export interface components {
* @constant
*/
type: "data";
/** Url Default */
url_default?: string | null;
};
/** DataRequestCollectionUri */
DataRequestCollectionUri: {
@@ -12973,30 +12975,42 @@ export interface components {
};
/** FilePatternDatasetCollectionDescription */
FilePatternDatasetCollectionDescription: {
/** Assign Primary Output */
/**
* Assign Primary Output
* @default false
*/
assign_primary_output: boolean;
/** Directory */
directory: string | null;
directory?: string | null;
/**
* Discover Via
* @default pattern
* @constant
*/
discover_via: "pattern";
/** Format */
format: string | null;
/** Match Relative Path */
format?: string | null;
/**
* Match Relative Path
* @default false
*/
match_relative_path: boolean;
/** Pattern */
pattern: string;
/** Recurse */
/**
* Recurse
* @default false
*/
recurse: boolean;
/**
* Sort Comp
* @default lexical
* @enum {string}
*/
sort_comp: "lexical" | "numeric";
/**
* Sort Key
* @default filename
* @enum {string}
*/
sort_key: "filename" | "name" | "designation" | "dbkey";
@@ -13005,7 +13019,10 @@ export interface components {
* @default false
*/
sort_reverse: boolean;
/** Visible */
/**
* Visible
* @default false
*/
visible: boolean;
};
/** FileRequestUri */
@@ -16079,6 +16096,29 @@ export interface components {
*/
type: "in_range";
};
/** IncomingToolOutputBoolean */
IncomingToolOutputBoolean: {
/**
* Hidden
* @description If true, the output will not be shown in the history.
*/
hidden?: boolean | null;
/**
* Label
* @description Output label. Will be used as dataset name in history.
*/
label?: string | null;
/**
* Name
* @description Parameter name. Used when referencing parameter in workflows.
*/
name: string;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
type: "boolean";
};
/** IncomingToolOutputCollection */
IncomingToolOutputCollection: {
/** Collection Type */
@@ -16172,6 +16212,75 @@ export interface components {
*/
type: "data";
};
/** IncomingToolOutputFloat */
IncomingToolOutputFloat: {
/**
* Hidden
* @description If true, the output will not be shown in the history.
*/
hidden?: boolean | null;
/**
* Label
* @description Output label. Will be used as dataset name in history.
*/
label?: string | null;
/**
* Name
* @description Parameter name. Used when referencing parameter in workflows.
*/
name: string;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
type: "float";
};
/** IncomingToolOutputInteger */
IncomingToolOutputInteger: {
/**
* Hidden
* @description If true, the output will not be shown in the history.
*/
hidden?: boolean | null;
/**
* Label
* @description Output label. Will be used as dataset name in history.
*/
label?: string | null;
/**
* Name
* @description Parameter name. Used when referencing parameter in workflows.
*/
name: string;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
type: "integer";
};
/** IncomingToolOutputText */
IncomingToolOutputText: {
/**
* Hidden
* @description If true, the output will not be shown in the history.
*/
hidden?: boolean | null;
/**
* Label
* @description Output label. Will be used as dataset name in history.
*/
label?: string | null;
/**
* Name
* @description Parameter name. Used when referencing parameter in workflows.
*/
name: string;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
type: "text";
};
/** InferredCollectionTypeLogEntry */
InferredCollectionTypeLogEntry: {
/** From Columns */
@@ -24205,116 +24314,36 @@ export interface components {
*/
uuid: string;
};
/** ToolOutputBoolean */
ToolOutputBoolean: {
/**
* Hidden
* @description If true, the output will not be shown in the history.
*/
hidden: unknown;
/**
* Label
* @description Output label. Will be used as dataset name in history.
*/
label?: string | null;
/**
* Name
* @description Parameter name. Used when referencing parameter in workflows.
*/
name: unknown;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
type: "boolean";
};
/** ToolOutputFloat */
ToolOutputFloat: {
/**
* Hidden
* @description If true, the output will not be shown in the history.
*/
hidden: unknown;
/**
* Label
* @description Output label. Will be used as dataset name in history.
*/
label?: string | null;
/**
* Name
* @description Parameter name. Used when referencing parameter in workflows.
*/
name: unknown;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
type: "float";
};
/** ToolOutputInteger */
ToolOutputInteger: {
/**
* Hidden
* @description If true, the output will not be shown in the history.
*/
hidden: unknown;
/**
* Label
* @description Output label. Will be used as dataset name in history.
*/
label?: string | null;
/**
* Name
* @description Parameter name. Used when referencing parameter in workflows.
*/
name: unknown;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
type: "integer";
};
/** ToolOutputText */
ToolOutputText: {
/**
* Hidden
* @description If true, the output will not be shown in the history.
*/
hidden: unknown;
/**
* Label
* @description Output label. Will be used as dataset name in history.
*/
label?: string | null;
/**
* Name
* @description Parameter name. Used when referencing parameter in workflows.
*/
name: unknown;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
type: "text";
};
/** ToolProvidedMetadataDatasetCollection */
ToolProvidedMetadataDatasetCollection: {
/** Assign Primary Output */
/**
* Assign Primary Output
* @default false
*/
assign_primary_output: boolean;
/** Directory */
directory: string | null;
directory?: string | null;
/**
* Discover Via
* @constant
*/
discover_via: "tool_provided_metadata";
/** Format */
format: string | null;
/** Match Relative Path */
format?: string | null;
/**
* Match Relative Path
* @default false
*/
match_relative_path: boolean;
/** Recurse */
/**
* Recurse
* @default false
*/
recurse: boolean;
/** Visible */
/**
* Visible
* @default false
*/
visible: boolean;
};
/** ToolReportForDataset */
@@ -25721,7 +25750,15 @@ export interface components {
*/
version: string;
};
/** UserToolSource */
/**
* UserToolSource
* @description Full unprivileged tool source, including the optional ``tests`` block.
*
* This is the model persisted and validated on the API surface
* (``DynamicUnprivilegedToolCreatePayload.representation``). LLM authoring
* uses the slimmer ``UserToolSourceAuthoringView`` parent; ``tests`` is added
* back here so direct authors and stored rows can still carry tests.
*/
"UserToolSource-Input": {
/** citations */
citations?: components["schemas"]["Citation"][] | null;
@@ -25784,10 +25821,10 @@ export interface components {
outputs: (
| components["schemas"]["IncomingToolOutputDataset"]
| components["schemas"]["IncomingToolOutputCollection"]
| components["schemas"]["ToolOutputText"]
| components["schemas"]["ToolOutputInteger"]
| components["schemas"]["ToolOutputFloat"]
| components["schemas"]["ToolOutputBoolean"]
| components["schemas"]["IncomingToolOutputText"]
| components["schemas"]["IncomingToolOutputInteger"]
| components["schemas"]["IncomingToolOutputFloat"]
| components["schemas"]["IncomingToolOutputBoolean"]
)[];
/** profile */
profile?: number | null;
@@ -25806,7 +25843,7 @@ export interface components {
/**
* shell_command
* @description A string that contains the command to be executed. Parameters can be referenced inside $().
* @example head -n '$(inputs.n_lines)' '$(inputs.data_input.path)'
* @example head -n '$(inputs.num_lines)' '$(inputs.input_file.path)' > output.txt
*/
shell_command: string;
/** tests */
@@ -25816,11 +25853,19 @@ export interface components {
* @description Version for the tool.
* @example 0.1.0
*/
version?: string | null;
version: string;
/** xrefs */
xrefs?: components["schemas"]["XrefDict"][] | null;
};
/** UserToolSource */
/**
* UserToolSource
* @description Full unprivileged tool source, including the optional ``tests`` block.
*
* This is the model persisted and validated on the API surface
* (``DynamicUnprivilegedToolCreatePayload.representation``). LLM authoring
* uses the slimmer ``UserToolSourceAuthoringView`` parent; ``tests`` is added
* back here so direct authors and stored rows can still carry tests.
*/
"UserToolSource-Output": {
/** citations */
citations?: components["schemas"]["Citation"][] | null;
@@ -25883,10 +25928,10 @@ export interface components {
outputs: (
| components["schemas"]["IncomingToolOutputDataset"]
| components["schemas"]["IncomingToolOutputCollection"]
| components["schemas"]["ToolOutputText"]
| components["schemas"]["ToolOutputInteger"]
| components["schemas"]["ToolOutputFloat"]
| components["schemas"]["ToolOutputBoolean"]
| components["schemas"]["IncomingToolOutputText"]
| components["schemas"]["IncomingToolOutputInteger"]
| components["schemas"]["IncomingToolOutputFloat"]
| components["schemas"]["IncomingToolOutputBoolean"]
)[];
/** profile */
profile?: number | null;
@@ -25905,7 +25950,7 @@ export interface components {
/**
* shell_command
* @description A string that contains the command to be executed. Parameters can be referenced inside $().
* @example head -n '$(inputs.n_lines)' '$(inputs.data_input.path)'
* @example head -n '$(inputs.num_lines)' '$(inputs.input_file.path)' > output.txt
*/
shell_command: string;
/** tests */
@@ -25915,7 +25960,7 @@ export interface components {
* @description Version for the tool.
* @example 0.1.0
*/
version?: string | null;
version: string;
/** xrefs */
xrefs?: components["schemas"]["XrefDict"][] | null;
};
@@ -27281,12 +27326,9 @@ export interface components {
help?: string | null;
/** Label */
label?: string | null;
/** Max */
max?: number | null;
/** Min */
min?: number | null;
/**
* Multiple
* @description Set true to accept several datasets (a list) for this input instead of one.
* @default false
*/
multiple: boolean;
@@ -27690,10 +27732,10 @@ export interface components {
outputs: (
| components["schemas"]["IncomingToolOutputDataset"]
| components["schemas"]["IncomingToolOutputCollection"]
| components["schemas"]["ToolOutputText"]
| components["schemas"]["ToolOutputInteger"]
| components["schemas"]["ToolOutputFloat"]
| components["schemas"]["ToolOutputBoolean"]
| components["schemas"]["IncomingToolOutputText"]
| components["schemas"]["IncomingToolOutputInteger"]
| components["schemas"]["IncomingToolOutputFloat"]
| components["schemas"]["IncomingToolOutputBoolean"]
)[];
/** profile */
profile?: number | null;
@@ -27712,7 +27754,7 @@ export interface components {
/**
* shell_command
* @description A string that contains the command to be executed. Parameters can be referenced inside $().
* @example head -n '$(inputs.n_lines)' '$(inputs.data_input.path)'
* @example head -n '$(inputs.num_lines)' '$(inputs.input_file.path)' > output.txt
*/
shell_command: string;
/** tests */
@@ -44953,6 +44995,8 @@ export interface operations {
/** @description Sort in descending order? */
sort_desc?: boolean;
user_id?: string | null;
/** @description Filter pages by this workflow invocation ID. */
invocation_id?: string | null;
history_id?: string | null;
};
header?: {
+2
View File
@@ -30,6 +30,8 @@ export function isWorkflowInvocationElementView(
return item !== null && "steps" in item;
}
// TODO: Consider caching this in the store given this doesn't change as it is generated once
// when an invocation is complete?
/**
* Fetches the invocation report for a given invocation ID
* @param {string} invocationId The ID of the invocation to fetch the report for
+10 -2
View File
@@ -16,9 +16,17 @@ export type UpdateHistoryPagePayload = components["schemas"]["UpdatePagePayload"
// --- API functions ---
export async function fetchHistoryPages(historyId: string): Promise<HistoryPageSummary[]> {
export async function fetchHistoryPages(historyId: string, invocationId?: string): Promise<HistoryPageSummary[]> {
const { data, error } = await GalaxyApi().GET("/api/pages", {
params: { query: { history_id: historyId, show_own: true, show_published: false } },
params: {
query: {
history_id: historyId,
invocation_id: invocationId,
show_own: true,
show_published: false,
sort_desc: true,
},
},
});
if (error) {
rethrowSimple(error);
@@ -21,7 +21,7 @@ vi.mock("@/composables/config", () => ({
}));
vi.mock("vue-router/composables", () => ({
useRoute: vi.fn(() => ({})),
useRoute: vi.fn(() => ({ path: "/", params: {}, query: {} })),
useRouter: vi.fn(() => ({ push: vi.fn() })),
}));
@@ -3,11 +3,12 @@ import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { faBell, faEllipsisH, faUserCog } from "@fortawesome/free-solid-svg-icons";
import { watchImmediate } from "@vueuse/core";
import { storeToRefs } from "pinia";
import { computed, type Ref, ref } from "vue";
import { computed, type Ref, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router/composables";
import draggable from "vuedraggable";
import { useConfig } from "@/composables/config";
import { useActiveContext } from "@/composables/useActiveContext";
import { convertDropData } from "@/stores/activitySetup";
import { useActivityStore } from "@/stores/activityStore";
import type { Activity } from "@/stores/activityStoreTypes";
@@ -83,6 +84,15 @@ const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const chatStore = useChatStore();
const { activeContext } = useActiveContext();
// Notebook context must never use center mode — the chat panel floats alongside
// the page editor, so force right-panel whenever we enter a notebook context.
watch(activeContext, (ctx) => {
if (ctx?.contextType === "notebook" && chatStore.isCenterMode) {
chatStore.setLocation("right");
}
});
const eventStore = useEventStore();
const activityStore = useActivityStore(props.activityBarId);
@@ -240,6 +250,18 @@ function toggleSidebar(toggle: string = "", to: string | null = null) {
}
function onChatGxyClick() {
// On notebook routes, always use the right panel — never navigate to center.
if (activeContext.value?.contextType === "notebook") {
if (chatStore.isCenterMode) {
chatStore.setLocation("right");
}
chatStore.toggleChat();
if (isActiveSideBar("galaxyai")) {
toggleSidebar("galaxyai");
}
return;
}
if (chatStore.isCenterMode) {
toggleSidebar("galaxyai");
if (!route.path.startsWith("/galaxyai")) {
@@ -79,10 +79,10 @@ const meta = computed(() => store.metaForId(props.id));
</script>
<template>
<Popper :placement="tooltipPlacement">
<Popper :placement="tooltipPlacement" class="activity-item-popper">
<template v-slot:reference>
<b-nav-item
class="activity-item my-1 p-2"
class="activity-item"
:class="{ 'nav-item-active': isActive }"
:link-attrs="{ id: `activity-${id}` }"
:link-classes="`variant-${props.variant}`"
@@ -137,10 +137,22 @@ const meta = computed(() => store.metaForId(props.id));
<style scoped lang="scss">
@import "@/style/scss/theme/blue.scss";
.activity-item-popper {
// Vertically centers the popper in the activity bar
:deep(.popper-reference-container) {
display: flex;
flex-direction: column;
align-items: center;
}
}
.activity-item {
position: relative;
display: flex;
flex-direction: column;
width: 5rem;
padding: 0.5rem;
margin: 0.125rem 0;
&:deep(.variant-danger) {
color: $brand-danger;
@@ -200,7 +212,6 @@ const meta = computed(() => store.metaForId(props.id));
position: relative;
display: flex;
justify-content: center;
width: 4rem;
margin-top: 0.5rem;
font-size: 0.7rem;
}
@@ -0,0 +1,112 @@
import { getLocalVue } from "@tests/vitest/helpers";
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import VueRouter from "vue-router";
import GButton from "./GButton.vue";
const localVue = getLocalVue(true);
localVue.use(VueRouter);
function mountGButton(props: object) {
return mount(GButton as object, { propsData: props, localVue });
}
describe("GButton.vue", () => {
it("uses the regular title when enabled", () => {
const wrapper = mountGButton({ title: "Click me" });
const button = wrapper.get("button");
expect(button.attributes("title")).toBe("Click me");
expect(button.attributes("data-title")).toBe("Click me");
});
it("uses the disabled title when disabled", () => {
const wrapper = mountGButton({
disabled: true,
title: "Click me",
disabledTitle: "Cannot click right now",
});
const button = wrapper.get("button");
expect(button.attributes("title")).toBe("Cannot click right now");
expect(button.attributes("data-title")).toBe("Cannot click right now");
});
it("falls back to the regular title when disabled without a disabled title", () => {
const wrapper = mountGButton({ disabled: true, title: "Click me" });
const button = wrapper.get("button");
expect(button.attributes("title")).toBe("Click me");
});
// A disabled button must stay hoverable so the (disabled) title can surface its
// tooltip. We mark it disabled via aria-disabled and a JS click guard rather than
// the native `disabled` attribute (which would suppress hover events).
it("remains hoverable when disabled", () => {
const wrapper = mountGButton({ disabled: true, disabledTitle: "Nope" });
const button = wrapper.get("button");
expect(button.attributes("aria-disabled")).toBe("true");
expect(button.attributes("disabled")).toBeUndefined();
});
it("does not emit click when disabled", async () => {
const wrapper = mountGButton({ disabled: true, disabledTitle: "Nope" });
await wrapper.get("button").trigger("click");
expect(wrapper.emitted("click")).toBeUndefined();
});
it("emits click when enabled", async () => {
const wrapper = mountGButton({ title: "Click me" });
await wrapper.get("button").trigger("click");
expect(wrapper.emitted("click")).toHaveLength(1);
});
});
describe("GButton.vue disabled navigation", () => {
// A disabled button with a `to` prop must not navigate. The component-level @click
// guard does not run for a RouterLink (Vue 2 treats @click on a component as a
// component listener, not a native one), and an empty `to` is not a reliable no-op
// in vue-router -- so a disabled GButton renders as a plain button instead.
it("renders an enabled router-link button as an anchor", () => {
const router = new VueRouter({ mode: "abstract", routes: [{ path: "/" }, { path: "/pages/create" }] });
const wrapper = mount(GButton as object, {
propsData: { to: "/pages/create" },
localVue,
router,
});
expect(wrapper.element.tagName).toBe("A");
});
it("renders a disabled router-link button as a plain button", () => {
const router = new VueRouter({ mode: "abstract", routes: [{ path: "/" }, { path: "/pages/create" }] });
const wrapper = mount(GButton as object, {
propsData: { to: "/pages/create", disabled: true, disabledTitle: "Nope" },
localVue,
router,
});
expect(wrapper.element.tagName).toBe("BUTTON");
});
it("does not navigate when a disabled router-link button is clicked", async () => {
const router = new VueRouter({ mode: "abstract", routes: [{ path: "/start" }, { path: "/pages/create" }] });
await router.push("/start?keep=me");
const routeBeforeClick = router.currentRoute.fullPath;
const wrapper = mount(GButton as object, {
propsData: { to: "/pages/create", disabled: true },
localVue,
router,
});
await wrapper.trigger("click");
expect(router.currentRoute.fullPath).toBe(routeBeforeClick);
});
});
@@ -241,8 +241,9 @@ const buttonElementRef = useResolveElement(buttonRef);
background-color: var(--color-grey-100) !important;
border-color: var(--color-grey-200) !important;
color: var(--color-grey-500) !important;
// No `pointer-events: none` here: clicks/routing are already blocked in JS, and
// suppressing pointer events would also kill hover, hiding the `disabledTitle` tooltip.
cursor: default;
pointer-events: none;
&:focus-visible {
border-color: var(--color-grey-500) !important;
@@ -239,8 +239,9 @@ defineExpose({ showModal, hideModal });
padding: var(--spacing-3);
max-height: calc(100vh - 4rem);
section {
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
@@ -253,12 +254,17 @@ defineExpose({ showModal, hideModal });
padding: var(--spacing-2);
margin: calc(var(--spacing-2) * -1);
max-height: 100%;
display: flex;
flex-direction: column;
}
}
&.g-fixed-height {
section {
height: 100%;
}
}
&.g-overflow-visible {
overflow: visible;
@@ -306,6 +312,10 @@ defineExpose({ showModal, hideModal });
height: calc(100vh - 6rem);
max-width: calc(100vw - 6rem);
max-height: calc(100vh - 6rem);
section {
height: 100%;
}
}
header {
@@ -1,19 +1,30 @@
import { computed } from "vue";
import { RouterLink } from "vue-router";
export interface ClickableProps {
to?: string;
href?: string;
disabled?: boolean;
}
/**
* returns the correct type of clickable root element based on a components props.
* Returns the correct type of clickable root element based on a component's props.
*
* A disabled component always renders as a plain `button`. Rendering it as a
* RouterLink (or anchor) would let clicks fall through to navigation: a
* component-level `@click` guard does not run for a RouterLink, and an empty
* `to`/`href` is not a reliable no-op in vue-router.
*/
export function useClickableElement(props: ClickableProps) {
if (props.to) {
return RouterLink;
} else if (props.href) {
return "a" as const;
} else {
return "button" as const;
}
return computed(() => {
if (props.disabled) {
return "button" as const;
} else if (props.to) {
return RouterLink;
} else if (props.href) {
return "a" as const;
} else {
return "button" as const;
}
});
}
+30 -4
View File
@@ -186,6 +186,16 @@ interface Props {
* @default false
*/
highlighted?: boolean;
/** Whether the card is disabled (non-interactive, dimmed appearance)
* @default false
*/
disabled?: boolean;
/** Tooltip text to show when the card is disabled
* @default undefined
*/
disabledTitle?: string;
}
const props = withDefaults(defineProps<Props>(), {
@@ -218,6 +228,8 @@ const props = withDefaults(defineProps<Props>(), {
updateTimeIcon: () => faEdit,
updateTimeTitle: "Last updated",
highlighted: false,
disabled: false,
disabledTitle: undefined,
});
/**
@@ -304,6 +316,9 @@ const getActionId = (cardId: string, actionId: string) => `g-card-action-${actio
const allowedTitleLines = computed(() => props.titleNLines);
function onKeyDown(event: KeyboardEvent) {
if (props.disabled) {
return;
}
if ((props.clickable && event.key === "Enter") || event.key === " ") {
event.stopPropagation();
emit("click", event);
@@ -318,20 +333,22 @@ function onKeyDown(event: KeyboardEvent) {
<component
:is="'div'"
:id="`g-card-${props.id}`"
:role="props.clickable ? 'button' : undefined"
:role="props.clickable && !props.disabled ? 'button' : undefined"
class="g-card pt-0 px-1 mb-2"
:class="[
{ 'g-card-grid-view': gridView },
{ 'g-card-selected': selected },
{ 'g-card-current': current },
{ 'g-card-published': published },
{ 'g-card-clickable': props.clickable },
{ 'g-card-clickable': props.clickable && !props.disabled },
{ 'g-card-disabled': props.disabled },
{ 'g-card-dim': props.dimWhenUnselected && !props.selected },
{ 'g-card-dropdown-open': extraActionsOpen },
containerClass,
]"
:tabindex="props.clickable ? 0 : undefined"
@click="props.clickable ? emit('click', $event) : undefined"
:tabindex="props.clickable && !props.disabled ? 0 : undefined"
:title="props.disabled ? props.disabledTitle : undefined"
@click="props.clickable && !props.disabled ? emit('click', $event) : undefined"
@keydown="onKeyDown">
<div
:id="`g-card-content-${props.id}`"
@@ -764,6 +781,15 @@ function onKeyDown(event: KeyboardEvent) {
box-shadow: 0 0 0 0.2rem transparentize($brand-primary, 0.75);
}
&.g-card-disabled {
cursor: default;
.g-card-content {
filter: saturate(0.3);
opacity: 0.5;
}
}
&.g-card-clickable {
cursor: pointer;
@@ -23,6 +23,7 @@ const localVue = getLocalVue();
const WORKFLOW_ID = "workflow-abc123";
const WORKFLOW_NAME = "My Test Workflow";
const INPUT_SELECTOR = "[data-description='workflow name input']";
async function mountRenameModal(name = WORKFLOW_NAME) {
const wrapper = mount(RenameModal as object, {
@@ -52,7 +53,7 @@ describe("RenameModal tested for renaming workflows", () => {
const wrapper = await mountRenameModal();
await createWrapper(document.body).find("#workflow-name-input").setValue("Renamed Workflow");
await createWrapper(document.body).find(INPUT_SELECTOR).setValue("Renamed Workflow");
wrapper.findComponent(GModal).vm.$emit("ok");
await flushPromises();
@@ -65,7 +66,7 @@ describe("RenameModal tested for renaming workflows", () => {
const wrapper = await mountRenameModal();
await createWrapper(document.body).find("#workflow-name-input").setValue("Attempted New Name");
await createWrapper(document.body).find(INPUT_SELECTOR).setValue("Attempted New Name");
wrapper.findComponent(GModal).vm.$emit("ok");
await flushPromises();
@@ -77,7 +78,7 @@ describe("RenameModal tested for renaming workflows", () => {
// Simulate parent closing and reopening the modal (destroys old instance)
wrapper.destroy();
await mountRenameModal();
expect((createWrapper(document.body).find("#workflow-name-input").element as HTMLInputElement).value).toBe(
expect((createWrapper(document.body).find(INPUT_SELECTOR).element as HTMLInputElement).value).toBe(
WORKFLOW_NAME,
);
});
+2 -1
View File
@@ -64,7 +64,7 @@ async function onRename(newName: string) {
show
:ok-text="localize('Rename')"
:ok-disabled="nameInvalid || renaming"
:title="`Rename ${props.itemType}: ${props.name}`"
:title="`Rename ${capitalizeFirstLetter(props.itemType)}: ${props.name}`"
confirm
:close-on-ok="false"
@ok="onRename(nameModel)"
@@ -73,6 +73,7 @@ async function onRename(newName: string) {
<GFormInput
:id="`${props.itemType}-name-input`"
v-model="nameModel"
:data-description="`${props.itemType} name input`"
class="w-100"
:disabled="renaming"
type="text"
+1 -1
View File
@@ -67,7 +67,7 @@ function onItemKeydown(item: any, index: number, event: KeyboardEvent) {
overflow-y: auto;
}
.sidebar-item:hover {
background: var(--gray-200);
background: var(--color-grey-200);
cursor: pointer;
}
</style>
@@ -1,41 +0,0 @@
import { getLocalVue } from "@tests/vitest/helpers";
import { shallowMount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import SplitView from "./SplitView.vue";
import DraggableSeparator from "@/components/Common/DraggableSeparator.vue";
const localVue = getLocalVue();
function mountComponent(propsData = {}) {
return shallowMount(SplitView as object, {
localVue,
propsData,
slots: {
left: '<div class="test-left">Left</div>',
right: '<div class="test-right">Right</div>',
},
});
}
describe("SplitView", () => {
it("renders split view container", () => {
const wrapper = mountComponent();
expect(wrapper.find('[data-description="split view"]').exists()).toBe(true);
});
it("renders left slot", () => {
const wrapper = mountComponent();
expect(wrapper.find(".test-left").exists()).toBe(true);
});
it("renders right slot", () => {
const wrapper = mountComponent();
expect(wrapper.find(".test-right").exists()).toBe(true);
});
it("delegates the divider to DraggableSeparator", () => {
const wrapper = mountComponent();
expect(wrapper.findComponent(DraggableSeparator).exists()).toBe(true);
});
});
@@ -1,92 +0,0 @@
<script setup lang="ts">
/**
* Two-pane split layout with a draggable resize handle.
*
* Composes ``DraggableSeparator`` for the divider, inheriting its
* keyboard accessibility, hover-delay, and animation-frame throttling.
* Caller supplies the pane content via ``left``/``right`` slots.
*/
import { useElementBounding } from "@vueuse/core";
import { computed, ref, watch } from "vue";
import DraggableSeparator from "@/components/Common/DraggableSeparator.vue";
const props = withDefaults(
defineProps<{
/** Starting size of the left pane, as a percent of the container. */
initialSplit?: number;
/** Smallest left-pane size, as a percent of the container. */
minPercent?: number;
/** Largest left-pane size, as a percent of the container. */
maxPercent?: number;
}>(),
{
initialSplit: 60,
minPercent: 20,
maxPercent: 80,
},
);
const containerRef = ref<HTMLElement>();
const { width: containerWidth } = useElementBounding(containerRef);
// Position is the left-pane width in pixels; ``DraggableSeparator`` works in
// pixels so this is the natural shape and avoids percent<->px drift.
const leftWidthPx = ref(0);
let initialized = false;
watch(
containerWidth,
(w) => {
if (!w) {
return;
}
if (!initialized) {
leftWidthPx.value = (props.initialSplit / 100) * w;
initialized = true;
} else {
const minPx = (props.minPercent / 100) * w;
const maxPx = (props.maxPercent / 100) * w;
leftWidthPx.value = Math.min(Math.max(leftWidthPx.value, minPx), maxPx);
}
},
{ immediate: true },
);
const minPx = computed(() => (containerWidth.value * props.minPercent) / 100);
const maxPx = computed(() => (containerWidth.value * props.maxPercent) / 100);
const rightWidthPx = computed(() => Math.max(containerWidth.value - leftWidthPx.value, 0));
</script>
<template>
<div ref="containerRef" class="split-view" data-description="split view">
<div class="split-pane" :style="{ flexBasis: `${leftWidthPx}px` }">
<slot name="left" />
</div>
<DraggableSeparator
:position="leftWidthPx"
side="left"
:inner="true"
:min="minPx"
:max="maxPx"
@positionChanged="(v) => (leftWidthPx = v)" />
<div class="split-pane" :style="{ flexBasis: `${rightWidthPx}px` }">
<slot name="right" />
</div>
</div>
</template>
<style scoped>
.split-view {
display: flex;
flex: 1;
overflow: hidden;
min-height: 0;
position: relative;
}
.split-pane {
overflow: auto;
min-width: 0;
}
</style>
+8 -1
View File
@@ -21,7 +21,14 @@ const trimmedText = computed(() => {
</script>
<template>
<span class="text-break text-center">
<span class="text-short-component">
{{ trimmedText }}
</span>
</template>
<style scoped>
.text-short-component {
text-align: center;
word-wrap: break-word;
}
</style>
@@ -153,6 +153,59 @@ describe("useFormState", () => {
expect(caseBInput.attributes.options).toEqual([["col1", "col1"]]);
});
it("should not overwrite earlier case attributes when multiple conditional cases share a parameter name", () => {
const { cloneInputs, formInputs, syncServerAttributes } = useFormState();
// All cases have a "feature" parameter — same name, different options per case
cloneInputs([
{
name: "col_choice",
type: "conditional",
test_param: { name: "col", type: "select", value: "0" },
cases: [
{ value: "0", inputs: [{ name: "feature", type: "select", value: null }] },
{ value: "2", inputs: [{ name: "feature", type: "select", value: null }] },
],
},
]);
// Server returns distinct options for each case
syncServerAttributes([
{
name: "col_choice",
type: "conditional",
test_param: { name: "col", type: "select" },
cases: [
{ value: "0", inputs: [{ name: "feature", type: "select", options: [["seqA", "seqA"]] }] },
{
value: "2",
inputs: [
{
name: "feature",
type: "select",
options: [
["mRNA", "mRNA"],
["exon", "exon"],
],
},
],
},
],
},
]);
const cond = formInputs.value[0];
const case0Feature = cond.cases[0].inputs[0];
const case2Feature = cond.cases[1].inputs[0];
// Each case must keep its OWN options, not inherit the last case's options
expect(case0Feature.attributes.options).toEqual([["seqA", "seqA"]]);
expect(case2Feature.attributes.options).toEqual([
["mRNA", "mRNA"],
["exon", "exon"],
]);
});
it("should update formIndex and formData on conditional switch", () => {
const { cloneInputs, formInputs, rebuildIndex, buildFormData, formIndex, formData } = useFormState();
cloneInputs(makeConditionalInputs());
@@ -122,28 +122,63 @@ export function useFormState(options: UseFormStateOptions = {}): UseFormStateRet
* Only copies server-owned fields; value, error, and warning are excluded
* to maintain the separation between server state (attributes) and client
* state (the clone's own properties).
*
* Uses a structural recursive approach so that conditional cases with the
* same parameter name (e.g. "feature" in every `when` branch) are each
* synced from their corresponding server case rather than from the last
* case (which is what a flat-key dict would produce).
*/
function syncServerAttributes(newInputs: FormInputNode[]): void {
const newAttributes: Record<string, FormInputNode> = {};
visitAllInputs(newInputs, (input: FormInputNode, name: string) => {
newAttributes[name] = input;
});
visitAllInputs(formInputs.value, (input: FormInputNode, name: string) => {
const serverNode = newAttributes[name];
if (serverNode != undefined) {
const attrs: Record<string, unknown> = {};
const raw = serverNode as unknown as Record<string, unknown>;
for (const key in raw) {
if (!CLIENT_OWNED_FIELDS.has(key)) {
attrs[key] = raw[key];
syncInputsStructural(formInputs.value, newInputs);
}
function syncInputsStructural(cloneInputs: FormInputNode[], serverInputs: FormInputNode[]): void {
const serverByName = new Map(serverInputs.map((n) => [String(n.name ?? ""), n]));
cloneInputs.forEach((cloneNode) => {
const serverNode = serverByName.get(String(cloneNode.name ?? ""));
if (!serverNode) {
return;
}
if (cloneNode.type === "conditional" && cloneNode.cases && serverNode.cases) {
syncNodeAttributes(cloneNode.test_param, serverNode.test_param);
cloneNode.cases.forEach((cloneCase, i) => {
const serverCase = serverNode.cases![i];
if (serverCase) {
syncInputsStructural(cloneCase.inputs, serverCase.inputs);
}
});
} else if (cloneNode.type === "section" && cloneNode.inputs && serverNode.inputs) {
syncInputsStructural(cloneNode.inputs, serverNode.inputs);
} else if (cloneNode.type === "repeat") {
if (cloneNode.cache && serverNode.cache) {
cloneNode.cache.forEach((instance, i) => {
const serverInstance = serverNode.cache![i];
if (serverInstance) {
syncInputsStructural(instance, serverInstance);
}
});
}
// set() required: attributes is a genuinely new property on clone nodes.
set(input, "attributes", attrs);
} else {
syncNodeAttributes(cloneNode, serverNode);
}
});
}
function syncNodeAttributes(cloneNode: FormInputNode | undefined, serverNode: FormInputNode | undefined): void {
if (!cloneNode || !serverNode) {
return;
}
const attrs: Record<string, unknown> = {};
const raw = serverNode as unknown as Record<string, unknown>;
for (const key in raw) {
if (!CLIENT_OWNED_FIELDS.has(key)) {
attrs[key] = raw[key];
}
}
// set() required: attributes is a genuinely new property on clone nodes.
set(cloneNode, "attributes", attrs);
}
function applyErrors(errors: FormMessages | null): void {
resetErrors();
if (errors) {
+3 -2
View File
@@ -190,8 +190,9 @@ function _buildLevel(inputs, formData, prefix) {
const condResult = {};
if (node.test_param) {
const testKey = `${flatKey}|${node.test_param.name}`;
condResult[node.test_param.name] = _convertValue(node.test_param, formData[testKey]);
const selectedCase = matchCase(node, node.test_param.value);
const testValue = _convertValue(node.test_param, formData[testKey]);
condResult[node.test_param.name] = testValue;
const selectedCase = matchCase(node, testValue ?? node.test_param.value);
if (selectedCase !== -1) {
Object.assign(condResult, _buildLevel(node.cases[selectedCase].inputs, formData, flatKey));
}
@@ -0,0 +1,208 @@
import { getLocalVue } from "@tests/vitest/helpers";
import { mount, type Wrapper } from "@vue/test-utils";
import flushPromises from "flush-promises";
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ref } from "vue";
import { useChatStore } from "@/stores/chatStore";
import GalaxyAI from "./GalaxyAI.vue";
const { mockGet, mockPost, mockPut, ChatMessageCellStub, ChatInputStub } = vi.hoisted(() => ({
mockGet: vi.fn(),
mockPost: vi.fn(),
mockPut: vi.fn(),
// render functions because the test environment uses the runtime-only Vue build
ChatMessageCellStub: {
name: "ChatMessageCellStub",
props: ["message"],
render(this: { message: { content: string } }, h: (...args: unknown[]) => unknown) {
return h("div", { class: "chat-message-stub" }, [this.message.content]);
},
},
ChatInputStub: {
name: "ChatInputStub",
props: ["value", "busy"],
render(h: (...args: unknown[]) => unknown) {
return h("input", { class: "chat-input-stub" });
},
},
}));
vi.mock("@/api", () => ({
GalaxyApi: () => ({ GET: mockGet, POST: mockPost, PUT: mockPut, DELETE: vi.fn() }),
}));
vi.mock("@/api/client", () => ({
GalaxyApi: () => ({ GET: mockGet, POST: mockPost, PUT: mockPut, DELETE: vi.fn() }),
}));
vi.mock("vue-router/composables", () => ({
useRoute: () => ({ path: "/", params: {}, query: {} }),
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}));
vi.mock("@/app", () => ({
getGalaxyInstance: () => ({ frame: { add: vi.fn() } }),
}));
// Child components are referenced directly from setup scope, so the test-utils
// `stubs` option cannot replace them — mock the modules instead.
vi.mock("@/components/GalaxyAI/ChatMessageCell.vue", () => ({ default: ChatMessageCellStub }));
vi.mock("@/components/GalaxyAI/ChatInput.vue", () => ({ default: ChatInputStub }));
vi.mock("@/composables/useActiveContext", () => ({
useActiveContext: () => ({ activeContext: ref(null), contextLabel: ref("") }),
}));
vi.mock("@/composables/agentActions", () => ({
useAgentActions: () => ({ processingAction: ref(false), handleAction: vi.fn() }),
}));
vi.mock("@/composables/confirmDialog", () => ({
useConfirmDialog: () => ({ confirm: vi.fn() }),
}));
vi.mock("@/composables/markdown", () => ({
useMarkdown: () => ({ renderMarkdown: (content: string) => content }),
}));
vi.mock("@/composables/toast", () => ({
useToast: () => ({ error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn() }),
}));
vi.mock("@/composables/useEntityMentions", () => ({
MENTION_PATTERN_SOURCE: "@(dataset|history):(\\S+)",
parseMentions: () => [],
resolveMentions: () => [],
buildEntityContext: () => null,
}));
vi.mock("@/composables/userLocalStorage", () => ({
useUserLocalStorage: vi.fn((_key: string, initialValue: unknown) => ref(initialValue)),
}));
const localVue = getLocalVue();
// jsdom does not implement Element.scrollTo
window.HTMLElement.prototype.scrollTo = vi.fn();
function mountChat() {
const pinia = createPinia();
setActivePinia(pinia);
const wrapper = mount(GalaxyAI as object, {
localVue,
pinia,
propsData: { panel: true },
stubs: { FontAwesomeIcon: true, BSkeleton: true },
});
const chatStore = useChatStore();
return { wrapper, chatStore };
}
function messageTexts(wrapper: Wrapper<Vue>) {
return wrapper.findAll(".chat-message-stub").wrappers.map((w) => w.text());
}
async function sendMessage(wrapper: Wrapper<Vue>, text: string) {
const input = wrapper.findComponent(ChatInputStub);
input.vm.$emit("input", text);
await wrapper.vm.$nextTick();
input.vm.$emit("submit");
await flushPromises();
}
function deferredResponse() {
let resolve!: (value: unknown) => void;
const promise = new Promise((r) => {
resolve = r;
});
return { promise, resolve };
}
describe("GalaxyAI", () => {
beforeEach(() => {
vi.clearAllMocks();
mockGet.mockResolvedValue({ data: [], error: undefined });
});
it("appends the response and records the exchange id on a normal exchange", async () => {
mockPost.mockResolvedValue({
data: { response: "Here you go", exchange_id: "exchange-123" },
error: undefined,
});
const { wrapper, chatStore } = mountChat();
await flushPromises();
await sendMessage(wrapper, "find me a mapper");
const texts = messageTexts(wrapper);
expect(texts).toHaveLength(3);
expect(texts[1]).toBe("find me a mapper");
expect(texts[2]).toBe("Here you go");
expect(chatStore.activeChatId).toBe("exchange-123");
});
it("resets an unsaved conversation when a new chat is requested mid-flight", async () => {
const deferred = deferredResponse();
mockPost.mockReturnValue(deferred.promise);
const { wrapper, chatStore } = mountChat();
await flushPromises();
await sendMessage(wrapper, "what mappers are available?");
expect(messageTexts(wrapper)).toHaveLength(2);
expect(wrapper.find(".loading-entry").exists()).toBe(true);
// the conversation is unsaved — no exchange id yet, so the identity
// (activeChatId) would not change and a plain showChat(null) is a no-op
expect(chatStore.activeChatId).toBeNull();
chatStore.requestNewChat();
await flushPromises();
const texts = messageTexts(wrapper);
expect(texts).toHaveLength(1);
expect(texts[0]).toContain("New conversation started");
expect(wrapper.find(".loading-entry").exists()).toBe(false);
// the stale response must not be appended or re-attach the old exchange
deferred.resolve({ data: { response: "Late answer", exchange_id: "exchange-123" }, error: undefined });
await flushPromises();
expect(messageTexts(wrapper)).toHaveLength(1);
expect(chatStore.activeChatId).toBeNull();
});
it("does not let a stale response clobber a newly started conversation", async () => {
const first = deferredResponse();
const second = deferredResponse();
mockPost.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
const { wrapper, chatStore } = mountChat();
await flushPromises();
await sendMessage(wrapper, "first question");
chatStore.requestNewChat();
await flushPromises();
await sendMessage(wrapper, "second question");
expect(wrapper.find(".loading-entry").exists()).toBe(true);
first.resolve({ data: { response: "First answer", exchange_id: "exchange-1" }, error: undefined });
await flushPromises();
// still only welcome + second user message, still waiting on the second response
let texts = messageTexts(wrapper);
expect(texts).toHaveLength(2);
expect(texts[1]).toBe("second question");
expect(wrapper.find(".loading-entry").exists()).toBe(true);
expect(chatStore.activeChatId).toBeNull();
second.resolve({ data: { response: "Second answer", exchange_id: "exchange-2" }, error: undefined });
await flushPromises();
texts = messageTexts(wrapper);
expect(texts).toHaveLength(3);
expect(texts[2]).toBe("Second answer");
expect(wrapper.find(".loading-entry").exists()).toBe(false);
expect(chatStore.activeChatId).toBe("exchange-2");
});
});
+294
View File
@@ -0,0 +1,294 @@
import { createTestingPinia } from "@pinia/testing";
import { getLocalVue } from "@tests/vitest/helpers";
import { shallowMount } from "@vue/test-utils";
import flushPromises from "flush-promises";
import { setActivePinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ref } from "vue";
import { useServerMock } from "@/api/client/__mocks__";
import type { ActiveContext } from "@/composables/useActiveContext";
import { useChatStore } from "@/stores/chatStore";
import { usePageEditorStore } from "@/stores/pageEditorStore";
import GalaxyAI from "./GalaxyAI.vue";
// API mock
const { server, http } = useServerMock();
const mockGetMessages = vi.fn();
const mockGetHistory = vi.fn();
// Composable mocks
const mockActiveContext = ref<ActiveContext | null>(null);
vi.mock("@/composables/useActiveContext", () => ({
useActiveContext: () => ({ activeContext: mockActiveContext, contextLabel: ref("") }),
}));
vi.mock("@/composables/userLocalStorage", () => ({
useUserLocalStorage: (_key: string, initial: unknown) => ref(initial),
}));
vi.mock("@/composables/markdown", () => ({
useMarkdown: () => ({ renderMarkdown: (s: string) => s }),
}));
vi.mock("@/composables/agentActions", () => ({
useAgentActions: () => ({ processingAction: ref(false), handleAction: vi.fn() }),
}));
vi.mock("@/composables/confirmDialog", () => ({
useConfirmDialog: () => ({ confirm: vi.fn() }),
}));
vi.mock("@/composables/toast", () => ({
useToast: () => ({ error: vi.fn(), success: vi.fn() }),
}));
vi.mock("@/composables/useEntityMentions", () => ({
parseMentions: (s: string) => s,
resolveMentions: (s: string) => s,
buildEntityContext: () => null,
}));
vi.mock("@/composables/usePageProposals", () => ({
usePageProposals: () => ({
pageContent: ref(""),
loadForPage: vi.fn(),
clear: vi.fn(),
getEditProposal: vi.fn(),
isProposalStale: vi.fn(),
isProposalVisible: vi.fn(() => false),
buildProposedContent: vi.fn(),
applyFullReplacement: vi.fn(),
applySectionPatched: vi.fn(),
dismissProposal: vi.fn(),
}),
}));
vi.mock("vue-router/composables", () => ({
useRoute: () => ({ path: "/", params: {}, query: {} }),
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}));
const localVue = getLocalVue();
let lastWrapper: ReturnType<typeof shallowMount> | null = null;
// Constants
const EXCHANGE_ID = "exchange-abc";
const MESSAGES_RESPONSE = [
{ role: "user", content: "Hello", timestamp: null },
{ role: "assistant", content: "Hi", timestamp: null, agent_type: "router", agent_response: null, feedback: null },
];
function makeSuccessfulFetch(messages = MESSAGES_RESPONSE) {
server.use(
http.get("/api/chat/exchange/{exchange_id}/messages", ({ response }) => {
mockGetMessages();
return response(200).json(messages);
}),
);
}
function makeEmptyFetch() {
server.use(
http.get("/api/chat/exchange/{exchange_id}/messages", ({ response }) => {
mockGetMessages();
return response(200).json([]);
}),
);
}
function makeHistoryFetch(items: { id: string }[] = [{ id: "latest-chat" }]) {
server.use(
http.get("/api/chat/history", ({ response }) => {
mockGetHistory();
return response(200).json(items as any);
}),
http.get("/api/chat/page/{page_id}/history", ({ response }) => {
mockGetHistory();
return response(200).json(items as any);
}),
);
}
interface MountOptions {
props?: Record<string, unknown>;
activeChatId?: string | null;
cachedExchangeId?: string | null;
pageId?: string;
chatHistoryItems?: { id: string }[];
}
function mountGalaxyAI({
props = {},
activeChatId = null,
cachedExchangeId = null,
pageId = "page-1",
chatHistoryItems,
}: MountOptions = {}) {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false });
setActivePinia(pinia);
const chatStore = useChatStore();
chatStore.activeChatId = activeChatId as any;
if (chatHistoryItems !== undefined) {
chatStore.chatHistory = chatHistoryItems as any;
vi.spyOn(chatStore, "loadHistory").mockResolvedValue(undefined);
}
const pageEditorStore = usePageEditorStore();
if (cachedExchangeId) {
pageEditorStore.setCurrentChatExchangeId(pageId, cachedExchangeId);
}
const wrapper = shallowMount(GalaxyAI as object, {
localVue,
pinia,
propsData: props,
stubs: {
FontAwesomeIcon: true,
ChatActions: true,
ChatInput: true,
ChatMessageCell: true,
ProposalDiffView: true,
SectionPatchView: true,
Heading: true,
BSkeleton: true,
},
});
lastWrapper = wrapper;
return { wrapper, chatStore, pageEditorStore };
}
describe("GalaxyAI fetch operations on mount", () => {
beforeEach(() => {
mockActiveContext.value = null;
mockGetMessages.mockClear();
mockGetHistory.mockClear();
makeHistoryFetch([]);
});
afterEach(() => {
lastWrapper?.destroy();
lastWrapper = null;
vi.restoreAllMocks();
});
describe("when exchangeId prop is provided and not 'new'", () => {
it("fetches the specified exchange on mount", async () => {
makeSuccessfulFetch();
mountGalaxyAI({ props: { exchangeId: EXCHANGE_ID } });
await flushPromises();
expect(mockGetMessages).toHaveBeenCalledOnce();
});
it("does not call loadLatestChat", async () => {
makeSuccessfulFetch();
mountGalaxyAI({ props: { exchangeId: EXCHANGE_ID }, chatHistoryItems: [{ id: EXCHANGE_ID }] });
await flushPromises();
expect(mockGetHistory).not.toHaveBeenCalled();
});
});
describe("when exchangeId prop is 'new'", () => {
it("starts a new chat without fetching messages", async () => {
mountGalaxyAI({ props: { exchangeId: "new" } });
await flushPromises();
expect(mockGetMessages).not.toHaveBeenCalled();
});
it("does not restore from activeChatId even when one is set", async () => {
mountGalaxyAI({ props: { exchangeId: "new" }, activeChatId: EXCHANGE_ID });
await flushPromises();
expect(mockGetMessages).not.toHaveBeenCalled();
});
});
describe("docked/panel — notebook context with cached exchange ID", () => {
it("fetches the cached exchange when activeChatId is null", async () => {
makeSuccessfulFetch();
mockActiveContext.value = { contextType: "notebook", pageId: "page-1", historyId: "hist-1" };
mountGalaxyAI({ props: { docked: true }, activeChatId: null, cachedExchangeId: EXCHANGE_ID });
await flushPromises();
expect(mockGetMessages).toHaveBeenCalledOnce();
});
it("clears stale cached ID when fetch returns no messages", async () => {
makeEmptyFetch();
mockActiveContext.value = { contextType: "notebook", pageId: "page-1", historyId: "hist-1" };
const { pageEditorStore } = mountGalaxyAI({
props: { docked: true },
activeChatId: null,
cachedExchangeId: EXCHANGE_ID,
});
await flushPromises();
expect(pageEditorStore.getCurrentChatExchangeId("page-1")).toBeNull();
});
});
describe("docked/panel — notebook context, no cached ID but page has history", () => {
it("fetches the most recent chat from page history (ignores global activeChatId)", async () => {
makeSuccessfulFetch();
mockActiveContext.value = { contextType: "notebook", pageId: "page-1", historyId: "hist-1" };
mountGalaxyAI({
props: { panel: true },
activeChatId: "some-other-chat",
chatHistoryItems: [{ id: EXCHANGE_ID }],
});
await flushPromises();
expect(mockGetMessages).toHaveBeenCalledOnce();
});
});
describe("docked/panel — notebook context, no cached ID and empty history", () => {
it("starts a new chat without fetching", async () => {
mockActiveContext.value = { contextType: "notebook", pageId: "page-1", historyId: "hist-1" };
mountGalaxyAI({ props: { docked: true }, activeChatId: null });
await flushPromises();
expect(mockGetMessages).not.toHaveBeenCalled();
});
});
describe("docked/panel — non-notebook context with activeChatId", () => {
it("fetches the active chat exchange", async () => {
makeSuccessfulFetch();
mountGalaxyAI({ props: { panel: true }, activeChatId: EXCHANGE_ID });
await flushPromises();
expect(mockGetMessages).toHaveBeenCalledOnce();
});
});
describe("docked/panel — non-notebook context, no activeChatId", () => {
it("starts a new chat without fetching", async () => {
mountGalaxyAI({ props: { docked: true }, activeChatId: null });
await flushPromises();
expect(mockGetMessages).not.toHaveBeenCalled();
});
});
describe("center mode (no exchangeId, not docked/panel)", () => {
it("calls loadLatestChat to fetch most recent history", async () => {
mountGalaxyAI({});
await flushPromises();
expect(mockGetHistory).toHaveBeenCalledOnce();
});
it("does not fetch exchange messages when history is empty", async () => {
mountGalaxyAI({});
await flushPromises();
expect(mockGetMessages).not.toHaveBeenCalled();
});
it("fetches the latest exchange when history has items", async () => {
makeHistoryFetch([{ id: "latest-chat" }]);
makeSuccessfulFetch();
mountGalaxyAI({});
await flushPromises();
expect(mockGetMessages).toHaveBeenCalledOnce();
});
});
});
+182 -37
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { faFile, faMagic, faSitemap, faTimes, faTrash, faWrench } from "@fortawesome/free-solid-svg-icons";
import { faMagic, faTimes, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BSkeleton } from "bootstrap-vue";
import { computed, nextTick, onMounted, ref, watch } from "vue";
@@ -12,7 +12,9 @@ import { useMarkdown } from "@/composables/markdown";
import { useToast } from "@/composables/toast";
import { useActiveContext } from "@/composables/useActiveContext";
import { buildEntityContext, parseMentions, resolveMentions } from "@/composables/useEntityMentions";
import { usePageProposals } from "@/composables/usePageProposals";
import { useChatStore } from "@/stores/chatStore";
import { usePageEditorStore } from "@/stores/pageEditorStore";
import { errorMessageAsString } from "@/utils/simple-error";
import { getAgentIcon } from "./GalaxyAI/agentTypes";
@@ -22,6 +24,8 @@ import { generateId, scrollToBottom } from "./GalaxyAI/chatUtils";
import ChatActions from "./GalaxyAI/ChatActions.vue";
import ChatInput from "./GalaxyAI/ChatInput.vue";
import ChatMessageCell from "./GalaxyAI/ChatMessageCell.vue";
import ProposalDiffView from "./PageEditor/ProposalDiffView.vue";
import SectionPatchView from "./PageEditor/SectionPatchView.vue";
import Heading from "@/components/Common/Heading.vue";
const props = withDefaults(
@@ -45,11 +49,49 @@ const router = useRouter();
const chatStore = useChatStore();
const Toast = useToast();
const { activeContext, contextLabel } = useActiveContext();
const { activeContext, contextLabel, contextIcon } = useActiveContext();
const pageEditorStore = usePageEditorStore();
const contextDismissed = ref(false);
watch(activeContext, () => {
watch(activeContext, async (newCtx, oldCtx) => {
contextDismissed.value = false;
if (!props.docked && !props.panel) {
return;
}
const switchingToNotebook =
newCtx?.contextType === "notebook" && (oldCtx?.contextType !== "notebook" || oldCtx.pageId !== newCtx.pageId);
if (switchingToNotebook) {
// Switched to a notebook page (or to a different notebook page): restore this page's
// cached exchange, or fall back to the most recent chat in this page's history.
const pageId = newCtx.pageId;
const cachedId = pageEditorStore.getCurrentChatExchangeId(pageId);
if (cachedId) {
await fetchConversation(cachedId);
if (messages.value.length === 0) {
pageEditorStore.clearCurrentChatExchangeId(pageId);
startNewChat();
}
} else {
try {
await chatStore.loadHistory(pageId);
} catch (e) {
Toast.error(errorMessageAsString(e), "Failed to load chat history");
}
const latestChat = chatStore.chatHistory[0];
if (latestChat) {
await fetchConversation(latestChat.id);
} else {
startNewChat();
}
}
} else if (oldCtx?.contextType === "notebook" && newCtx?.contextType !== "notebook") {
// Switched away from notebook: drop notebook-specific state and start fresh.
clearProposals();
startNewChat();
}
});
const effectiveContext = computed(() => {
@@ -59,20 +101,6 @@ const effectiveContext = computed(() => {
return activeContext.value;
});
const contextIcon = computed(() => {
switch (effectiveContext.value?.contextType) {
case "tool":
return faWrench;
case "dataset":
return faFile;
case "workflow_editor":
case "workflow_run":
return faSitemap;
default:
return faMagic;
}
});
/** The chat is in "route mode": It's being viewed in the center and the route starts with `/galaxyai`
* _(and we are not in the window manager)_. */
const isRouteMode = computed(() => chatStore.isCenterMode && !props.compact && route.path.startsWith("/galaxyai"));
@@ -85,14 +113,69 @@ const selectedAgentType = ref("auto");
const currentChatId = ref<string | null>(null);
const hasLoadedInitialChat = ref(false);
// Bumped whenever the displayed conversation changes so that responses still in
// flight for a previous conversation can be recognized as stale and dropped.
let conversationGeneration = 0;
const { renderMarkdown } = useMarkdown({ openLinksInNewPage: true, removeNewlinesAfterList: true });
const { processingAction, handleAction } = useAgentActions();
// Proposal rendering (notebook / page_assistant context)
const {
pageContent,
loadForPage: loadProposalsForPage,
clear: clearProposals,
getEditProposal,
isProposalStale,
isProposalVisible,
buildProposedContent,
applyFullReplacement,
applySectionPatched,
dismissProposal,
} = usePageProposals(activeContext);
onMounted(async () => {
if (props.exchangeId && props.exchangeId !== "new") {
await fetchConversation(props.exchangeId);
} else if (props.docked || props.panel || props.exchangeId === "new") {
} else if (props.exchangeId === "new") {
startNewChat();
} else if (props.docked || props.panel) {
const ctx = activeContext.value;
// For notebook pages, always prefer the per-page cached exchange over the global
// activeChatId — the global one may belong to a completely unrelated normal chat.
const notebookPageId = ctx?.contextType === "notebook" ? ctx.pageId : null;
if (notebookPageId) {
// Notebook context: prefer per-page cached exchange, fall back to page history.
const cachedId = pageEditorStore.getCurrentChatExchangeId(notebookPageId);
if (cachedId) {
await fetchConversation(cachedId);
if (messages.value.length === 0) {
pageEditorStore.clearCurrentChatExchangeId(notebookPageId);
startNewChat();
}
} else {
try {
await chatStore.loadHistory(notebookPageId);
} catch (e) {
Toast.error(errorMessageAsString(e), "Failed to load chat history");
}
const latestChat = chatStore.chatHistory[0];
if (latestChat) {
await fetchConversation(latestChat.id);
} else {
startNewChat();
}
}
} else {
// Non-notebook context: use global activeChatId if available.
const chatId = chatStore.activeChatId;
if (chatId) {
await fetchConversation(chatId);
} else {
startNewChat();
}
}
} else {
await loadLatestChat();
}
@@ -105,7 +188,7 @@ onMounted(async () => {
watch(
() => props.exchangeId,
async (newId, oldId) => {
if (newId === oldId) {
if (newId === oldId || newId === currentChatId.value) {
return;
}
if (newId && newId !== "new") {
@@ -116,6 +199,14 @@ watch(
},
);
// A "new chat" request must work even when the conversation identity wouldn't
// change (an unsaved conversation has no exchange id), so it arrives as a counter
// bump rather than through the exchangeId prop.
watch(
() => chatStore.newChatRequestCount,
() => startNewChat(),
);
function showWelcome() {
messages.value.push({
id: generateId(),
@@ -152,6 +243,10 @@ async function submitQuery() {
scrollToBottom(chatContainer.value);
busy.value = true;
const generation = conversationGeneration;
// False once the conversation changes while we're waiting (e.g. the user
// started a new chat) — stale responses must not touch the current one.
const stillCurrent = () => generation === conversationGeneration;
try {
const parsed = parseMentions(currentQuery);
@@ -172,6 +267,10 @@ async function submitQuery() {
},
});
if (!stillCurrent()) {
return;
}
if (error) {
const errorText = errorMessageAsString(error, "Failed to get response from GalaxyAI.");
const errorMsg: ChatMessage = {
@@ -215,23 +314,29 @@ async function submitQuery() {
}
} catch (e) {
console.error("Unexpected chat error:", e);
const errorMsg: ChatMessage = {
id: generateId(),
role: "assistant",
content: "Unexpected error occurred. Please try again.",
timestamp: new Date(),
agentType: selectedAgentType.value,
confidence: "low",
feedback: null,
};
messages.value.push(errorMsg);
if (stillCurrent()) {
const errorMsg: ChatMessage = {
id: generateId(),
role: "assistant",
content: "Unexpected error occurred. Please try again.",
timestamp: new Date(),
agentType: selectedAgentType.value,
confidence: "low",
feedback: null,
};
messages.value.push(errorMsg);
await nextTick();
scrollToBottom(chatContainer.value);
await nextTick();
scrollToBottom(chatContainer.value);
}
} finally {
busy.value = false;
await nextTick();
scrollToBottom(chatContainer.value);
// Only clear the busy indicator if it still belongs to this request — the
// current conversation may have its own request in flight by now.
if (stillCurrent()) {
busy.value = false;
await nextTick();
scrollToBottom(chatContainer.value);
}
}
}
@@ -276,12 +381,20 @@ async function fetchConversation(exchangeId: string) {
return;
}
const generation = ++conversationGeneration;
const { data: fullConversation, error } = await GalaxyApi().GET(`/api/chat/exchange/{exchange_id}/messages`, {
params: {
path: { exchange_id: exchangeId },
},
});
if (generation !== conversationGeneration) {
// A newer conversation was loaded (or a new chat started) while this one
// was being fetched — don't overwrite it.
return;
}
if (error) {
Toast.error(errorMessageAsString(error, "Failed to load conversation."), "Error loading conversation");
return;
@@ -300,7 +413,7 @@ async function fetchConversation(exchangeId: string) {
};
if (msg.role === "assistant") {
message.agentType = msg.agent_type;
message.agentType = msg.agent_response?.agent_type || msg.agent_type;
message.confidence = msg.agent_response?.confidence || "medium";
message.feedback = msg.feedback === 1 ? "up" : msg.feedback === 0 ? "down" : null;
@@ -316,6 +429,11 @@ async function fetchConversation(exchangeId: string) {
currentChatId.value = exchangeId;
nextTick(() => scrollToBottom(chatContainer.value));
const ctx = activeContext.value;
if (ctx?.contextType === "notebook") {
loadProposalsForPage(ctx.pageId);
}
hasLoadedInitialChat.value = true;
}
@@ -335,7 +453,9 @@ async function loadLatestChat() {
}
function startNewChat() {
conversationGeneration++;
hasLoadedInitialChat.value = true;
busy.value = false;
messages.value = [
{
id: generateId(),
@@ -350,6 +470,7 @@ function startNewChat() {
];
currentChatId.value = null;
query.value = "";
clearProposals();
if (props.docked || props.panel) {
chatStore.setActiveChatId(null);
}
@@ -389,11 +510,18 @@ function dockTo(location: "right" | "bottom") {
watch(currentChatId, async (newId) => {
if (props.docked || props.panel) {
chatStore.setActiveChatId(newId);
// Keep the per-page cache in sync so reopening the panel restores this exchange.
const ctx = activeContext.value;
if (ctx?.contextType === "notebook") {
pageEditorStore.setCurrentChatExchangeId(ctx.pageId, newId);
}
}
if (newId && !chatStore.chatHistory.some((item) => item.id === newId)) {
const pageId = activeContext.value?.contextType === "notebook" ? activeContext.value.pageId : undefined;
try {
await chatStore.loadHistory();
await chatStore.loadHistory(pageId);
} catch (e) {
Toast.error(errorMessageAsString(e), "Failed to load chat history");
}
@@ -446,7 +574,24 @@ watch(currentChatId, async (newId) => {
:processing-action="processingAction"
@feedback="sendFeedback"
@handle-action="handleAction"
@select-clarification-option="selectClarificationOption" />
@select-clarification-option="selectClarificationOption">
<template v-if="isProposalVisible(message)" v-slot:after-content>
<ProposalDiffView
v-if="getEditProposal(message)?.mode === 'full_replacement'"
:original="pageContent"
:proposed="buildProposedContent(message)"
:stale="isProposalStale(message)"
@accept="applyFullReplacement(message)"
@reject="dismissProposal(message)" />
<SectionPatchView
v-else-if="getEditProposal(message)?.mode === 'section_patch'"
:original="pageContent"
:proposed="buildProposedContent(message)"
:stale="isProposalStale(message)"
@accept="applySectionPatched($event, message)"
@reject="dismissProposal(message)" />
</template>
</ChatMessageCell>
<!-- Loading state -->
<div v-if="busy" class="loading-entry">
@@ -70,17 +70,31 @@ describe("ChatActions", () => {
expect(mockPush).toHaveBeenCalledWith("/galaxyai/new");
});
it("calls chatStore.showChat(null) in docked mode", async () => {
const { wrapper, store } = mountComponent("docked");
it("requests a new chat in all modes", async () => {
for (const source of ["center", "docked", "panel"] as const) {
const { wrapper, store } = mountComponent(source);
await findButton(wrapper, "Start New Chat").trigger("click");
expect(store.requestNewChat).toHaveBeenCalled();
}
});
it("still requests a new chat when already at /galaxyai/new, without re-routing", async () => {
mockRoute = { path: "/galaxyai/new", params: { exchangeId: "new" } };
const { wrapper, store } = mountComponent("center");
await findButton(wrapper, "Start New Chat").trigger("click");
expect(mockPush).not.toHaveBeenCalled();
expect(store.requestNewChat).toHaveBeenCalled();
});
it("does not route in docked mode", async () => {
const { wrapper } = mountComponent("docked");
await findButton(wrapper, "Start New Chat").trigger("click");
expect(store.showChat).toHaveBeenCalledWith(null);
expect(mockPush).not.toHaveBeenCalled();
});
it("calls chatStore.showChat(null) in panel mode", async () => {
const { wrapper, store } = mountComponent("panel");
it("does not route in panel mode", async () => {
const { wrapper } = mountComponent("panel");
await findButton(wrapper, "Start New Chat").trigger("click");
expect(store.showChat).toHaveBeenCalledWith(null);
expect(mockPush).not.toHaveBeenCalled();
});
+20 -13
View File
@@ -19,6 +19,8 @@ import { getGalaxyInstance } from "@/app";
import { useActivityStore } from "@/stores/activityStore.js";
import { useChatStore } from "@/stores/chatStore";
import { useStartNewChat } from "./useStartNewChat";
import GButton from "../BaseComponents/GButton.vue";
const props = defineProps<{
@@ -37,15 +39,12 @@ const route = useRoute();
const router = useRouter();
const chatStore = useChatStore();
const activityStore = useActivityStore("default");
const startNewChat = useStartNewChat();
const showingActivityPanel = computed(() => activityStore.toggledSideBar === "galaxyai");
function startNew() {
if (props.source === "center") {
router.push("/galaxyai/new");
} else {
chatStore.showChat(null);
}
startNewChat(props.source === "center");
emit("update:collapsed", false);
}
@@ -117,14 +116,22 @@ function onDockTo(location: "right" | "bottom") {
@click="maximize">
<FontAwesomeIcon :icon="faExpand" fixed-width />
</GButton>
<template v-if="props.source === 'center'">
<GButton size="small" transparent title="Dock to side panel" @click="onDockTo('right')">
<FontAwesomeIcon :icon="faColumns" fixed-width />
</GButton>
<GButton size="small" transparent title="Dock to bottom panel" @click="onDockTo('bottom')">
<FontAwesomeIcon :icon="faAngleDoubleDown" fixed-width />
</GButton>
</template>
<GButton
v-if="props.source !== 'docked'"
size="small"
transparent
title="Dock to side panel"
@click="onDockTo('right')">
<FontAwesomeIcon :icon="faColumns" fixed-width />
</GButton>
<GButton
v-if="props.source !== 'panel'"
size="small"
transparent
title="Dock to bottom panel"
@click="onDockTo('bottom')">
<FontAwesomeIcon :icon="faAngleDoubleDown" fixed-width />
</GButton>
<GButton size="small" transparent title="Open in floating window" @click="popOut">
<FontAwesomeIcon :icon="faExternalLinkAlt" fixed-width />
</GButton>
@@ -3,17 +3,19 @@ import { faCheckSquare, faSquare } from "@fortawesome/free-regular-svg-icons";
import { faClock, faPlus, faTimes, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { storeToRefs } from "pinia";
import { computed, onMounted } from "vue";
import { computed, watch } from "vue";
import { useRoute, useRouter } from "vue-router/composables";
import { useConfirmDialog } from "@/composables/confirmDialog";
import { useToast } from "@/composables/toast";
import { useActiveContext } from "@/composables/useActiveContext";
import { useSidebarSelection } from "@/composables/useSidebarSelection";
import { useChatStore } from "@/stores/chatStore";
import { errorMessageAsString } from "@/utils/simple-error";
import { getAgentIcon } from "./agentTypes";
import type { ChatHistoryItem } from "./chatTypes";
import { useStartNewChat } from "./useStartNewChat";
import GButton from "../BaseComponents/GButton.vue";
import ChatModeSelector from "./ChatModeSelector.vue";
@@ -26,6 +28,14 @@ const Toast = useToast();
const route = useRoute();
const router = useRouter();
const chatStore = useChatStore();
const { activeContext } = useActiveContext();
const notebookPageId = computed(() => {
const ctx = activeContext.value;
return ctx?.contextType === "notebook" ? ctx.pageId : undefined;
});
const newChat = useStartNewChat();
const { chatHistory, loading } = storeToRefs(chatStore);
@@ -47,13 +57,17 @@ const currentExchangeId = computed(() => {
}
});
onMounted(async () => {
try {
await chatStore.loadHistory();
} catch (e) {
Toast.error(errorMessageAsString(e), "Failed to load chat history");
}
});
watch(
notebookPageId,
async (pageId) => {
try {
await chatStore.loadHistory(pageId);
} catch (e) {
Toast.error(errorMessageAsString(e), "Failed to load chat history");
}
},
{ immediate: true },
);
function handleItemClick(item: ChatHistoryItem, index: number, event: MouseEvent) {
if (handleSelectionClick(item, index, event)) {
@@ -67,11 +81,7 @@ function handleItemClick(item: ChatHistoryItem, index: number, event: MouseEvent
}
function startNewChat() {
if (chatStore.isCenterMode) {
router.push("/galaxyai/new");
} else {
chatStore.showChat(null);
}
newChat(chatStore.isCenterMode);
}
async function deleteSelected() {
@@ -1,6 +1,8 @@
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { mount } from "@vue/test-utils";
import { describe, expect, it, vi } from "vitest";
import GButton from "../BaseComponents/GButton.vue";
import ChatInput from "./ChatInput.vue";
vi.mock("@/stores/historyStore", () => ({
@@ -13,7 +15,6 @@ vi.mock("@/stores/historyItemsStore", () => ({
function mountInput(
props: Record<string, unknown> = {},
stubs: Record<string, boolean> = {
FontAwesomeIcon: true,
LoadingSpan: true,
MentionDropdown: true,
},
@@ -66,27 +67,27 @@ describe("ChatInput", () => {
it("disables send button when value is empty", () => {
const wrapper = mountInput({ value: "" });
expect((wrapper.find("button").element as HTMLButtonElement).disabled).toBe(true);
expect(wrapper.findComponent(GButton).props("disabled")).toBe(true);
});
it("disables send button when value is whitespace", () => {
const wrapper = mountInput({ value: " " });
expect((wrapper.find("button").element as HTMLButtonElement).disabled).toBe(true);
expect(wrapper.findComponent(GButton).props("disabled")).toBe(true);
});
it("enables send button when value has content", () => {
const wrapper = mountInput({ value: "hello" });
expect((wrapper.find("button").element as HTMLButtonElement).disabled).toBe(false);
expect(wrapper.findComponent(GButton).props("disabled")).toBe(false);
});
it("disables send button when busy even with content", () => {
const wrapper = mountInput({ value: "hello", busy: true });
expect((wrapper.find("button").element as HTMLButtonElement).disabled).toBe(true);
expect(wrapper.findComponent(GButton).props("disabled")).toBe(true);
});
it("disables send button when disabled prop is true even with content", () => {
const wrapper = mountInput({ value: "hello", disabled: true });
expect((wrapper.find("button").element as HTMLButtonElement).disabled).toBe(true);
expect(wrapper.findComponent(GButton).props("disabled")).toBe(true);
});
});
@@ -102,7 +103,7 @@ describe("ChatInput", () => {
it("emits submit on send button click", async () => {
const wrapper = mountInput({ value: "hello" });
await wrapper.find("button").trigger("click");
await wrapper.findComponent(GButton).trigger("click");
expect(wrapper.emitted("submit")).toBeTruthy();
});
@@ -149,15 +150,77 @@ describe("ChatInput", () => {
});
});
describe("busy state UI", () => {
it("shows loading indicator when busy", () => {
const wrapper = mountInput({ busy: true });
expect(wrapper.findComponent({ name: "LoadingSpan" }).exists()).toBe(true);
describe("auto-resize", () => {
function mockScrollHeight(el: HTMLTextAreaElement, value: number) {
Object.defineProperty(el, "scrollHeight", { configurable: true, value });
}
it("does not set height on mount when value is empty", () => {
const wrapper = mountInput({ value: "" });
const el = wrapper.find("textarea").element as HTMLTextAreaElement;
expect(el.style.height).toBe("");
});
it("hides loading indicator when not busy", () => {
it("sizes the textarea to its content on mount when value is non-empty", () => {
// onMounted resizes synchronously, so mock scrollHeight on the
// prototype before mounting and restore it afterwards.
const proto = window.HTMLTextAreaElement.prototype;
const original = Object.getOwnPropertyDescriptor(proto, "scrollHeight");
Object.defineProperty(proto, "scrollHeight", { configurable: true, get: () => 96 });
try {
const wrapper = mountInput({ value: "line one\nline two\nline three" });
const el = wrapper.find("textarea").element as HTMLTextAreaElement;
expect(el.style.height).toBe("96px");
} finally {
if (original) {
Object.defineProperty(proto, "scrollHeight", original);
} else {
delete (proto as { scrollHeight?: number }).scrollHeight;
}
}
});
it("grows when the value changes (typing / mention insert)", async () => {
const wrapper = mountInput({ value: "short" });
const el = wrapper.find("textarea").element as HTMLTextAreaElement;
mockScrollHeight(el, 120);
await wrapper.setProps({ value: "much\nlonger\ncontent\nhere" });
await wrapper.vm.$nextTick();
expect(el.style.height).toBe("120px");
});
it("resets height to '' when value is cleared (e.g. after submit)", async () => {
const wrapper = mountInput({ value: "a\nb\nc\nd\ne" });
const el = wrapper.find("textarea").element as HTMLTextAreaElement;
mockScrollHeight(el, 140);
await wrapper.setProps({ value: "a\nb\nc\nd\ne\nf" });
await wrapper.vm.$nextTick();
expect(el.style.height).toBe("140px");
await wrapper.setProps({ value: "" });
await wrapper.vm.$nextTick();
expect(el.style.height).toBe("");
});
});
describe("busy state UI", () => {
it("shows spinner icon when busy", () => {
const wrapper = mountInput({ busy: true });
const buttonIcon = wrapper.findComponent(GButton).findComponent(FontAwesomeIcon);
expect(buttonIcon.classes()).toContain("fa-spinner");
expect(buttonIcon.classes()).toContain("fa-spin");
expect(buttonIcon.classes()).not.toContain("fa-paper-plane");
});
it("shows send icon when not busy", () => {
const wrapper = mountInput({ busy: false });
expect(wrapper.findComponent({ name: "LoadingSpan" }).exists()).toBe(false);
const buttonIcon = wrapper.findComponent(GButton).findComponent(FontAwesomeIcon);
expect(buttonIcon.classes()).toContain("fa-paper-plane");
expect(buttonIcon.classes()).not.toContain("fa-spinner");
expect(buttonIcon.classes()).not.toContain("fa-spin");
});
});
});
+43 -12
View File
@@ -1,12 +1,12 @@
<script setup lang="ts">
import { faPaperPlane } from "@fortawesome/free-solid-svg-icons";
import { faPaperPlane, faSpinner } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { ref } from "vue";
import { nextTick, onMounted, ref, watch } from "vue";
import { detectMentionTrigger, type EntityType, type MentionTrigger } from "@/composables/useEntityMentions";
import GButton from "../BaseComponents/GButton.vue";
import MentionDropdown from "./MentionDropdown.vue";
import LoadingSpan from "@/components/LoadingSpan.vue";
const props = withDefaults(
defineProps<{
@@ -30,6 +30,37 @@ const textareaEl = ref<HTMLTextAreaElement | null>(null);
const dropdownRef = ref<InstanceType<typeof MentionDropdown> | null>(null);
const mentionTrigger = ref<MentionTrigger | null>(null);
function resize() {
const textarea = textareaEl.value;
if (!textarea) {
return;
}
if (!textarea.value) {
// No content — let CSS min-height govern; don't measure scrollHeight
// which would include the placeholder's wrapped height.
textarea.style.height = "";
return;
}
// Reset first so the element can shrink, then grow to fit content.
// CSS max-height caps growth and overflow-y:auto takes over past the cap.
textarea.style.height = "auto";
textarea.style.height = `${textarea.scrollHeight}px`;
}
// props.value is the single funnel for every value change -- user typing
// (round-trips through the parent), programmatic @-mention inserts, and the
// parent clearing the field after submit. nextTick lets the DOM update first.
watch(
() => props.value,
() => nextTick(resize),
);
onMounted(() => {
if (props.value) {
resize();
}
});
function onInput(event: Event) {
const textarea = event.target as HTMLTextAreaElement;
emit("input", textarea.value);
@@ -119,13 +150,14 @@ function closeMention() {
class="form-control chat-input"
@input="onInput"
@keydown="onKeydown" />
<button
<GButton
:disabled="busy || disabled || !props.value.trim()"
class="btn btn-primary send-button"
class="send-button"
color="blue"
size="large"
@click="emit('submit')">
<FontAwesomeIcon v-if="!busy" :icon="faPaperPlane" fixed-width />
<LoadingSpan v-else message="" />
</button>
<FontAwesomeIcon :icon="!busy ? faPaperPlane : faSpinner" fixed-width :spin="busy" />
</GButton>
<MentionDropdown
ref="dropdownRef"
@@ -144,7 +176,7 @@ function closeMention() {
.chat-input-container {
display: flex;
gap: 0.5rem;
align-items: flex-end;
align-items: flex-start;
position: relative;
.chat-input {
@@ -156,6 +188,7 @@ function closeMention() {
font-size: 0.9rem;
min-height: 2.5rem;
max-height: 8rem;
overflow-y: auto;
&:focus {
border-color: $brand-primary;
@@ -165,9 +198,7 @@ function closeMention() {
}
.send-button {
flex-shrink: 0;
border-radius: $border-radius-base;
padding: 0.5rem 0.875rem;
display: block;
}
}
@@ -13,7 +13,11 @@ import ChatModeSelector from "./ChatModeSelector.vue";
import GButton from "@/components/BaseComponents/GButton.vue";
const mockPush = vi.fn();
let mockRoute: { path: string; params: Record<string, string> } = { path: "/", params: {} };
let mockRoute: { path: string; params: Record<string, string>; query: Record<string, string> } = {
path: "/",
params: {},
query: {},
};
vi.mock("vue-router/composables", () => ({
useRouter: () => ({ push: mockPush }),
@@ -41,7 +45,7 @@ function getButtons(wrapper: Wrapper<Vue>) {
describe("ChatModeSelector", () => {
beforeEach(() => {
mockRoute = { path: "/", params: {} };
mockRoute = { path: "/", params: {}, query: {} };
vi.clearAllMocks();
});
@@ -53,7 +57,7 @@ describe("ChatModeSelector", () => {
describe("button active states", () => {
it("full view button is pressed when in center mode on /galaxyai route", async () => {
mockRoute = { path: "/galaxyai/chat-1", params: { exchangeId: "chat-1" } };
mockRoute = { path: "/galaxyai/chat-1", params: { exchangeId: "chat-1" }, query: {} };
const { wrapper, store } = mountComponent();
store.chatLocation = "center";
await nextTick();
@@ -100,6 +104,28 @@ describe("ChatModeSelector", () => {
});
});
// ── notebook context ──────────────────────────────────────────────────────
describe("notebook context", () => {
it("hides full view button when in notebook context", async () => {
mockRoute = {
path: "/histories/hist-1/pages/page-1",
params: { historyId: "hist-1", pageId: "page-1" },
query: {},
};
const { wrapper } = mountComponent();
await nextTick();
// Only 2 buttons should be rendered (side panel + bottom panel)
expect(getButtons(wrapper).length).toBe(2);
});
it("shows full view button when not in notebook context", async () => {
const { wrapper } = mountComponent();
await nextTick();
expect(getButtons(wrapper).length).toBe(3);
});
});
// ── openCenterChat ─────────────────────────────────────────────────────────
describe("openCenterChat", () => {
@@ -134,21 +160,21 @@ describe("ChatModeSelector", () => {
describe("openDockedChat — navigation", () => {
it("pushes '/' when on /galaxyai root route", async () => {
mockRoute = { path: "/galaxyai", params: {} };
mockRoute = { path: "/galaxyai", params: {}, query: {} };
const { wrapper } = mountComponent();
await getButtons(wrapper).at(1).trigger("click");
expect(mockPush).toHaveBeenCalledWith("/");
});
it("pushes '/' when on /galaxyai/:id route", async () => {
mockRoute = { path: "/galaxyai/chat-1", params: { exchangeId: "chat-1" } };
mockRoute = { path: "/galaxyai/chat-1", params: { exchangeId: "chat-1" }, query: {} };
const { wrapper } = mountComponent();
await getButtons(wrapper).at(1).trigger("click");
expect(mockPush).toHaveBeenCalledWith("/");
});
it("does not navigate when on an unrelated route", async () => {
mockRoute = { path: "/histories", params: {} };
mockRoute = { path: "/histories", params: {}, query: {} };
const { wrapper } = mountComponent();
await getButtons(wrapper).at(1).trigger("click");
expect(mockPush).not.toHaveBeenCalled();
@@ -173,7 +199,7 @@ describe("ChatModeSelector", () => {
});
it("uses routedChatId when coming from center mode with exchangeId in route", async () => {
mockRoute = { path: "/galaxyai/routed-chat", params: { exchangeId: "routed-chat" } };
mockRoute = { path: "/galaxyai/routed-chat", params: { exchangeId: "routed-chat" }, query: {} };
const { wrapper, store } = mountComponent();
store.chatLocation = "center";
await getButtons(wrapper).at(1).trigger("click");
@@ -181,7 +207,7 @@ describe("ChatModeSelector", () => {
});
it("uses activeChatId fallback when in center mode but no exchangeId in route", async () => {
mockRoute = { path: "/galaxyai", params: {} };
mockRoute = { path: "/galaxyai", params: {}, query: {} };
const { wrapper, store } = mountComponent();
store.chatLocation = "center";
store.activeChatId = "active-chat";
@@ -222,7 +248,7 @@ describe("ChatModeSelector", () => {
});
it("does NOT use routedChatId when not in center mode even with exchangeId in route", async () => {
mockRoute = { path: "/galaxyai/some-chat", params: { exchangeId: "some-chat" } };
mockRoute = { path: "/galaxyai/some-chat", params: { exchangeId: "some-chat" }, query: {} };
const { wrapper, store } = mountComponent();
store.chatLocation = "right";
store.activeChatId = "active-chat";
@@ -5,6 +5,7 @@ import { storeToRefs } from "pinia";
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router/composables";
import { useActiveContext } from "@/composables/useActiveContext";
import { useChatStore } from "@/stores/chatStore.js";
import GButton from "../BaseComponents/GButton.vue";
@@ -14,6 +15,9 @@ const route = useRoute();
const router = useRouter();
const chatStore = useChatStore();
const { activeContext } = useActiveContext();
const isNotebookContext = computed(() => activeContext.value?.contextType === "notebook");
const { activeChatId, isBottomPanelOpen, isCenterMode, isRightPanelOpen } = storeToRefs(chatStore);
@@ -45,7 +49,14 @@ function openDockedChat(location: "right" | "bottom") {
<template>
<GButtonGroup class="chat-mode-selector" size="small">
<GButton color="blue" outline :pressed="isOnCenter" title="Enable Full View" tooltip @click="openCenterChat">
<GButton
v-if="!isNotebookContext"
color="blue"
outline
:pressed="isOnCenter"
title="Enable Full View"
tooltip
@click="openCenterChat">
<FontAwesomeIcon :icon="faExpand" />
</GButton>
<GButton
+24 -2
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { storeToRefs } from "pinia";
import { ref } from "vue";
import { ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router/composables";
import { useChatStore } from "@/stores/chatStore";
@@ -10,14 +11,35 @@ import GalaxyAI from "@/components/GalaxyAI.vue";
const chatStore = useChatStore();
const { activeChatId } = storeToRefs(chatStore);
const route = useRoute();
const router = useRouter();
const collapsed = ref(false);
function dockTo(location: "right" | "bottom") {
chatStore.dockChat(location, activeChatId.value);
if (route.path.startsWith("/galaxyai")) {
router.push("/");
}
}
// Expand collapsed panel if a chat is selected from the sidebar
watch(
activeChatId,
(chatId) => {
if (chatId && collapsed.value) {
collapsed.value = false;
}
},
{ immediate: true },
);
</script>
<template>
<div class="chat-panel" :class="collapsed ? 'collapsed' : 'expanded'">
<div class="chat-panel-header">
<span class="chat-panel-title">GalaxyAI</span>
<ChatActions source="panel" :collapsed.sync="collapsed" />
<ChatActions source="panel" :collapsed.sync="collapsed" @dock-to="dockTo" />
</div>
<div v-show="!collapsed" class="chat-panel-body">
<GalaxyAI :exchange-id="activeChatId || undefined" panel />
+13 -2
View File
@@ -2,13 +2,16 @@ import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import {
faBook,
faBug,
faChartBar,
faFileContract,
faGraduationCap,
faHistory,
faMagic,
faPlus,
faQuestionCircle,
faRobot,
faRoute,
faSitemap,
faTools,
} from "@fortawesome/free-solid-svg-icons";
import { AGENT_LABELS } from "@/components/Page/constants";
@@ -26,7 +29,15 @@ export const agentTypes: AgentType[] = [
{ value: "router", label: "Router", icon: faRoute, description: "Query router" },
{ value: "error_analysis", label: "Error Analysis", icon: faBug, description: "Debug tool errors" },
{ value: "custom_tool", label: "Custom Tool", icon: faPlus, description: "Create custom tools" },
{ value: "dataset_analyzer", label: "Dataset Analyzer", icon: faChartBar, description: "Analyze datasets" },
{
value: "tool_recommendation",
label: "Tool Recommendation",
icon: faTools,
description: "Recommend tools & workflows",
},
{ value: "orchestrator", label: "Orchestrator", icon: faSitemap, description: "Coordinates multiple steps" },
{ value: "history", label: "History", icon: faHistory, description: "Summarize your analysis" },
{ value: "workflow_report", label: "Workflow Report", icon: faFileContract, description: "Workflow run reports" },
{ value: "gtn_training", label: "GTN Training", icon: faGraduationCap, description: "Find tutorials" },
{ value: "clarification", label: "Clarification", icon: faQuestionCircle, description: "Needs more info" },
{
@@ -0,0 +1,26 @@
import { useRoute, useRouter } from "vue-router/composables";
import { useChatStore } from "@/stores/chatStore";
/** Shared behavior for the various "New Chat" buttons. */
export function useStartNewChat() {
const route = useRoute();
const router = useRouter();
const chatStore = useChatStore();
/** @param center whether the fresh conversation should open in the center view */
return function startNewChat(center: boolean) {
if (center) {
if (route.path !== "/galaxyai/new") {
router.push("/galaxyai/new");
}
} else {
// make sure the docked/bottom panel is visible
chatStore.showChat();
}
// Reset via the store, not just the route/id above: for an unsaved
// conversation the identity wouldn't change, so a plain navigation or
// showChat(null) is a no-op.
chatStore.requestNewChat();
};
}
+4 -3
View File
@@ -470,9 +470,10 @@ watch(operationMessage, () => {
fieldText(fieldEntry, rowData)
}}</span>
</GButton>
<SwitchToHistoryLink
v-else-if="fieldEntry.type == 'history'"
:history-id="rowData[fieldEntry.key]" />
<template v-else-if="fieldEntry.type == 'history'">
<span v-if="!rowData[fieldEntry.key]" v-localize> No history found </span>
<SwitchToHistoryLink v-else :history-id="rowData[fieldEntry.key]" />
</template>
<HelpText
v-else-if="fieldEntry.type == 'helptext' && fieldEntry.converter"
:uri="fieldEntry.converter(rowData)"
+19 -2
View File
@@ -31,13 +31,18 @@ type PageEntry = Record<string, unknown>;
* Request and return data from server
*/
async function getData(offset: number, limit: number, search: string, sort_by: string, sort_desc: boolean) {
const typeFilteredSearch = search ? `type:standalone ${search}` : "type:standalone";
if (search.includes("is:standalone")) {
// Pages that are not attached to a history have their type set to "standalone" on the backend
// We convert the search term here before sending to the backend
search = search.replace("is:standalone", "type:standalone");
}
const { response, data, error } = await GalaxyApi().GET("/api/pages", {
params: {
query: {
limit,
offset,
search: typeFilteredSearch,
search: search,
sort_by: sort_by as SortKeyLiteral,
sort_desc,
show_published: false,
@@ -193,6 +198,11 @@ const fields: FieldArray = [
title: "Status",
type: "sharing",
},
{
key: "history_id",
title: "History",
type: "history",
},
];
/**
@@ -222,6 +232,13 @@ const validFilters: Record<string, ValidFilter<string | boolean | undefined>> =
handler: equals("deleted", "deleted", toBool),
menuItem: true,
},
standalone: {
placeholder: "Only standalone",
type: Boolean,
boolType: "is",
handler: equals("standalone", "type", toBool),
menuItem: true,
},
};
/**
@@ -3,7 +3,7 @@ import { useEventBus } from "@vueuse/core";
import { GalaxyApi } from "@/api";
import { GRID_LABELS } from "@/components/Page/constants";
import Filtering, { contains, type ValidFilter } from "@/utils/filtering";
import Filtering, { contains, equals, toBool, type ValidFilter } from "@/utils/filtering";
import { rethrowSimple } from "@/utils/simple-error";
import type { ActionArray, FieldArray, GridConfig } from "./types";
@@ -20,13 +20,18 @@ type PageEntry = Record<string, unknown>;
* Request and return data from server
*/
async function getData(offset: number, limit: number, search: string, sort_by: string, sort_desc: boolean) {
const typeFilteredSearch = search ? `type:standalone ${search}` : "type:standalone";
if (search.includes("is:standalone")) {
// Pages that are not attached to a history have their type set to "standalone" on the backend
// We convert the search term here before sending to the backend
search = search.replace("is:standalone", "type:standalone");
}
const { response, data, error } = await GalaxyApi().GET("/api/pages", {
params: {
query: {
limit,
offset,
search: typeFilteredSearch,
search: search,
sort_by: sort_by as SortKeyLiteral,
sort_desc,
show_own: false,
@@ -103,6 +108,13 @@ const validFilters: Record<string, ValidFilter<string | boolean | undefined>> =
title: { placeholder: "title", type: String, handler: contains("title"), menuItem: true },
slug: { handler: contains("slug"), menuItem: false },
user: { placeholder: "user", type: String, handler: contains("username"), menuItem: true },
standalone: {
placeholder: "Only standalone",
type: Boolean,
boolType: "is",
handler: equals("standalone", "type", toBool),
menuItem: true,
},
};
/**
@@ -26,6 +26,7 @@ const expectedOptions = [
"Extract Workflow",
"Show Invocations",
"Show History Graph",
"Show History Notebooks",
"Share & Manage Access",
];
@@ -3,6 +3,7 @@ import {
faArchive,
faBars,
faBezierCurve,
faBook,
faBurn,
faClone,
faColumns,
@@ -237,6 +238,14 @@ watch(
<span v-localize>Show History Graph</span>
</BDropdownItem>
<BDropdownItem
:disabled="isAnonymous || !canEditHistory"
:title="userTitle('View History Notebooks')"
:to="`/histories/${history.id}/pages`">
<FontAwesomeIcon fixed-width :icon="faBook" />
<span v-localize>Show History Notebooks</span>
</BDropdownItem>
<BDropdownDivider />
<BDropdownItem
@@ -24,6 +24,7 @@ const selectors = {
const mockSetCurrentHistory = vi.fn();
const mockApplyFilters = vi.fn();
const mockWindowOpen = vi.fn(() => null);
const mockGetHistoryLoadError = vi.fn(() => null as Error | null);
vi.mock("vue-router/composables", () => ({
useRouter: () => ({
@@ -42,6 +43,7 @@ vi.mock("@/stores/historyStore", async () => {
...(originalModule as any).useHistoryStore(),
currentHistoryId: "current-history-id",
setCurrentHistory: mockSetCurrentHistory,
getHistoryLoadError: mockGetHistoryLoadError,
applyFilters: vi.fn().mockImplementation((historyId: string) => {
// We mock what the actual method does: set the current history if not current
if (historyId !== "current-history-id") {
@@ -67,6 +69,7 @@ function initializeMocks() {
mockSetCurrentHistory.mockClear();
mockApplyFilters.mockClear();
mockWindowOpen.mockClear();
mockGetHistoryLoadError.mockReturnValue(null);
const windowSpy = vi.spyOn(window, "open");
windowSpy.mockImplementation(() => mockWindowOpen());
}
@@ -268,5 +271,24 @@ describe("SwitchToHistoryLink", () => {
await expectActionForHistory("View in new tab", history, true);
});
// if the history is inaccessible, the HistorySummary would never be fetched in the first place
it("shows an error badge when the history is inaccessible (backend error)", async () => {
initializeMocks();
// The history store is fully mocked, so mockGetHistoryLoadError is used to emulate a 403
mockGetHistoryLoadError.mockReturnValue(new Error("History is not accessible to the current user"));
const pinia = createTestingPinia({ createSpy: vi.fn });
const wrapper = mount(SwitchToHistoryLink as object, {
propsData: { historyId: "inaccessible-history-id" },
localVue,
pinia,
stubs: { FontAwesomeIcon: true },
});
await flushPromises();
expect(wrapper.find(selectors.historyLink).exists()).toBe(false);
expect(wrapper.html()).toContain("Error loading history");
expect(wrapper.html()).not.toContain("Loading");
});
});
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { faArchive, faBurn } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BBadge } from "bootstrap-vue";
import { computed } from "vue";
import { useRouter } from "vue-router/composables";
@@ -109,7 +110,14 @@ const linkClass = computed(() => {
<template>
<component :is="tag">
<LoadingSpan v-if="!history" />
<BBadge
v-if="historyStore.getHistoryLoadError(props.historyId)"
v-g-tooltip
:title="errorMessageAsString(historyStore.getHistoryLoadError(props.historyId))"
variant="danger">
Error loading history
</BBadge>
<LoadingSpan v-else-if="!history" />
<component :is="tag" v-else :class="linkClass" data-description="switch to history link">
<GLink
class="history-link-click"
@@ -1,7 +1,7 @@
<template>
<div class="d-flex h-100 w-100">
<FlexPanel v-if="!hideToolbox" side="left">
<MarkdownToolBox :steps="steps" @insert="insertMarkdown" />
<FlexPanel v-if="!hideToolbox" ref="markdownFlexPanel" side="left">
<MarkdownToolBox :steps="steps" @close-panel="onClosePanel" @insert="insertMarkdown" />
</FlexPanel>
<textarea
id="workflow-report-editor"
@@ -43,6 +43,7 @@ const emit = defineEmits<{
const content = ref<string>(props.markdownText);
const textArea = ref<HTMLTextAreaElement | null>(null);
const markdownFlexPanel = ref<InstanceType<typeof FlexPanel> | null>(null);
const FENCE = "```";
@@ -60,6 +61,12 @@ watch(
},
);
function onClosePanel() {
if (markdownFlexPanel.value) {
markdownFlexPanel.value.show = false;
}
}
function insertMarkdown(markdown: string) {
markdown = markdown.replace(")(", ", ");
markdown = `${FENCE}galaxy\n${markdown}\n${FENCE}\n`;
+21 -27
View File
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { faDownload, faEdit } from "@fortawesome/free-solid-svg-icons";
import { faDownload, faEdit, type IconDefinition } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BButton } from "bootstrap-vue";
import { computed, onMounted, ref, watch } from "vue";
import { parseMarkdown } from "./parse";
import type { MarkdownConfig } from "./types";
import GButton from "@/components/BaseComponents/GButton.vue";
import Heading from "@/components/Common/Heading.vue";
@@ -12,19 +12,6 @@ import LoadingSpan from "@/components/LoadingSpan.vue";
import SectionWrapper from "@/components/Markdown/Sections/SectionWrapper.vue";
import StsDownloadButton from "@/components/StsDownloadButton.vue";
// Props
interface MarkdownConfig {
content?: string;
errors?: Array<{ error?: string; line?: string }>;
generate_time?: string;
generate_version?: string;
id?: string;
markdown?: string;
model_class?: string;
title?: string;
update_time?: string;
}
const props = defineProps<{
markdownConfig: MarkdownConfig;
enable_beta_markdown_export?: boolean;
@@ -33,6 +20,8 @@ const props = defineProps<{
exportLink?: string;
showIdentifier?: boolean;
directDownloadLink?: boolean;
noHeading?: boolean;
editButtonConfig?: { tooltip?: string; icon?: IconDefinition; label: string; disabled?: boolean };
}>();
// Refs and data
@@ -95,10 +84,14 @@ onMounted(() => {
<div v-else class="d-flex flex-column">
<div class="d-flex flex-column sticky-top bg-white">
<div class="d-flex">
<Heading v-localize h1 separator inline size="md" class="flex-grow-1">
{{ pageTitle }}
<Heading v-if="!props.noHeading" h1 separator inline size="md" class="flex-grow-1">
<slot name="heading">
{{ pageTitle }}
</slot>
</Heading>
<div>
<slot name="extra-actions" />
<template v-if="effectiveExportLink">
<GButton
v-if="directDownloadLink"
@@ -123,18 +116,19 @@ onMounted(() => {
outline />
</template>
<BButton
<GButton
v-if="!readOnly"
v-g-tooltip.hover
tooltip
class="markdown-edit"
role="button"
size="sm"
title="Edit Markdown"
variant="outline-primary"
size="small"
:title="editButtonConfig?.tooltip || 'Edit Markdown'"
:disabled="editButtonConfig?.disabled"
outline
color="blue"
@click="$emit('onEdit')">
Edit
<FontAwesomeIcon :icon="faEdit" />
</BButton>
{{ editButtonConfig?.label || "Edit" }}
<FontAwesomeIcon :icon="editButtonConfig?.icon || faEdit" />
</GButton>
</div>
</div>
</div>
@@ -145,7 +139,7 @@ onMounted(() => {
{{ obj.line }}
</div>
</b-alert>
<div v-for="(obj, index) in markdownObjects" :key="index" class="markdown-component py-2">
<div v-for="(obj, index) in markdownObjects" :key="index" class="markdown-component">
<SectionWrapper :name="obj.name" :content="obj.content" />
</div>
<div class="markdown-scroll-overlay" />
@@ -1,5 +1,11 @@
<template>
<ActivityPanel title="Insert Markdown Objects">
<ActivityPanel>
<template v-slot:activity-panel-header-top>
<GButton size="small" transparent @click="onClosePanel">
<FontAwesomeIcon fixed-width :icon="faChevronLeft" />
<h2 v-localize class="activity-panel-heading h-sm mb-0">Insert Markdown Objects</h2>
</GButton>
</template>
<div class="toolMenuContainer">
<b-alert v-if="error" variant="danger" class="my-2 mx-3 px-2 py-1" show>
{{ error }}
@@ -30,6 +36,8 @@
</template>
<script>
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import axios from "axios";
import BootstrapVue from "bootstrap-vue";
import Vue from "vue";
@@ -39,6 +47,7 @@ import { getAppRoot } from "@/onload/loadConfig";
import { directiveEntry } from "./directives.ts";
import GButton from "../BaseComponents/GButton.vue";
import MarkdownDialog from "./MarkdownDialog.vue";
import ActivityPanel from "@/components/Panels/ActivityPanel.vue";
import ToolSection from "@/components/Panels/Common/ToolSection.vue";
@@ -85,6 +94,8 @@ function historySharedElements(mode) {
export default {
components: {
GButton,
FontAwesomeIcon,
MarkdownDialog,
ToolSection,
ActivityPanel,
@@ -97,6 +108,7 @@ export default {
},
data() {
return {
faChevronLeft,
selectedArgumentName: null,
selectedType: null,
selectedShow: false,
@@ -324,6 +336,10 @@ export default {
onNoParameter(argumentName) {
this.onInsert(`${argumentName}()`);
},
onClosePanel() {
this.selectedShow = false;
this.$emit("close-panel");
},
onVisualizationId(argumentName) {
this.selectedArgumentName = argumentName;
this.selectedType = "visualization_id";
+11
View File
@@ -0,0 +1,11 @@
export interface MarkdownConfig {
content?: string;
errors?: Array<{ error?: string; line?: string }>;
generate_time?: string;
generate_version?: string;
id?: string;
markdown?: string;
model_class?: string;
title?: string;
update_time?: string;
}
+5 -2
View File
@@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BAlert, BButton } from "bootstrap-vue";
import { storeToRefs } from "pinia";
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router/composables";
import type { PublishedItem as PublishedItemType } from "@/components/Common/models/PublishedItem";
import { PAGE_LABELS, PUBLISHED_LABELS } from "@/components/Page/constants";
@@ -37,6 +38,8 @@ const props = withDefaults(defineProps<Props>(), {
displayOnly: false,
});
const router = useRouter();
const { config, isConfigLoaded } = useConfig(true);
const userStore = useUserStore();
const { currentUser } = storeToRefs(userStore);
@@ -86,7 +89,7 @@ onMounted(() => {
});
function onEdit() {
window.location.href = `/pages/editor?id=${props.pageId}`;
router.push(`/pages/editor?id=${props.pageId}`);
}
/** Whether to render chrome-free (embed or displayOnly). */
@@ -125,7 +128,7 @@ function stsUrl(config: any) {
{{ page.title || page.name }}
</Heading>
<div class="page-content">
<div class="page-content h-100">
<Markdown
v-if="page.content_format === 'markdown'"
:markdown-config="page"
+26 -21
View File
@@ -8,6 +8,9 @@
*
* Change strings here to rename across the entire UI.
*/
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { faBook, faFileContract } from "@fortawesome/free-solid-svg-icons";
import type { PageEditorMode } from "@/stores/pageEditorStore";
/** Per-mode labels used by the page editor, history page list, chat panel, etc. */
@@ -16,57 +19,59 @@ export const PAGE_LABELS: Record<
{
entityName: string;
entityNamePlural: string;
titleIcon: IconDefinition;
defaultTitle: string;
editorBackLabel: string;
newButton: string;
emptyStateTitle: string;
emptyStateDescription: string;
editButton: string;
viewButton: string;
createButton: string;
historyCounterTooltip: string;
assistantName: string;
assistantWelcome: string;
chatPlaceholder: string;
newConversation: string;
historyCounterTooltip?: string;
}
> = {
history: {
entityName: "Galaxy Notebook",
entityNamePlural: "Galaxy Notebooks",
titleIcon: faBook,
defaultTitle: "Untitled Notebook",
editorBackLabel: "This History's Notebooks",
newButton: "New Notebook",
emptyStateTitle: "No notebooks yet",
emptyStateDescription:
"Create a notebook to document your analysis with rich markdown, embedded datasets, and visualizations.",
viewButton: "View notebook",
editButton: "Edit Notebook",
viewButton: "View Notebook",
createButton: "Create Notebook",
historyCounterTooltip: "Galaxy Notebooks",
assistantName: "Markdown Assistant",
assistantWelcome:
"I'm the Markdown Assistant. I can help you edit this notebook — " +
"ask me to rewrite sections, add content, fix formatting, or analyze your history datasets.",
chatPlaceholder: "Ask about your history or request notebook edits...",
newConversation: "Starting a new conversation. How can I help with this notebook?",
},
standalone: {
entityName: "Report",
entityNamePlural: "Reports",
titleIcon: faFileContract,
defaultTitle: "Untitled Report",
editorBackLabel: "Back to Reports",
newButton: "Create Report",
emptyStateTitle: "No reports yet",
emptyStateDescription: "Create a report to publish your analysis.",
viewButton: "View report",
editButton: "Edit Report",
viewButton: "View Report",
createButton: "Create Report",
historyCounterTooltip: "",
assistantName: "Markdown Assistant",
assistantWelcome:
"I'm the Markdown Assistant. I can help you edit this report — " +
"ask me to rewrite sections, add content, fix formatting, " +
"or browse your current history's datasets.",
chatPlaceholder: "Ask about your history or request report edits...",
newConversation: "Starting a new conversation. How can I help with this report?",
},
invocation: {
entityName: "Invocation Report",
entityNamePlural: "Invocation Reports",
titleIcon: faFileContract,
defaultTitle: "Untitled Invocation Report",
editorBackLabel: "This Invocation's Reports",
newButton: "New Invocation Report",
emptyStateTitle: "No invocation reports yet",
emptyStateDescription:
"Create a report in the form of a Galaxy Notebook to document this workflow invocation with rich markdown, embedded datasets, and visualizations.",
editButton: "Edit Invocation Report",
viewButton: "View Invocation Report",
createButton: "Create Invocation Report",
},
} as const;
@@ -1,13 +1,22 @@
import { getLocalVue } from "@tests/vitest/helpers";
import { mount } from "@vue/test-utils";
import flushPromises from "flush-promises";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useServerMock } from "@/api/client/__mocks__";
import pageTemplate from "@/components/PageDisplay/pageTemplate.yml";
import { Toast } from "@/composables/toast";
import PageForm from "./PageForm.vue";
vi.mock("@/composables/toast", () => {
const toastInstance = { success: vi.fn(), error: vi.fn() };
return {
Toast: toastInstance,
useToast: () => toastInstance,
};
});
const { server, http } = useServerMock();
const localVue = getLocalVue();
@@ -35,6 +44,10 @@ function mountTarget(props = {}) {
}
describe("PageForm.vue - Create mode", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders loading spinner when fetching report", async () => {
server.use(
http.get("/api/invocations/:invocation_id/report", ({ response }) =>
@@ -79,14 +92,10 @@ describe("PageForm.vue - Create mode", () => {
expect(alert.text()).toContain("Failed to fetch report");
});
it("shows validation error when required fields are missing on submit", async () => {
it("submit button is disabled when required fields are missing", async () => {
const wrapper = mountTarget({ mode: "create" });
await flushPromises();
await wrapper.find("#page-submit").trigger("click");
await flushPromises();
const alert = wrapper.findComponent({ name: "BAlert" });
expect(alert.exists()).toBe(true);
expect(alert.text()).toContain("Please complete all required inputs.");
expect(wrapper.find("#page-submit").classes()).toContain("g-disabled");
});
it("submits page creation with correct fields", async () => {
@@ -118,13 +127,15 @@ describe("PageForm.vue - Create mode", () => {
await wrapper.find("#page-slug").setValue("some-title");
await wrapper.find("#page-submit").trigger("click");
await flushPromises();
const alert = wrapper.findComponent({ name: "BAlert" });
expect(alert.exists()).toBe(true);
expect(alert.text()).toContain("Creation failed");
expect(Toast.error).toHaveBeenCalledWith("Creation failed", "Error Creating Page");
});
});
describe("PageForm.vue - Edit mode", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders loading spinner and fetches page details", async () => {
server.use(
http.get("/api/pages/:id", ({ response }) =>
@@ -154,14 +165,10 @@ describe("PageForm.vue - Edit mode", () => {
expect(alert.text()).toContain("Page load failed");
});
it("shows validation error if required fields are missing on update", async () => {
it("submit button is disabled when required fields are missing", async () => {
const wrapper = mountTarget({ mode: "edit", id: "123" });
await flushPromises();
await wrapper.find("#page-submit").trigger("click");
await flushPromises();
const alert = wrapper.findComponent({ name: "BAlert" });
expect(alert.exists()).toBe(true);
expect(alert.text()).toContain("Please complete all required inputs.");
expect(wrapper.find("#page-submit").classes()).toContain("g-disabled");
});
it("submits page update with correct fields", async () => {
@@ -192,8 +199,6 @@ describe("PageForm.vue - Edit mode", () => {
await wrapper.find("#page-slug").setValue("error-title");
await wrapper.find("#page-submit").trigger("click");
await flushPromises();
const alert = wrapper.findComponent({ name: "BAlert" });
expect(alert.exists()).toBe(true);
expect(alert.text()).toContain("Update failed");
expect(Toast.error).toHaveBeenCalledWith("Update failed", "Error Updating Page");
});
});
+41 -11
View File
@@ -1,8 +1,10 @@
<template>
<div>
<LoadingSpan v-if="loading" />
<BAlert v-if="loading" show>
<LoadingSpan />
</BAlert>
<BAlert v-else-if="errorMessage" variant="danger" show>{{ errorMessage }}</BAlert>
<div v-else>
<BAlert v-if="errorMessage" variant="danger" show>{{ errorMessage }}</BAlert>
<FormCard :title="cardTitle" icon="fa-file-contract">
<template v-slot:body>
<FormElementLabel title="Title" :required="true" :condition="!!title">
@@ -20,26 +22,34 @@
</FormElementLabel>
</template>
</FormCard>
<BButton id="page-submit" class="my-2" variant="primary" @click="onSubmit">
<FontAwesomeIcon :icon="faSave" class="mr-1" />
<GButton
id="page-submit"
class="my-2"
color="blue"
:disabled="submitting || !title || !slug"
:disabled-title="disabledTitle"
@click="onSubmit">
<FontAwesomeIcon :icon="submitting ? faSpinner : faSave" class="mr-1" :spin="submitting" />
<span v-localize>{{ buttonText }}</span>
</BButton>
</GButton>
</div>
</div>
</template>
<script setup lang="ts">
import { faSave } from "@fortawesome/free-solid-svg-icons";
import { faSave, faSpinner } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BAlert, BButton } from "bootstrap-vue";
import { ref, watch } from "vue";
import { BAlert } from "bootstrap-vue";
import { computed, ref, watch } from "vue";
import { useRouter } from "vue-router/composables";
import { GalaxyApi } from "@/api";
import { fetchInvocationReport } from "@/api/invocations";
import { FORM_LABELS } from "@/components/Page/constants";
import pageTemplate from "@/components/PageDisplay/pageTemplate.yml";
import { useToast } from "@/composables/toast";
import GButton from "@/components/BaseComponents/GButton.vue";
import FormInput from "@/components/Form/Elements/FormInput.vue";
import FormCard from "@/components/Form/FormCard.vue";
import FormElementLabel from "@/components/Form/FormElementLabel.vue";
@@ -55,11 +65,24 @@ const annotation = ref("");
const content = ref(pageTemplate.content);
const errorMessage = ref("");
const loading = ref(false);
const submitting = ref(false);
const slug = ref("");
const title = ref("");
const disabledTitle = computed(() => {
if (submitting.value) {
return "Submitting";
}
if (!title.value || !slug.value) {
return "Please complete all required inputs.";
}
return "";
});
const router = useRouter();
const Toast = useToast();
const cardTitle = props.mode === "edit" ? FORM_LABELS.editTitle : FORM_LABELS.createTitle;
const buttonText = props.mode === "edit" ? "Update" : "Create";
@@ -97,10 +120,11 @@ async function fetchData() {
async function onSubmit() {
if (!slug.value || !title.value) {
errorMessage.value = "Please complete all required inputs.";
Toast.error("Please complete all required inputs.");
return;
}
submitting.value = true;
if (props.mode === "create") {
const { data, error } = await GalaxyApi().POST("/api/pages", {
body: {
@@ -113,8 +137,12 @@ async function onSubmit() {
},
});
if (error) {
errorMessage.value = error.err_msg;
Toast.error(error.err_msg, "Error Creating Page");
} else if (data.history_id) {
Toast.success("Galaxy Notebook created successfully");
router.push(`/histories/${data.history_id}/pages/${data.id}`);
} else {
Toast.success("Report created successfully");
router.push(`/pages/editor?id=${data.id}`);
}
} else {
@@ -127,11 +155,13 @@ async function onSubmit() {
},
});
if (error) {
errorMessage.value = error.err_msg;
Toast.error(error.err_msg, "Error Updating Page");
} else {
Toast.success("Page updated successfully");
router.push("/pages/list");
}
}
submitting.value = false;
}
watch(() => [props.id, props.invocationId], fetchData, { immediate: true });
@@ -1,61 +1,61 @@
import { createTestingPinia } from "@pinia/testing";
import { getLocalVue } from "@tests/vitest/helpers";
import { setupMockHistoryBreadcrumbs } from "@tests/vitest/mockHistoryBreadcrumbs";
import { shallowMount, type Wrapper } from "@vue/test-utils";
import flushPromises from "flush-promises";
import { beforeEach, describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { HistoryPageSummary } from "@/api/pages";
import { FAKE_PAGE_SUMMARY, FAKE_PAGE_UNTITLED } from "./testData";
import HistoryPageList from "./HistoryPageList.vue";
const localVue = getLocalVue();
const FAKE_PAGE_SUMMARY: HistoryPageSummary = {
id: "page-1",
history_id: "history-1",
title: "My Analysis",
slug: null,
source_invocation_id: null,
published: false,
importable: false,
deleted: false,
latest_revision_id: "rev-1",
revision_ids: ["rev-1"],
create_time: "2025-06-15T10:30:00Z",
update_time: "2025-06-15T12:45:00Z",
username: "test",
email_hash: "",
author_deleted: false,
model_class: "Page",
tags: [],
};
const FAKE_PAGE_UNTITLED: HistoryPageSummary = {
...FAKE_PAGE_SUMMARY,
id: "page-2",
title: "",
};
const SELECTORS = {
HEADER_TITLE: "h4",
NEW_BUTTON: "bbutton-stub",
NEW_BUTTON: "[data-description='create page button']",
EMPTY_STATE: ".empty-state",
PAGE_ITEMS: ".page-items",
PAGE_ITEM: ".page-item",
PAGE_TITLE: ".page-title",
PAGE_META: ".page-meta",
VIEW_BUTTON: "[data-description='page view button']",
PAGE_ITEM: "[data-description='page item']",
UNOWNED_ALERT: "balert-stub",
};
async function mountComponent(propsData: { pages: HistoryPageSummary[] }) {
setupMockHistoryBreadcrumbs();
// Mocks for store methods
const mockGetHistoryById = vi.fn();
const mockMatchesCurrentUserId = vi.fn();
vi.mock("@/stores/historyStore", () => ({
useHistoryStore: vi.fn(() => ({
getHistoryById: mockGetHistoryById,
})),
}));
vi.mock("@/stores/userStore", () => ({
useUserStore: vi.fn(() => ({
matchesCurrentUserId: mockMatchesCurrentUserId,
})),
}));
async function mountComponent(propsData: { pages: HistoryPageSummary[]; invocationId?: string }) {
const wrapper = shallowMount(HistoryPageList as object, {
localVue,
propsData,
propsData: { ...propsData, historyId: "history-1" },
pinia: createTestingPinia({ createSpy: vi.fn }),
});
await flushPromises();
return wrapper;
}
describe("HistoryPageList", () => {
beforeEach(() => {
// Default: history is owned by the current user, no alert shown
mockGetHistoryById.mockReturnValue({ id: "history-1", name: "Test History", user_id: "user-1" });
mockMatchesCurrentUserId.mockReturnValue(true);
});
describe("Header", () => {
let wrapper: Wrapper<Vue>;
@@ -63,12 +63,6 @@ describe("HistoryPageList", () => {
wrapper = await mountComponent({ pages: [] });
});
it("always shows 'Galaxy Notebooks' heading", () => {
const heading = wrapper.find(SELECTORS.HEADER_TITLE);
expect(heading.exists()).toBe(true);
expect(heading.text()).toBe("Galaxy Notebooks");
});
it("always shows 'New Notebook' button", () => {
const button = wrapper.find(SELECTORS.NEW_BUTTON);
expect(button.exists()).toBe(true);
@@ -108,78 +102,48 @@ describe("HistoryPageList", () => {
});
});
it("renders each page as a clickable item", () => {
it("renders each page as a card", () => {
expect(wrapper.find(SELECTORS.PAGE_ITEMS).exists()).toBe(true);
const items = wrapper.findAll(SELECTORS.PAGE_ITEM);
expect(items.length).toBe(2);
});
it("displays page title for each item", () => {
const titles = wrapper.findAll(SELECTORS.PAGE_TITLE);
expect(titles.at(0).text()).toBe("My Analysis");
});
it("shows 'Untitled Notebook' when title is empty", () => {
const titles = wrapper.findAll(SELECTORS.PAGE_TITLE);
expect(titles.at(1).text()).toBe("Untitled Notebook");
});
it("displays formatted update_time for each page", () => {
const meta = wrapper.findAll(SELECTORS.PAGE_META);
const metaText = meta.at(0).text();
expect(metaText).toContain("Updated");
expect(metaText.replace("Updated", "").trim().length).toBeGreaterThan(0);
});
it("does NOT show empty state when pages exist", () => {
expect(wrapper.find(SELECTORS.EMPTY_STATE).exists()).toBe(false);
});
});
describe("Events", () => {
it("emits 'create' when New Page button is clicked", async () => {
describe("Unowned history alert", () => {
it("does not show alert when current user owns the history", async () => {
const wrapper = await mountComponent({ pages: [] });
const button = wrapper.find(SELECTORS.NEW_BUTTON);
await button.trigger("click");
expect(wrapper.emitted().create).toBeTruthy();
expect(wrapper.find(SELECTORS.UNOWNED_ALERT).exists()).toBe(false);
});
it("emits 'select' with page.id when a page item is clicked", async () => {
const wrapper = await mountComponent({
pages: [FAKE_PAGE_SUMMARY],
});
const item = wrapper.find(SELECTORS.PAGE_ITEM);
await item.trigger("click");
expect(wrapper.emitted().select).toBeTruthy();
expect(wrapper.emitted().select![0]![0]).toBe("page-1");
});
});
describe("View button", () => {
it("shows view button on each page item", async () => {
const wrapper = await mountComponent({
pages: [FAKE_PAGE_SUMMARY],
});
const viewButton = wrapper.find(SELECTORS.VIEW_BUTTON);
expect(viewButton.exists()).toBe(true);
it("shows alert when current user does not own the history", async () => {
mockMatchesCurrentUserId.mockReturnValue(false);
const wrapper = await mountComponent({ pages: [] });
const alert = wrapper.find(SELECTORS.UNOWNED_ALERT);
expect(alert.exists()).toBe(true);
expect(alert.text()).toContain("You do not own this history");
});
it("emits 'view' with page.id when view button clicked", async () => {
const wrapper = await mountComponent({
pages: [FAKE_PAGE_SUMMARY],
});
const viewButton = wrapper.find(SELECTORS.VIEW_BUTTON);
await viewButton.trigger("click");
expect(wrapper.emitted().view).toBeTruthy();
expect(wrapper.emitted().view![0]![0]).toBe("page-1");
it("does not show invocation span in alert when no invocationId", async () => {
mockMatchesCurrentUserId.mockReturnValue(false);
const wrapper = await mountComponent({ pages: [] });
expect(wrapper.find(SELECTORS.UNOWNED_ALERT).text()).not.toContain("associated with the invocation");
});
it("does not emit 'select' when view button clicked", async () => {
const wrapper = await mountComponent({
pages: [FAKE_PAGE_SUMMARY],
});
const viewButton = wrapper.find(SELECTORS.VIEW_BUTTON);
await viewButton.trigger("click");
expect(wrapper.emitted().select).toBeFalsy();
it("shows invocation qualifier in alert when invocationId is set", async () => {
mockMatchesCurrentUserId.mockReturnValue(false);
const wrapper = await mountComponent({ pages: [], invocationId: "inv-1" });
expect(wrapper.find(SELECTORS.UNOWNED_ALERT).text()).toContain("associated with the invocation");
});
it("does not show alert when history has no user_id field", async () => {
mockGetHistoryById.mockReturnValue({ id: "history-1", name: "Test History" });
const wrapper = await mountComponent({ pages: [] });
expect(wrapper.find(SELECTORS.UNOWNED_ALERT).exists()).toBe(false);
});
});
});
@@ -1,47 +1,78 @@
<script setup lang="ts">
import { faChevronRight, faEye, faPlus } from "@fortawesome/free-solid-svg-icons";
import { faArrowLeft, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BButton } from "bootstrap-vue";
import { BAlert } from "bootstrap-vue";
import { computed, toRef } from "vue";
import type { HistoryPageSummary } from "@/api/pages";
import { PAGE_LABELS } from "@/components/Page/constants";
import { useHistoryBreadCrumbsTo } from "@/composables/historyBreadcrumbs";
import { useHistoryStore } from "@/stores/historyStore.js";
import { useUserStore } from "@/stores/userStore.js";
defineProps<{
import BreadcrumbHeading from "../Common/BreadcrumbHeading.vue";
import Heading from "../Common/Heading.vue";
import PageCard from "./PageCard.vue";
import GButton from "@/components/BaseComponents/GButton.vue";
const props = defineProps<{
pages: HistoryPageSummary[];
historyId: string;
invocationId?: string;
}>();
defineEmits<{
(e: "select", pageId: string): void;
const emit = defineEmits<{
(e: "edit", pageId: string, username: string): void;
(e: "view", pageId: string): void;
(e: "create"): void;
(e: "view-runtime-report"): void;
}>();
const labels = PAGE_LABELS.history;
const historyStore = useHistoryStore();
const userStore = useUserStore();
function getPageTitle(page: HistoryPageSummary): string {
return page.title || labels.defaultTitle;
}
const history = computed(() => historyStore.getHistoryById(props.historyId));
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
const unownedHistory = computed(() =>
history.value && "user_id" in history.value
? !userStore.matchesCurrentUserId(history.value.user_id as string)
: undefined,
);
const labels = computed(() => (props.invocationId ? PAGE_LABELS.invocation : PAGE_LABELS.history));
const { breadcrumbItems } = useHistoryBreadCrumbsTo(toRef(props, "historyId"), labels.value.entityNamePlural);
</script>
<template>
<div class="history-page-list" data-description="history page list">
<div class="list-header d-flex justify-content-between align-items-center p-3 border-bottom">
<h4 class="mb-0">{{ labels.entityNamePlural }}</h4>
<BButton variant="primary" size="sm" data-description="create page button" @click="$emit('create')">
<div class="d-flex flex-column" data-description="history page list">
<BreadcrumbHeading v-if="!props.invocationId" :items="breadcrumbItems">
<GButton
class="text-nowrap"
color="blue"
size="small"
data-description="create page button"
@click="emit('create')">
<FontAwesomeIcon :icon="faPlus" />
{{ labels.newButton }}
</BButton>
</GButton>
</BreadcrumbHeading>
<div v-else class="d-flex flex-gapx-1 pb-2">
<Heading h1 separator inline size="md" class="flex-grow-1 mb-0">
Edited {{ labels.entityNamePlural }}
</Heading>
<GButton size="small" outline color="blue" @click="emit('view-runtime-report')">
Back to Runtime Report
<FontAwesomeIcon :icon="faArrowLeft" />
</GButton>
</div>
<BAlert v-if="unownedHistory" show>
You do not own this history
<span v-if="props.invocationId">(associated with the invocation)</span>
so only {{ labels.entityNamePlural }} that you created against this history are shown.
</BAlert>
<div v-if="pages.length === 0" class="empty-state text-center p-4" data-description="page empty state">
<p class="text-muted">{{ labels.emptyStateTitle }}</p>
<p class="text-muted small">
@@ -49,34 +80,19 @@ function formatDate(dateStr: string): string {
</p>
</div>
<div v-else class="page-items">
<div
<div v-else class="page-items mt-1">
<PageCard
v-for="page in pages"
:key="page.id"
class="page-item p-3 border-bottom cursor-pointer"
data-description="page item"
@click="$emit('select', page.id)">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="page-title fw-bold" data-description="page title">
{{ getPageTitle(page) }}
</div>
<div class="page-meta text-muted small">Updated {{ formatDate(page.update_time) }}</div>
</div>
<span class="page-actions d-flex align-items-center">
<BButton
variant="link"
size="sm"
class="p-1"
:title="labels.viewButton"
data-description="page view button"
@click.stop="$emit('view', page.id)">
<FontAwesomeIcon :icon="faEye" />
</BButton>
<FontAwesomeIcon :icon="faChevronRight" class="text-muted" />
</span>
</div>
</div>
:page="page"
:default-title="labels.defaultTitle"
:entity-name="labels.entityName"
:edit-title="labels.editButton"
:show-invocation-badge="!props.invocationId"
:view-title="labels.viewButton"
@edit="emit('edit', page.id, page.username)"
@view="emit('view', page.id)" />
</div>
</div>
</template>
@@ -85,6 +101,10 @@ function formatDate(dateStr: string): string {
.page-item:hover {
background: var(--panel-header-bg);
}
.page-items {
flex: 1 1 0;
overflow-y: auto;
}
.cursor-pointer {
cursor: pointer;
}
@@ -9,8 +9,8 @@ import { usePageEditorStore } from "@/stores/pageEditorStore";
import HistoryPageList from "./HistoryPageList.vue";
import HistoryPageView from "./HistoryPageView.vue";
import PageDisplayOnly from "./PageDisplayOnly.vue";
import PageEditorView from "./PageEditorView.vue";
import Markdown from "@/components/Markdown/Markdown.vue";
vi.mock("@/composables/config", () => ({
useConfig: vi.fn(() => ({
@@ -29,6 +29,19 @@ vi.mock("vue-router/composables", () => ({
})),
}));
const mockPushToFrameOrPage = vi.fn();
vi.mock("@/composables/windowAwareNavigation", () => ({
useWindowAwareNavigation: vi.fn(() => ({
pushToFrameOrPage: mockPushToFrameOrPage,
})),
}));
vi.mock("@/composables/confirmDialog.js", () => ({
useConfirmDialog: vi.fn(() => ({
confirm: vi.fn().mockResolvedValue(true),
})),
}));
vi.mock("@/stores/historyStore", () => ({
useHistoryStore: vi.fn(() => ({
getHistoryById: vi.fn((id: string) => {
@@ -53,10 +66,7 @@ const PAGE_ID = "page-1";
const SELECTORS = {
INFO_ALERT: "balert-stub[variant='info']",
ERROR_ALERT: "balert-stub[variant='danger']",
DISPLAY_TOOLBAR: "[data-description='page display toolbar']",
EDIT_BUTTON: "[data-description='page edit button']",
MANAGE_BUTTON: "[data-description='page manage button']",
};
} as const;
let pinia: Pinia;
@@ -80,6 +90,7 @@ describe("HistoryPageView", () => {
beforeEach(() => {
pinia = createTestingPinia({ createSpy: vi.fn });
vi.clearAllMocks();
mockPushToFrameOrPage.mockReset();
});
afterEach(() => {
@@ -195,7 +206,7 @@ describe("HistoryPageView", () => {
await flushPromises();
expect(wrapper.findComponent(PageEditorView).exists()).toBe(false);
expect(wrapper.findComponent(Markdown).exists()).toBe(true);
expect(wrapper.findComponent(PageDisplayOnly).exists()).toBe(true);
});
});
@@ -214,6 +225,8 @@ describe("HistoryPageView", () => {
} as any;
store.currentContent = "# Hello";
store.currentTitle = "My Page";
// hasCurrentPage is a computed (readonly) — stub it so the display toolbar renders
vi.spyOn(store, "hasCurrentPage", "get").mockReturnValue(true);
return store;
}
@@ -222,39 +235,27 @@ describe("HistoryPageView", () => {
const wrapper = mountComponent({ historyId: HISTORY_ID, pageId: PAGE_ID, displayOnly: true });
await flushPromises();
expect(wrapper.findComponent(Markdown).exists()).toBe(true);
expect(wrapper.findComponent(PageDisplayOnly).exists()).toBe(true);
});
it("passes correct markdownConfig to Markdown", async () => {
it("passes correct markdownConfig to PageDisplayOnly", async () => {
setupLoadedPage();
const wrapper = mountComponent({ historyId: HISTORY_ID, pageId: PAGE_ID, displayOnly: true });
await flushPromises();
const md = wrapper.findComponent(Markdown);
const md = wrapper.findComponent(PageDisplayOnly);
const config = md.props("markdownConfig");
expect(config.id).toBe(PAGE_ID);
expect(config.title).toBe("My Page");
expect(config.content).toBe("# Hello");
});
it("shows display toolbar with Edit button", async () => {
setupLoadedPage();
const wrapper = mountComponent({ historyId: HISTORY_ID, pageId: PAGE_ID, displayOnly: true });
await flushPromises();
expect(wrapper.find(SELECTORS.DISPLAY_TOOLBAR).exists()).toBe(true);
const editBtn = wrapper.find(SELECTORS.EDIT_BUTTON);
expect(editBtn.exists()).toBe(true);
expect(editBtn.text()).toContain("Edit");
});
it("Edit button navigates to edit mode (no displayOnly)", async () => {
setupLoadedPage();
const wrapper = mountComponent({ historyId: HISTORY_ID, pageId: PAGE_ID, displayOnly: true });
await flushPromises();
const editBtn = wrapper.find(SELECTORS.EDIT_BUTTON);
await editBtn.trigger("click");
wrapper.findComponent(PageDisplayOnly).vm.$emit("edit");
expect(mockPush).toHaveBeenCalledWith(`/histories/${HISTORY_ID}/pages/${PAGE_ID}`);
});
@@ -280,13 +281,13 @@ describe("HistoryPageView", () => {
});
describe("Navigation/Events", () => {
it("handleSelect navigates to page URL via router.push", async () => {
it("edit emit from list navigates to page edit URL", async () => {
setupListViewStore([{ id: "nb-1", history_id: HISTORY_ID, title: "NB1" }]);
const wrapper = mountComponent({ historyId: HISTORY_ID });
await flushPromises();
const list = wrapper.findComponent(HistoryPageList);
list.vm.$emit("select", "nb-1");
list.vm.$emit("edit", "nb-1");
await wrapper.vm.$nextTick();
expect(mockPush).toHaveBeenCalledWith(`/histories/${HISTORY_ID}/pages/nb-1`);
@@ -307,11 +308,11 @@ describe("HistoryPageView", () => {
list.vm.$emit("create");
await flushPromises();
expect(store.createPage).toHaveBeenCalledWith({ title: "Untitled Notebook" });
expect(store.createPage).toHaveBeenCalledWith({ title: undefined, content: undefined });
expect(mockPush).toHaveBeenCalledWith(`/histories/${HISTORY_ID}/pages/new-page`);
});
it("view emit from list navigates to displayOnly URL", async () => {
it("view emit from list navigates to displayOnly URL via pushToFrameOrPage", async () => {
setupListViewStore([{ id: "nb-1", history_id: HISTORY_ID, title: "NB1" }]);
const wrapper = mountComponent({ historyId: HISTORY_ID });
await flushPromises();
@@ -320,7 +321,11 @@ describe("HistoryPageView", () => {
list.vm.$emit("view", "nb-1");
await wrapper.vm.$nextTick();
expect(mockPush).toHaveBeenCalledWith(`/histories/${HISTORY_ID}/pages/nb-1?displayOnly=true`);
expect(mockPushToFrameOrPage).toHaveBeenCalledWith(
expect.objectContaining({
inlineUrl: `/histories/${HISTORY_ID}/pages/nb-1?displayOnly=true`,
}),
);
});
});
@@ -329,20 +334,22 @@ describe("HistoryPageView", () => {
mockGalaxyInstance.frame.active = false;
});
it("handleSelect opens in WinBox when WM is active", async () => {
it("view emit opens in WinBox when WM is active", async () => {
mockGalaxyInstance.frame.active = true;
setupListViewStore([{ id: "nb-1", history_id: HISTORY_ID, title: "NB1" }]);
const wrapper = mountComponent({ historyId: HISTORY_ID });
await flushPromises();
const list = wrapper.findComponent(HistoryPageList);
list.vm.$emit("select", "nb-1");
list.vm.$emit("view", "nb-1");
await wrapper.vm.$nextTick();
expect(mockPush).toHaveBeenCalledWith(`/histories/${HISTORY_ID}/pages/nb-1?displayOnly=true`, {
title: "Galaxy Notebook: NB1",
preventWindowManager: false,
});
expect(mockPushToFrameOrPage).toHaveBeenCalledWith(
expect.objectContaining({
inlineUrl: `/histories/${HISTORY_ID}/pages/nb-1?displayOnly=true`,
title: "Galaxy Notebook: NB1",
}),
);
});
});
@@ -352,7 +359,7 @@ describe("HistoryPageView", () => {
mountComponent({ historyId: HISTORY_ID });
await flushPromises();
expect(store.loadPages).toHaveBeenCalledWith(HISTORY_ID);
expect(store.loadPages).toHaveBeenCalledWith(HISTORY_ID, undefined);
});
it("does not call store.loadPageById on mount when no pageId", async () => {
@@ -1,28 +1,37 @@
<script setup lang="ts">
import { faArrowLeft, faEdit, faSpinner } from "@fortawesome/free-solid-svg-icons";
import { faCopy, faPlus, faSpinner } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BAlert, BButton } from "bootstrap-vue";
import { BAlert } from "bootstrap-vue";
import { computed, onMounted, onUnmounted, watch } from "vue";
import { useRouter } from "vue-router/composables";
import { PAGE_LABELS } from "@/components/Page/constants";
import { useConfirmDialog } from "@/composables/confirmDialog.js";
import { useToast } from "@/composables/toast";
import { useWindowAwareNavigation } from "@/composables/windowAwareNavigation";
import { usePageEditorStore } from "@/stores/pageEditorStore";
import { useUserStore } from "@/stores/userStore.js";
import { errorMessageAsString } from "@/utils/simple-error.js";
import HistoryPageList from "./HistoryPageList.vue";
import PageDisplayOnly from "./PageDisplayOnly.vue";
import PageEditorView from "./PageEditorView.vue";
import Markdown from "@/components/Markdown/Markdown.vue";
const props = defineProps<{
historyId: string;
invocationId?: string;
pageId?: string;
displayOnly?: boolean;
}>();
const Toast = useToast();
const { confirm } = useConfirmDialog();
const router = useRouter();
const { pushToFrameOrPage } = useWindowAwareNavigation();
const userStore = useUserStore();
const store = usePageEditorStore();
const labels = PAGE_LABELS.history;
const labels = computed(() => (props.invocationId ? PAGE_LABELS.invocation : PAGE_LABELS.history));
const markdownConfig = computed(() => {
if (!store.currentPage) {
@@ -31,7 +40,7 @@ const markdownConfig = computed(() => {
const content = props.displayOnly ? (store.currentPage.content ?? store.currentContent) : store.currentContent;
return {
id: store.currentPage.id,
title: store.currentTitle || labels.defaultTitle,
title: store.currentTitle || labels.value.defaultTitle,
content,
model_class: "Page",
update_time: store.currentPage.update_time,
@@ -39,7 +48,7 @@ const markdownConfig = computed(() => {
});
onMounted(async () => {
await store.loadPages(props.historyId);
await store.loadPages(props.historyId, props.invocationId);
if (props.pageId && props.displayOnly) {
await store.loadPageById(props.pageId);
}
@@ -52,11 +61,13 @@ onUnmounted(() => {
});
watch(
() => props.historyId,
async (newId) => {
await store.loadPages(newId);
if (props.pageId && props.displayOnly) {
await store.loadPageById(props.pageId);
() => [props.historyId, props.invocationId],
async ([newHistoryId, newInvocationId]) => {
if (newHistoryId) {
await store.loadPages(newHistoryId, newInvocationId);
if (props.pageId && props.displayOnly) {
await store.loadPageById(props.pageId);
}
}
},
);
@@ -72,36 +83,86 @@ watch(
},
);
function handleSelect(pageId: string) {
const page = store.pages.find((n) => n.id === pageId);
const pageTitle = page?.title || labels.entityName;
const inlineUrl = `/histories/${props.historyId}/pages/${pageId}`;
async function createAPage(isCopy = false) {
const entity = labels.value.entityName;
const modalText = isCopy
? `You are not the owner of this ${entity}. To edit it, a copy with its contents, owned by you, will be created. Do you want to proceed?`
: `A new ${entity} will be created and will be added to the list of ${labels.value.entityNamePlural} for this ${props.invocationId ? "invocation" : "history"}. Do you want to proceed?`;
const modalTitle = isCopy ? `Copy this ${entity}?` : `Create new ${entity}?`;
const okText = isCopy ? `Copy ${entity}` : `Create ${entity}`;
const okIcon = isCopy ? faCopy : faPlus;
const confirmed = await confirm(modalText, {
title: modalTitle,
okText,
okIcon,
});
if (!confirmed) {
return;
}
const newPage = await store.createPage({
title: isCopy
? store.currentTitle
? `Copy of "${store.currentTitle}"`
: labels.value.defaultTitle
: undefined,
content: isCopy ? store.currentContent : undefined,
});
return newPage;
}
function handleView(viewingPageId: string) {
const page = store.pages.find((n) => n.id === viewingPageId);
const pageTitle = page?.title || labels.value.entityName;
const inlineUrl = props.invocationId
? `/workflows/invocations/${props.invocationId}/reports?id=${viewingPageId}`
: `/histories/${props.historyId}/pages/${viewingPageId}?displayOnly=true`;
pushToFrameOrPage({
framedUrl: `${inlineUrl}?displayOnly=true`,
framedUrl: `/pages/editor?id=${viewingPageId}&displayOnly=true&hideHeader=true`,
inlineUrl,
title: `${labels.entityName}: ${pageTitle}`,
title: `${labels.value.entityName}: ${pageTitle}`,
});
}
async function handleCreate() {
const page = await store.createPage({ title: labels.defaultTitle });
if (page) {
router.push(`/histories/${props.historyId}/pages/${page.id}`);
try {
const createdPage = await createAPage();
if (createdPage) {
handleEdit(createdPage.id, createdPage.username);
}
} catch (error) {
Toast.error(errorMessageAsString(error), `Failed to create ${labels.value.entityName.toLowerCase()}`);
}
}
function handleView(pageId: string) {
router.push(`/histories/${props.historyId}/pages/${pageId}?displayOnly=true`);
}
async function handleEdit(editingPageId: string, ownerUsername: string) {
let routedId: string | undefined = editingPageId;
function handleEdit() {
if (props.pageId) {
router.push(`/histories/${props.historyId}/pages/${props.pageId}`);
if (routedId && ownerUsername && !userStore.matchesCurrentUsername(ownerUsername)) {
const copiedPage = await createAPage(true);
routedId = copiedPage?.id;
}
if (routedId) {
let editUrl = `/histories/${props.historyId}/pages/${routedId}`;
if (props.invocationId) {
editUrl += `?invocation_id=${props.invocationId}`;
}
router.push(editUrl);
}
}
function handleBack() {
store.clearCurrentPage();
if (props.invocationId) {
router.push(`/workflows/invocations/${props.invocationId}/reports`);
return;
}
router.push(`/histories/${props.historyId}/pages`);
}
</script>
@@ -128,38 +189,27 @@ function handleBack() {
</BAlert>
<template v-else-if="!pageId">
<HistoryPageList :pages="store.pages" @select="handleSelect" @view="handleView" @create="handleCreate" />
<HistoryPageList
:history-id="props.historyId"
:invocation-id="props.invocationId"
:pages="store.pages"
@view-runtime-report="router.push(`/workflows/invocations/${props.invocationId}/report`)"
@edit="handleEdit"
@view="handleView"
@create="handleCreate" />
</template>
<!-- Display-only mode: rendered view -->
<template v-else-if="store.hasCurrentPage && displayOnly">
<div
class="page-display-toolbar d-flex align-items-center p-2 border-bottom"
data-description="page display toolbar">
<BButton variant="link" size="sm" data-description="page manage button" @click="handleBack">
<FontAwesomeIcon :icon="faArrowLeft" />
{{ labels.editorBackLabel }}
</BButton>
<span class="flex-grow-1 text-center font-weight-bold">
{{ store.currentTitle || labels.defaultTitle }}
</span>
<BButton variant="outline-primary" size="sm" data-description="page edit button" @click="handleEdit">
<FontAwesomeIcon :icon="faEdit" />
Edit
</BButton>
</div>
<div class="page-display-content overflow-auto flex-grow-1" data-description="page rendered view">
<Markdown
v-if="markdownConfig"
:markdown-config="markdownConfig"
:read-only="true"
download-endpoint="" />
</div>
</template>
<PageDisplayOnly
v-else-if="store.currentPage?.id && store.currentPage.id === props.pageId && displayOnly"
:labels="labels"
:markdown-config="markdownConfig || undefined"
@back="handleBack"
@edit="handleEdit(store.currentPage.id, store.currentPage.username)" />
<!-- Edit mode: delegate to unified PageEditorView -->
<template v-else-if="pageId && !displayOnly">
<PageEditorView :page-id="pageId" :history-id="historyId" />
<PageEditorView :page-id="pageId" :history-id="historyId" :invocation-id="invocationId" />
</template>
<BAlert v-else-if="store.isLoadingPage" variant="info" show>
@@ -173,7 +223,4 @@ function handleBack() {
.history-page-view {
background: var(--body-bg);
}
.page-display-toolbar {
background: var(--panel-header-bg);
}
</style>
@@ -0,0 +1,86 @@
import { createTestingPinia } from "@pinia/testing";
import { getLocalVue } from "@tests/vitest/helpers";
import { mount } from "@vue/test-utils";
import { describe, expect, it, vi } from "vitest";
import { FAKE_PAGE_SUMMARY, FAKE_PAGE_UNTITLED } from "./testData";
import PageCard from "./PageCard.vue";
const localVue = getLocalVue();
function getSelector(selector: "title" | "revision" | "time" | "view" | "edit", pageId: string) {
if (selector === "title") {
return `#g-card-title-link-page-${pageId}`;
} else if (selector === "revision") {
return `#g-card-badge-notebook-revisions-count-page-${pageId}`;
} else if (selector === "time") {
return `#g-card-page-${pageId}-update-time`;
} else if (selector === "view") {
return `#g-card-action-view-notebook-page-${pageId}`;
} else if (selector === "edit") {
return `#g-card-action-edit-notebook-page-${pageId}`;
}
throw new Error(`Unknown selector: ${selector}`);
}
describe("PageCard", () => {
it("displays page title which edits notebook on click", async () => {
const wrapper = mount(PageCard as object, {
localVue,
propsData: { page: FAKE_PAGE_SUMMARY },
pinia: createTestingPinia({ createSpy: vi.fn }),
});
const title = wrapper.find(getSelector("title", FAKE_PAGE_SUMMARY.id));
expect(title.text()).toBe("My Analysis");
expect(title.attributes("title")).toBe("Edit Notebook");
// Click to emit edit event
await title.trigger("click");
expect(wrapper.emitted().edit).toBeTruthy();
});
it("shows 'Untitled Notebook' when title is empty", () => {
const wrapper = mount(PageCard as object, {
localVue,
propsData: { page: FAKE_PAGE_UNTITLED },
pinia: createTestingPinia({ createSpy: vi.fn }),
});
const title = wrapper.find(getSelector("title", FAKE_PAGE_UNTITLED.id));
expect(title.text()).toBe("Untitled Notebook");
});
it("displays update time badge and revision count", () => {
const wrapper = mount(PageCard as object, {
localVue,
propsData: { page: FAKE_PAGE_SUMMARY },
pinia: createTestingPinia({ createSpy: vi.fn }),
});
expect(wrapper.find(getSelector("time", FAKE_PAGE_SUMMARY.id)).exists()).toBe(true);
const revisionBadge = wrapper.find(getSelector("revision", FAKE_PAGE_SUMMARY.id));
expect(revisionBadge.exists()).toBe(true);
expect(revisionBadge.text()).toBe(
`${FAKE_PAGE_SUMMARY.revision_ids.length} Revision${FAKE_PAGE_SUMMARY.revision_ids.length !== 1 ? "s" : ""}`,
);
});
it("emits 'view' and 'select' operations correctly", async () => {
const wrapper = mount(PageCard as object, {
localVue,
propsData: { page: FAKE_PAGE_SUMMARY },
pinia: createTestingPinia({ createSpy: vi.fn }),
});
const viewButton = wrapper.find(getSelector("view", FAKE_PAGE_SUMMARY.id));
await viewButton.trigger("click");
expect(wrapper.emitted().view).toBeTruthy();
const editButton = wrapper.find(getSelector("edit", FAKE_PAGE_SUMMARY.id));
await editButton.trigger("click");
expect(wrapper.emitted().edit).toBeTruthy();
});
});
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { faEdit, faEye, faHistory, faShareAlt, faSitemap, faUser } from "@fortawesome/free-solid-svg-icons";
import { computed } from "vue";
import type { HistoryPageSummary } from "@/api/pages";
import type { CardAction, CardBadge, Title } from "@/components/Common/GCard.types";
import { useUserStore } from "@/stores/userStore.js";
import GCard from "../Common/GCard.vue";
interface Props {
page: HistoryPageSummary;
defaultTitle?: string;
entityName?: string;
editTitle?: string;
showInvocationBadge?: boolean;
viewTitle?: string;
}
const props = withDefaults(defineProps<Props>(), {
entityName: "Notebook",
editTitle: "Edit Notebook",
defaultTitle: "Untitled Notebook",
viewTitle: "View Notebook",
});
const emit = defineEmits<{
(e: "edit"): void;
(e: "view"): void;
}>();
const userStore = useUserStore();
const title: Title = {
label: props.page.title || props.defaultTitle,
title: props.editTitle,
handler: () => emit("edit"),
};
const badges = computed<CardBadge[]>(() => {
const retBadges: CardBadge[] = [
{
id: "notebook-revisions-count",
label: `${props.page.revision_ids.length} Revision${props.page.revision_ids.length !== 1 ? "s" : ""}`,
icon: faHistory,
title: "Number of revisions for this notebook",
class: "unselectable",
},
];
if (!userStore.matchesCurrentUsername(props.page.username)) {
retBadges.push({
id: "owned-by-other-user",
label: `Owned by ${props.page.username}`,
icon: faUser,
title: "This notebook is owned by another user. Editing it will create a copy owned by you.",
class: "unselectable",
variant: "outline-primary",
});
}
if (props.showInvocationBadge && props.page.source_invocation_id) {
retBadges.push({
id: "is-invocation-notebook",
label: `Invocation ${props.page.source_invocation_id}`,
icon: faSitemap,
title: "This notebook is a report created from a workflow invocation (Click to view the original report under the invocation)",
class: "unselectable",
to: `/workflows/invocations/${props.page.source_invocation_id}/reports?id=${props.page.id}`,
variant: "info",
});
}
return retBadges;
});
const primaryActions: CardAction[] = [
{
id: "edit-notebook",
label: "Edit",
icon: faEdit,
title: !props.page.deleted ? props.editTitle : `This ${props.entityName} is deleted`,
handler: () => emit("edit"),
disabled: props.page.deleted,
},
];
const secondaryActions: CardAction[] = [
{
id: "share-access-management",
label: "Share and Publish",
title: `Make this ${props.entityName} accessible or public`,
icon: faShareAlt,
variant: "outline-primary",
to: `/pages/sharing?id=${props.page.id}`,
visible: !props.page.deleted && userStore.matchesCurrentUsername(props.page.username),
},
{
id: "view-notebook",
label: "View",
icon: faEye,
title: props.viewTitle,
handler: () => emit("view"),
},
];
</script>
<template>
<GCard
:id="`page-${props.page.id}`"
:badges="badges"
:primary-actions="primaryActions"
:published="props.page.published"
:secondary-actions="secondaryActions"
:title="title"
title-size="text"
:update-time="props.page.update_time" />
</template>
@@ -1,231 +0,0 @@
import { createTestingPinia } from "@pinia/testing";
import { mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import PageChatHistoryList from "./PageChatHistoryList.vue";
const MOCK_ITEMS = [
{
id: "enc1",
query: "First question",
response: "First answer",
agent_type: "page_assistant",
timestamp: "2025-06-15T10:00:00Z",
feedback: null,
message_count: 2,
},
{
id: "enc2",
query: "Second question",
response: "Second answer",
agent_type: "page_assistant",
timestamp: "2025-06-14T09:00:00Z",
feedback: 1,
message_count: 4,
},
{
id: "enc3",
query: "Third question",
response: "Third answer",
agent_type: "page_assistant",
timestamp: "2025-06-13T08:00:00Z",
feedback: null,
message_count: 2,
},
];
function mountList(props: Record<string, unknown> = {}) {
return mount(PageChatHistoryList as any, {
propsData: {
items: MOCK_ITEMS,
isLoading: false,
error: null,
activeExchangeId: null,
...props,
},
stubs: {
FontAwesomeIcon: true,
UtcDate: true,
BAlert: true,
},
});
}
describe("PageChatHistoryList", () => {
// Vue 2.7 emits prop warnings for `string | null` TS types; suppress them
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
createTestingPinia({ createSpy: vi.fn, stubActions: false });
});
describe("rendering", () => {
it("renders items", () => {
const wrapper = mountList();
const items = wrapper.findAll('[data-description="sidebar item"]');
expect(items.length).toBe(3);
});
it("shows query text for each item", () => {
const wrapper = mountList();
expect(wrapper.text()).toContain("First question");
expect(wrapper.text()).toContain("Second question");
expect(wrapper.text()).toContain("Third question");
});
it("shows empty message when no items", () => {
const wrapper = mountList({ items: [] });
expect(wrapper.text()).toContain("No conversations yet.");
});
it("shows loading message when loading", () => {
const wrapper = mountList({ isLoading: true });
expect(wrapper.text()).toContain("Loading history...");
});
});
describe("selection mode toggle", () => {
it("trash button toggles selection mode on", async () => {
const wrapper = mountList();
expect(wrapper.find(".selection-toolbar").exists()).toBe(false);
await wrapper.find('[data-description="toggle selection button"]').trigger("click");
expect(wrapper.find(".selection-toolbar").exists()).toBe(true);
});
it("cancel button toggles selection mode off", async () => {
const wrapper = mountList();
await wrapper.find('[data-description="toggle selection button"]').trigger("click");
expect(wrapper.find(".selection-toolbar").exists()).toBe(true);
await wrapper.find('[data-description="toggle selection button"]').trigger("click");
expect(wrapper.find(".selection-toolbar").exists()).toBe(false);
});
it("checkboxes visible in selection mode", async () => {
const wrapper = mountList();
expect(wrapper.find(".history-checkbox").exists()).toBe(false);
await wrapper.find('[data-description="toggle selection button"]').trigger("click");
expect(wrapper.findAll(".history-checkbox").length).toBe(3);
});
});
describe("item clicks", () => {
it("emits select when clicking item outside selection mode", async () => {
const wrapper = mountList();
await wrapper.findAll('[data-description="sidebar item"]').at(0)!.trigger("click");
const emitted = wrapper.emitted("select");
expect(emitted).toBeTruthy();
expect(emitted![0]![0]).toEqual(MOCK_ITEMS[0]);
});
it("does NOT emit select when clicking item in selection mode", async () => {
const wrapper = mountList();
await wrapper.find('[data-description="toggle selection button"]').trigger("click");
await wrapper.findAll('[data-description="sidebar item"]').at(0)!.trigger("click");
expect(wrapper.emitted("select")).toBeFalsy();
});
it("toggles item selection when clicking in selection mode", async () => {
const wrapper = mountList();
await wrapper.find('[data-description="toggle selection button"]').trigger("click");
await wrapper.findAll('[data-description="sidebar item"]').at(1)!.trigger("click");
// Delete button should show count
const deleteBtn = wrapper.find('[data-description="delete selected button"]');
expect(deleteBtn.text()).toContain("1");
});
});
describe("select all / deselect all", () => {
it("select all selects all items", async () => {
const wrapper = mountList();
await wrapper.find('[data-description="toggle selection button"]').trigger("click");
await wrapper.find(".select-all-toggle").trigger("click");
const deleteBtn = wrapper.find('[data-description="delete selected button"]');
expect(deleteBtn.text()).toContain("3");
});
it("deselect all clears selection", async () => {
const wrapper = mountList();
await wrapper.find('[data-description="toggle selection button"]').trigger("click");
// Select all
await wrapper.find(".select-all-toggle").trigger("click");
// Deselect all
await wrapper.find(".select-all-toggle").trigger("click");
const deleteBtn = wrapper.find('[data-description="delete selected button"]');
expect((deleteBtn.element as HTMLButtonElement).disabled).toBe(true);
});
});
describe("delete", () => {
it("delete button disabled when nothing selected", async () => {
const wrapper = mountList();
await wrapper.find('[data-description="toggle selection button"]').trigger("click");
const deleteBtn = wrapper.find('[data-description="delete selected button"]');
expect((deleteBtn.element as HTMLButtonElement).disabled).toBe(true);
});
it("delete button emits delete with selected IDs", async () => {
const wrapper = mountList();
await wrapper.find('[data-description="toggle selection button"]').trigger("click");
// Click first and third items
const items = wrapper.findAll('[data-description="sidebar item"]');
await items.at(0)!.trigger("click");
await items.at(2)!.trigger("click");
await wrapper.find('[data-description="delete selected button"]').trigger("click");
const emitted = wrapper.emitted("delete");
expect(emitted).toBeTruthy();
const ids = emitted![0]![0] as string[];
expect(ids).toHaveLength(2);
expect(ids).toContain("enc1");
expect(ids).toContain("enc3");
});
});
describe("shift-click range selection", () => {
it("selects range with shift-click", async () => {
const wrapper = mountList();
await wrapper.find('[data-description="toggle selection button"]').trigger("click");
const items = wrapper.findAll('[data-description="sidebar item"]');
// Click first item normally
await items.at(0)!.trigger("click");
// Shift-click third item
await items.at(2)!.trigger("click", { shiftKey: true });
// All 3 should be selected
const deleteBtn = wrapper.find('[data-description="delete selected button"]');
expect(deleteBtn.text()).toContain("3");
});
});
describe("active exchange highlight", () => {
it("applies active class to matching item when not in selection mode", () => {
const wrapper = mountList({ activeExchangeId: "enc2" });
const items = wrapper.findAll('[data-description="sidebar item"]');
expect(items.at(1)!.classes()).toContain("active");
});
it("does not apply active class in selection mode", async () => {
const wrapper = mountList({ activeExchangeId: "enc2" });
await wrapper.find('[data-description="toggle selection button"]').trigger("click");
const items = wrapper.findAll('[data-description="sidebar item"]');
expect(items.at(1)!.classes()).not.toContain("active");
});
});
});
@@ -1,234 +0,0 @@
<script setup lang="ts">
import { faCheckSquare, faSquare } from "@fortawesome/free-regular-svg-icons";
import { faClock, faComments, faTimes, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BAlert } from "bootstrap-vue";
import { computed, watch } from "vue";
import type { components } from "@/api";
import { useSidebarSelection } from "@/composables/useSidebarSelection";
import SidebarList from "@/components/Common/SidebarList.vue";
import UtcDate from "@/components/UtcDate.vue";
type ChatHistoryItem = components["schemas"]["ChatHistoryItemResponse"];
const props = defineProps<{
items: ChatHistoryItem[];
isLoading: boolean;
error: string | null;
activeExchangeId: string | null;
}>();
const emit = defineEmits<{
(e: "select", item: ChatHistoryItem): void;
(e: "delete", ids: string[]): void;
(e: "dismiss-error"): void;
}>();
const itemsRef = computed(() => props.items);
const {
selectionMode,
selectedIds,
allSelected,
toggleSelectionMode,
toggleSelectAll,
handleSelectionClick,
pruneAfterDelete,
} = useSidebarSelection(itemsRef, (item) => item.id);
function itemKey(item: ChatHistoryItem): string {
return item.id;
}
function itemClass(item: ChatHistoryItem): Record<string, boolean> {
return {
active: !selectionMode.value && item.id === props.activeExchangeId,
selected: selectedIds.value.has(item.id),
};
}
function onSelect(item: ChatHistoryItem, index: number, event: MouseEvent) {
if (handleSelectionClick(item, index, event)) {
return;
}
emit("select", item);
}
function deleteSelected() {
if (selectedIds.value.size === 0) {
return;
}
emit("delete", Array.from(selectedIds.value));
}
// Items are owned by the parent (store) and removed asynchronously after
// the "delete" emit. Prune stale selections once the prop actually updates.
watch(itemsRef, () => {
if (selectionMode.value) {
pruneAfterDelete();
}
});
defineExpose({ selectionMode, toggleSelectionMode });
</script>
<template>
<div class="page-chat-history-list" data-description="page chat history list">
<div class="history-toolbar d-flex align-items-center justify-content-between px-2 py-1 border-bottom">
<span class="toolbar-label">History</span>
<button
class="btn btn-sm"
:class="selectionMode ? 'btn-outline-secondary' : 'btn-outline-danger'"
:title="selectionMode ? 'Cancel selection' : 'Select chats to delete'"
data-description="toggle selection button"
@click="toggleSelectionMode">
<FontAwesomeIcon :icon="selectionMode ? faTimes : faTrash" fixed-width />
</button>
</div>
<div v-if="selectionMode && items.length > 0" class="selection-toolbar">
<!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events vuejs-accessibility/no-static-element-interactions -->
<span class="select-all-toggle" @click="toggleSelectAll">
<FontAwesomeIcon :icon="allSelected ? faCheckSquare : faSquare" fixed-width />
{{ allSelected ? "Deselect all" : "Select all" }}
</span>
<button
class="btn btn-sm btn-danger"
:disabled="selectedIds.size === 0"
data-description="delete selected button"
@click="deleteSelected">
Delete {{ selectedIds.size > 0 ? selectedIds.size : "" }}
</button>
</div>
<BAlert
v-if="props.error"
variant="danger"
dismissible
show
class="m-2 mb-0"
data-description="chat history error"
@dismissed="emit('dismiss-error')">
{{ props.error }}
</BAlert>
<SidebarList
:items="items"
:is-loading="isLoading"
loading-message="Loading history..."
empty-message="No conversations yet."
:item-key="itemKey"
:item-class="itemClass"
@select="onSelect">
<template v-slot:item="{ item }">
<span v-if="selectionMode" class="history-checkbox">
<FontAwesomeIcon :icon="selectedIds.has(item.id) ? faCheckSquare : faSquare" fixed-width />
</span>
<div class="history-content">
<div class="history-query">{{ item.query }}</div>
<div class="history-meta">
<span v-if="item.message_count" class="history-messages">
<FontAwesomeIcon :icon="faComments" fixed-width />
{{ item.message_count }}
</span>
<span v-if="item.timestamp" class="history-time">
<FontAwesomeIcon :icon="faClock" fixed-width />
<UtcDate :date="item.timestamp" mode="elapsed" />
</span>
</div>
</div>
</template>
</SidebarList>
</div>
</template>
<style scoped>
.page-chat-history-list {
max-height: 100%;
overflow-y: auto;
}
.history-toolbar {
font-size: 0.75rem;
font-weight: 600;
}
.toolbar-label {
text-transform: uppercase;
letter-spacing: 0.025em;
color: var(--text-muted, #6c757d);
}
.selection-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.375rem 0.5rem;
border-bottom: 1px solid var(--border-color, #dee2e6);
font-size: 0.75rem;
}
.select-all-toggle {
cursor: pointer;
color: var(--text-muted, #6c757d);
display: flex;
align-items: center;
gap: 0.25rem;
}
.select-all-toggle:hover {
color: var(--text-color, #212529);
}
:deep(.sidebar-item.active) {
background: rgba(var(--brand-primary-rgb, 0, 123, 255), 0.08);
border-left: 3px solid var(--brand-primary, #007bff);
padding-left: calc(0.5rem - 3px);
}
:deep(.sidebar-item.selected) {
background: rgba(var(--brand-primary-rgb, 0, 123, 255), 0.06);
}
.history-checkbox {
flex-shrink: 0;
color: var(--text-muted, #6c757d);
padding-top: 0.1rem;
}
.history-content {
flex: 1;
min-width: 0;
}
.history-query {
font-size: 0.8rem;
color: var(--text-color, #212529);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 0.2rem;
}
.history-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.7rem;
color: var(--text-muted, #6c757d);
}
.history-messages {
display: flex;
align-items: center;
gap: 0.2rem;
}
.history-time {
display: flex;
align-items: center;
gap: 0.2rem;
}
</style>
@@ -1,385 +0,0 @@
import { createTestingPinia } from "@pinia/testing";
import { getLocalVue } from "@tests/vitest/helpers";
import { mount } from "@vue/test-utils";
import flushPromises from "flush-promises";
import type { Pinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { usePageEditorStore } from "@/stores/pageEditorStore";
import PageChatPanel from "./PageChatPanel.vue";
import ChatInput from "@/components/GalaxyAI/ChatInput.vue";
import ChatMessageCell from "@/components/GalaxyAI/ChatMessageCell.vue";
// Mock GalaxyApi
const mockPOST = vi.fn();
const mockGET = vi.fn();
const mockPUT = vi.fn();
vi.mock("@/api", () => ({
GalaxyApi: () => ({
POST: mockPOST,
GET: mockGET,
PUT: mockPUT,
}),
}));
vi.mock("vue-router/composables", () => ({
useRouter: vi.fn(() => ({ push: vi.fn() })),
useRoute: vi.fn(() => ({ params: {} })),
}));
const localVue = getLocalVue();
const HISTORY_ID = "history-1";
const PAGE_ID = "page-1";
const PAGE_CONTENT = "# Intro\nSome intro text\n# Methods\nSome methods text";
let pinia: Pinia;
function mountComponent(
propsData: { historyId?: string; pageId: string; pageContent: string } = {
historyId: HISTORY_ID,
pageId: PAGE_ID,
pageContent: PAGE_CONTENT,
},
) {
return mount(PageChatPanel as any, {
localVue,
propsData,
pinia,
stubs: {
FontAwesomeIcon: true,
BSkeleton: true,
LoadingSpan: true,
ChatMessageCell: true,
ProposalDiffView: true,
SectionPatchView: true,
},
});
}
describe("PageChatPanel", () => {
beforeEach(() => {
pinia = createTestingPinia({ createSpy: vi.fn });
vi.clearAllMocks();
// Default: no page chat history
mockGET.mockResolvedValue({ data: [], error: null });
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("Initial render", () => {
it("renders chat panel container", async () => {
const wrapper = mountComponent();
await flushPromises();
expect(wrapper.find('[data-description="page chat panel"]').exists()).toBe(true);
});
it("renders header with Markdown Assistant title", async () => {
const wrapper = mountComponent();
await flushPromises();
expect(wrapper.find(".chat-panel-header").text()).toContain("Markdown Assistant");
});
it("renders ChatInput component", async () => {
const wrapper = mountComponent();
await flushPromises();
expect(wrapper.findComponent(ChatInput).exists()).toBe(true);
});
it("shows welcome message when no history", async () => {
const wrapper = mountComponent();
await flushPromises();
const cells = wrapper.findAllComponents(ChatMessageCell);
expect(cells.length).toBe(1);
});
it("renders new conversation button", async () => {
const wrapper = mountComponent();
await flushPromises();
expect(wrapper.find('[data-description="new conversation button"]').exists()).toBe(true);
});
});
describe("Message submission", () => {
it("sends query to API on submit", async () => {
mockPOST.mockResolvedValue({
data: {
response: "I can help with that.",
exchange_id: 42,
agent_response: {
agent_type: "page_assistant",
confidence: "high",
suggestions: [],
metadata: {},
},
},
error: null,
});
const wrapper = mountComponent();
await flushPromises();
// Use ChatInput's v-model: set value via the textarea + trigger submit
const textarea = wrapper.find("textarea");
await textarea.setValue("Rewrite the intro section");
await wrapper.find(".send-button").trigger("click");
await flushPromises();
expect(mockPOST).toHaveBeenCalledWith(
"/api/chat",
expect.objectContaining({
params: { query: { agent_type: "page_assistant" } },
}),
);
});
it("adds user and assistant messages after submit", async () => {
mockPOST.mockResolvedValue({
data: {
response: "Here is a rewrite.",
exchange_id: 42,
agent_response: {
agent_type: "page_assistant",
confidence: "high",
suggestions: [],
metadata: {},
},
},
error: null,
});
const wrapper = mountComponent();
await flushPromises();
const textarea = wrapper.find("textarea");
await textarea.setValue("Rewrite intro");
await wrapper.find(".send-button").trigger("click");
await flushPromises();
const cells = wrapper.findAllComponents(ChatMessageCell);
// Welcome + user + assistant = 3
expect(cells.length).toBe(3);
});
it("shows error message on API failure", async () => {
mockPOST.mockResolvedValue({
data: null,
error: { err_msg: "Server error" },
});
const wrapper = mountComponent();
await flushPromises();
const textarea = wrapper.find("textarea");
await textarea.setValue("test query");
await wrapper.find(".send-button").trigger("click");
await flushPromises();
const cells = wrapper.findAllComponents(ChatMessageCell);
const lastCell = cells.at(cells.length - 1);
expect(lastCell.props("message").content).toContain("Error");
});
it("sets exchange_id from response for follow-up queries", async () => {
mockPOST.mockResolvedValue({
data: { response: "Done.", exchange_id: 99, agent_response: null },
error: null,
});
const wrapper = mountComponent();
await flushPromises();
const textarea = wrapper.find("textarea");
await textarea.setValue("hello");
await wrapper.find(".send-button").trigger("click");
await flushPromises();
// Subsequent request should include the exchange_id
mockPOST.mockResolvedValue({
data: { response: "Follow up.", exchange_id: 99, agent_response: null },
error: null,
});
await textarea.setValue("follow up");
await wrapper.find(".send-button").trigger("click");
await flushPromises();
const lastCall = mockPOST.mock.calls[mockPOST.mock.calls.length - 1]!;
expect(lastCall[1].body.exchange_id).toBe(99);
});
});
describe("New conversation", () => {
it("resets messages on new conversation click", async () => {
mockPOST.mockResolvedValue({
data: { response: "Test", exchange_id: 1, agent_response: null },
error: null,
});
const wrapper = mountComponent();
await flushPromises();
const textarea = wrapper.find("textarea");
await textarea.setValue("hello");
await wrapper.find(".send-button").trigger("click");
await flushPromises();
// Click new conversation
await wrapper.find('[data-description="new conversation button"]').trigger("click");
await flushPromises();
const cells = wrapper.findAllComponents(ChatMessageCell);
expect(cells.length).toBe(1);
});
});
describe("Loading state", () => {
it("shows loading indicator when busy", async () => {
// Make POST never resolve to keep busy=true
mockPOST.mockReturnValue(new Promise(() => {}));
const wrapper = mountComponent();
await flushPromises();
const textarea = wrapper.find("textarea");
await textarea.setValue("hello");
await wrapper.find(".send-button").trigger("click");
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-description="chat loading indicator"]').exists()).toBe(true);
});
});
describe("Store integration", () => {
it("does not call store methods on mount", async () => {
const store = usePageEditorStore();
mountComponent();
await flushPromises();
expect(store.updateContent).not.toHaveBeenCalled();
expect(store.savePage).not.toHaveBeenCalled();
});
});
describe("Chat persistence", () => {
it("stores exchange ID in store after first submit", async () => {
mockPOST.mockResolvedValue({
data: { response: "Done.", exchange_id: 77, agent_response: null },
error: null,
});
const wrapper = mountComponent();
await flushPromises();
const store = usePageEditorStore();
const textarea = wrapper.find("textarea");
await textarea.setValue("hello");
await wrapper.find(".send-button").trigger("click");
await flushPromises();
expect(store.setCurrentChatExchangeId).toHaveBeenCalledWith(PAGE_ID, 77);
});
it("clears stored exchange ID on new conversation", async () => {
const wrapper = mountComponent();
await flushPromises();
const store = usePageEditorStore();
await wrapper.find('[data-description="new conversation button"]').trigger("click");
await flushPromises();
expect(store.setCurrentChatExchangeId).toHaveBeenCalledWith(PAGE_ID, null);
});
it("checks store for cached exchange ID on mount", async () => {
const store = usePageEditorStore();
vi.mocked(store.getCurrentChatExchangeId).mockReturnValue("abc55");
// Mock the exchange messages load
mockGET.mockResolvedValue({
data: [
{ role: "user", content: "hello", timestamp: null },
{ role: "assistant", content: "hi", agent_type: "page_assistant", timestamp: null },
],
error: null,
});
const wrapper = mountComponent();
await flushPromises();
expect(store.getCurrentChatExchangeId).toHaveBeenCalledWith(PAGE_ID);
// Should have loaded 2 messages from the cached conversation
const cells = wrapper.findAllComponents(ChatMessageCell);
expect(cells.length).toBe(2);
});
it("falls back to API when stored exchange ID returns empty messages", async () => {
const store = usePageEditorStore();
vi.mocked(store.getCurrentChatExchangeId).mockReturnValue("abc999");
// All GET calls return empty — stored exchange has no messages, no page history
mockGET.mockResolvedValue({ data: [], error: null });
const wrapper = mountComponent();
await flushPromises();
// Stored ID yielded nothing, API fallback yielded nothing — welcome message shown
const cells = wrapper.findAllComponents(ChatMessageCell);
expect(cells.length).toBe(1);
});
});
describe("Standalone page mode (no historyId)", () => {
function mountStandalone() {
return mountComponent({ pageId: PAGE_ID, pageContent: PAGE_CONTENT });
}
it("renders header with Markdown Assistant title", async () => {
const wrapper = mountStandalone();
await flushPromises();
expect(wrapper.find(".chat-panel-header").text()).toContain("Markdown Assistant");
});
it("shows Markdown Assistant welcome message", async () => {
const wrapper = mountStandalone();
await flushPromises();
const cells = wrapper.findAllComponents(ChatMessageCell);
expect(cells.length).toBe(1);
// The welcome message should mention "Markdown Assistant"
expect(cells.at(0).props("message").content).toContain("Markdown Assistant");
});
it("sends query to API without historyId", async () => {
mockPOST.mockResolvedValue({
data: {
response: "I can help with that.",
exchange_id: 42,
agent_response: null,
},
error: null,
});
const wrapper = mountStandalone();
await flushPromises();
const textarea = wrapper.find("textarea");
await textarea.setValue("Rewrite the intro");
await wrapper.find(".send-button").trigger("click");
await flushPromises();
expect(mockPOST).toHaveBeenCalledWith(
"/api/chat",
expect.objectContaining({
params: { query: { agent_type: "page_assistant" } },
}),
);
});
it("renders Markdown Assistant header when historyId is provided", async () => {
const wrapper = mountComponent();
await flushPromises();
expect(wrapper.find(".chat-panel-header").text()).toContain("Markdown Assistant");
});
});
});
@@ -1,570 +0,0 @@
<script setup lang="ts">
/**
* Page-specific chat panel.
* Talks to the page_assistant agent, shows diff views for edit proposals,
* and applies accepted edits to the page via the store.
*/
import { faBook, faHistory, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BAlert, BButton, BSkeleton } from "bootstrap-vue";
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { GalaxyApi } from "@/api";
import type { ChatMessage } from "@/components/GalaxyAI/chatTypes";
import { generateId, scrollToBottom } from "@/components/GalaxyAI/chatUtils";
import { PAGE_LABELS } from "@/components/Page/constants";
import { type AgentResponse, type EditProposal, useAgentActions } from "@/composables/agentActions";
import { useMarkdown } from "@/composables/markdown";
import { type PageEditorMode, usePageEditorStore } from "@/stores/pageEditorStore";
import { errorMessageAsString } from "@/utils/simple-error";
import { applySectionEdit } from "./sectionDiffUtils";
import PageChatHistoryList from "./PageChatHistoryList.vue";
import ProposalDiffView from "./ProposalDiffView.vue";
import SectionPatchView from "./SectionPatchView.vue";
import ChatInput from "@/components/GalaxyAI/ChatInput.vue";
import ChatMessageCell from "@/components/GalaxyAI/ChatMessageCell.vue";
import LoadingSpan from "@/components/LoadingSpan.vue";
const props = withDefaults(
defineProps<{
historyId?: string;
pageId: string;
pageContent: string;
}>(),
{ historyId: "" },
);
const isHistoryAttached = computed(() => !!props.historyId);
const chatMode = computed<PageEditorMode>(() => (isHistoryAttached.value ? "history" : "standalone"));
const chatLabels = computed(() => PAGE_LABELS[chatMode.value]);
const assistantName = computed(() => chatLabels.value.assistantName);
const AGENT_TYPE = "page_assistant";
const store = usePageEditorStore();
const { renderMarkdown } = useMarkdown({ openLinksInNewPage: true, removeNewlinesAfterList: true });
const { processingAction, handleAction } = useAgentActions();
const query = ref("");
const messages = ref<ChatMessage[]>([]);
const busy = ref(false);
const chatContainer = ref<HTMLElement>();
const currentChatId = ref<string | null>(null);
const dismissedProposals = ref(new Set<string>());
onMounted(async () => {
await loadPageChat();
if (messages.value.length === 0) {
messages.value.push({
id: generateId(),
role: "assistant",
content: chatLabels.value.assistantWelcome,
timestamp: new Date(),
agentType: AGENT_TYPE,
confidence: "high",
feedback: null,
isSystemMessage: true,
});
}
});
async function loadPageChat() {
store.chatError = null;
// Try store-cached exchange ID first (avoids API round-trip on panel reopen)
const storedExchangeId = store.getCurrentChatExchangeId(props.pageId);
if (storedExchangeId !== null) {
try {
await loadConversation(storedExchangeId);
if (messages.value.length > 0) {
return;
}
} catch (e) {
console.warn("Failed to load cached chat exchange:", e);
store.clearCurrentChatExchangeId(props.pageId);
}
}
// Fall back to API
try {
const { data, error } = await GalaxyApi().GET("/api/chat/page/{page_id}/history", {
params: { path: { page_id: props.pageId }, query: { limit: 1 } },
});
if (data && !error && data.length > 0) {
await loadConversation(data[0]!.id);
}
} catch (e) {
console.warn("Failed to load page chat history:", e);
store.chatError = errorMessageAsString(e);
}
}
async function loadConversation(exchangeId: string) {
const { data } = await GalaxyApi().GET("/api/chat/exchange/{exchange_id}/messages", {
params: { path: { exchange_id: exchangeId } },
});
if (data && data.length > 0) {
messages.value = data.map((msg: any, index: number) => {
const m: ChatMessage = {
id: `hist-${msg.role}-${exchangeId}-${index}`,
role: msg.role,
content: msg.content,
timestamp: msg.timestamp ? new Date(msg.timestamp) : new Date(),
feedback: null,
};
if (msg.role === "assistant") {
m.agentType = msg.agent_type || AGENT_TYPE;
m.confidence = msg.agent_response?.confidence || "medium";
m.feedback = msg.feedback === 1 ? "up" : msg.feedback === 0 ? "down" : null;
if (msg.agent_response) {
m.agentResponse = msg.agent_response;
m.suggestions = msg.agent_response.suggestions || [];
}
}
return m;
});
currentChatId.value = exchangeId;
store.setCurrentChatExchangeId(props.pageId, exchangeId);
dismissedProposals.value = new Set(store.getDismissedProposals(props.pageId));
}
}
async function submitQuery() {
if (!query.value.trim()) {
return;
}
const userMessage: ChatMessage = {
id: generateId(),
role: "user",
content: query.value,
timestamp: new Date(),
feedback: null,
};
messages.value.push(userMessage);
const currentQuery = query.value;
query.value = "";
await nextTick();
scrollToBottom(chatContainer.value);
busy.value = true;
const wasNewExchange = currentChatId.value === null;
try {
const { data, error } = await GalaxyApi().POST("/api/chat", {
params: { query: { agent_type: AGENT_TYPE } },
body: {
query: currentQuery,
context: null,
exchange_id: currentChatId.value,
page_id: props.pageId,
},
});
if (error) {
const errStr = errorMessageAsString(error, "Failed to get response.");
messages.value.push({
id: generateId(),
role: "assistant",
content: `Error: ${errStr}`,
timestamp: new Date(),
agentType: AGENT_TYPE,
confidence: "low",
feedback: null,
});
} else if (data) {
if (data.exchange_id) {
currentChatId.value = data.exchange_id;
store.setCurrentChatExchangeId(props.pageId, data.exchange_id);
}
if (wasNewExchange) {
store.loadPageChatHistory(props.pageId);
}
const agentResponse = data.agent_response as AgentResponse | undefined;
messages.value.push({
id: generateId(),
role: "assistant",
content: data.response || "No response received",
timestamp: new Date(),
agentType: agentResponse?.agent_type || AGENT_TYPE,
confidence: agentResponse?.confidence || "medium",
feedback: null,
agentResponse: agentResponse,
suggestions: agentResponse?.suggestions || [],
});
}
} catch (e) {
messages.value.push({
id: generateId(),
role: "assistant",
content: "Unexpected error occurred. Please try again.",
timestamp: new Date(),
agentType: AGENT_TYPE,
confidence: "low",
feedback: null,
});
} finally {
busy.value = false;
await nextTick();
scrollToBottom(chatContainer.value);
}
}
watch(busy, (isBusy) => {
if (isBusy) {
nextTick(() => scrollToBottom(chatContainer.value));
}
});
async function sendFeedback(messageId: string, value: "up" | "down") {
const message = messages.value.find((m) => m.id === messageId);
if (!message || !currentChatId.value) {
return;
}
message.feedback = value;
try {
const feedbackValue = value === "up" ? 1 : 0;
const { error } = await GalaxyApi().PUT("/api/chat/exchange/{exchange_id}/feedback", {
params: { path: { exchange_id: currentChatId.value } },
body: feedbackValue,
});
if (error) {
message.feedback = null;
}
} catch {
message.feedback = null;
}
}
function djb2Hash(s: string): string {
let h = 5381;
for (let i = 0; i < s.length; i++) {
h = (h * 33 + s.charCodeAt(i)) >>> 0;
}
return h.toString(16).padStart(8, "0");
}
function isProposalStale(msg: ChatMessage): boolean {
const meta = msg.agentResponse?.metadata;
const originalHash = meta?.original_content_hash as string | undefined;
if (!originalHash) {
return false;
}
return originalHash !== djb2Hash(props.pageContent);
}
function getEditProposal(msg: ChatMessage): EditProposal | null {
const meta = msg.agentResponse?.metadata;
const editMode = meta?.edit_mode as EditProposal["mode"] | undefined;
if (!editMode) {
return null;
}
return {
mode: editMode,
content: (meta?.content as string) || (meta?.new_section_content as string) || "",
target_section_heading: meta?.target_section_heading as string | undefined,
new_section_content: meta?.new_section_content as string | undefined,
};
}
function isProposalVisible(msg: ChatMessage): boolean {
if (dismissedProposals.value.has(msg.id)) {
return false;
}
const proposal = getEditProposal(msg);
if (!proposal) {
return false;
}
// Content-based: if full_replacement content matches current doc, already applied
if (proposal.mode === "full_replacement" && proposal.content === props.pageContent) {
return false;
}
return true;
}
function getProposalMode(msg: ChatMessage): string {
return getEditProposal(msg)?.mode || "";
}
function buildProposedContent(msg: ChatMessage): string {
const proposal = getEditProposal(msg);
if (!proposal) {
return props.pageContent;
}
if (proposal.mode === "full_replacement") {
return proposal.content;
}
// section_patch: reconstruct full doc by replacing the target section
return applySectionEdit(
props.pageContent,
proposal.target_section_heading || "",
proposal.new_section_content || proposal.content,
);
}
async function applyFullReplacement(msg: ChatMessage) {
const proposal = getEditProposal(msg);
if (!proposal) {
return;
}
store.updateContent(proposal.content);
await store.savePage("agent");
dismissedProposals.value.add(msg.id);
store.addDismissedProposal(props.pageId, msg.id);
}
async function applySectionPatched(patchedContent: string, msg: ChatMessage) {
store.updateContent(patchedContent);
await store.savePage("agent");
dismissedProposals.value.add(msg.id);
store.addDismissedProposal(props.pageId, msg.id);
}
function dismissProposal(msg: ChatMessage) {
dismissedProposals.value.add(msg.id);
store.addDismissedProposal(props.pageId, msg.id);
}
function toggleHistory() {
store.toggleChatHistory();
if (store.showChatHistory) {
store.loadPageChatHistory(props.pageId);
}
}
async function handleHistorySelect(item: { id: string }) {
try {
await loadConversation(item.id);
store.showChatHistory = false;
await nextTick();
scrollToBottom(chatContainer.value);
} catch (e) {
store.chatHistoryError = errorMessageAsString(e);
}
}
function resetToNewChat() {
messages.value = [
{
id: generateId(),
role: "assistant",
content: chatLabels.value.newConversation,
timestamp: new Date(),
agentType: AGENT_TYPE,
confidence: "high",
feedback: null,
isSystemMessage: true,
},
];
currentChatId.value = null;
store.setCurrentChatExchangeId(props.pageId, null);
query.value = "";
dismissedProposals.value = new Set();
store.clearDismissedProposals(props.pageId);
}
async function handleHistoryDelete(ids: string[]) {
try {
await store.deletePageChatExchanges(props.pageId, ids);
if (currentChatId.value && ids.includes(currentChatId.value)) {
resetToNewChat();
}
} catch (e) {
store.chatHistoryError = errorMessageAsString(e);
}
}
async function deleteCurrentExchange() {
if (!currentChatId.value) {
return;
}
try {
await store.deletePageChatExchanges(props.pageId, [currentChatId.value]);
resetToNewChat();
} catch (e) {
store.chatError = errorMessageAsString(e);
}
}
function startNewConversation() {
resetToNewChat();
}
</script>
<template>
<div class="page-chat-panel d-flex flex-column h-100" data-description="page chat panel">
<div class="chat-panel-header d-flex align-items-center justify-content-between p-2 border-bottom">
<span class="d-flex align-items-center gap-2">
<FontAwesomeIcon :icon="faBook" fixed-width />
<strong>{{ assistantName }}</strong>
</span>
<span class="d-flex align-items-center gap-1">
<BButton
variant="outline-secondary"
size="sm"
:pressed="store.showChatHistory"
data-description="chat history button"
@click="toggleHistory">
<FontAwesomeIcon :icon="faHistory" fixed-width />
</BButton>
<BButton
v-if="currentChatId"
variant="outline-danger"
size="sm"
title="Delete current conversation"
data-description="delete conversation button"
@click="deleteCurrentExchange">
<FontAwesomeIcon :icon="faTrash" fixed-width />
</BButton>
<BButton
variant="outline-primary"
size="sm"
data-description="new conversation button"
@click="startNewConversation">
New Chat
</BButton>
</span>
</div>
<div class="chat-body d-flex flex-grow-1" style="min-height: 0">
<div ref="chatContainer" class="chat-panel-messages flex-grow-1 overflow-auto">
<BAlert
v-if="store.chatError"
variant="danger"
dismissible
show
data-description="chat error alert"
@dismissed="store.chatError = null">
{{ store.chatError }}
</BAlert>
<ChatMessageCell
v-for="msg in messages"
:key="msg.id"
:message="msg"
:render-markdown="renderMarkdown"
:processing-action="processingAction"
@feedback="sendFeedback"
@handle-action="handleAction">
<template v-if="isProposalVisible(msg)" v-slot:after-content>
<ProposalDiffView
v-if="getProposalMode(msg) === 'full_replacement'"
:original="pageContent"
:proposed="buildProposedContent(msg)"
:stale="isProposalStale(msg)"
@accept="applyFullReplacement(msg)"
@reject="dismissProposal(msg)" />
<SectionPatchView
v-else-if="getProposalMode(msg) === 'section_patch'"
:original="pageContent"
:proposed="buildProposedContent(msg)"
:stale="isProposalStale(msg)"
@accept="applySectionPatched($event, msg)"
@reject="dismissProposal(msg)" />
</template>
</ChatMessageCell>
<div v-if="busy" class="loading-cell" data-description="chat loading indicator">
<div class="cell-label">
<FontAwesomeIcon :icon="faBook" fixed-width />
<span>{{ assistantName }}</span>
</div>
<div class="cell-content">
<BSkeleton animation="wave" width="85%" />
<BSkeleton animation="wave" width="55%" />
<BSkeleton animation="wave" width="70%" />
</div>
</div>
<div v-if="!busy && messages.length === 0" class="text-muted text-center p-4">
<LoadingSpan message="Loading conversation..." />
</div>
</div>
<PageChatHistoryList
v-if="store.showChatHistory"
class="chat-history-sidebar border-left"
:items="store.pageChatHistory"
:is-loading="store.isLoadingChatHistory"
:error="store.chatHistoryError"
:active-exchange-id="currentChatId"
data-description="chat history sidebar"
@select="handleHistorySelect"
@delete="handleHistoryDelete"
@dismiss-error="store.chatHistoryError = null" />
</div>
<div class="chat-panel-footer p-2 border-top">
<ChatInput v-model="query" :busy="busy" :placeholder="chatLabels.chatPlaceholder" @submit="submitQuery" />
</div>
</div>
</template>
<style scoped>
.page-chat-panel {
background: var(--body-bg, #fff);
min-width: 0;
}
.chat-panel-header {
background: var(--panel-header-bg, #f8f9fa);
font-size: 0.85rem;
}
.chat-panel-header .gap-2 {
gap: 0.5rem;
}
.chat-panel-messages {
padding: 0.75rem;
min-width: 0;
}
.chat-history-sidebar {
width: 220px;
flex-shrink: 0;
overflow-y: auto;
}
.chat-panel-footer {
background: var(--panel-header-bg, #f8f9fa);
}
.loading-cell {
margin-bottom: 1rem;
opacity: 0.7;
}
.loading-cell .cell-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
margin-bottom: 0.375rem;
padding-left: 0.25rem;
color: var(--text-muted, #6c757d);
}
.loading-cell .cell-content {
border-left: 3px solid var(--brand-secondary, #6c757d);
background: var(--panel-bg-color, #f8f9fa);
padding: 0.75rem 1rem;
border-radius: 4px;
}
</style>
@@ -0,0 +1,46 @@
<script setup lang="ts">
import type { PAGE_LABELS } from "@/components/Page/constants";
import type { MarkdownConfig } from "../Markdown/types.js";
import Markdown from "../Markdown/Markdown.vue";
import PageDisplayToolbar from "./PageDisplayToolbar.vue";
const props = defineProps<{
labels: (typeof PAGE_LABELS)[keyof typeof PAGE_LABELS];
markdownConfig?: MarkdownConfig;
hideHeader?: boolean;
}>();
const emit = defineEmits<{
(e: "edit"): void;
(e: "back"): void;
}>();
</script>
<template>
<div class="d-flex flex-column">
<PageDisplayToolbar
v-if="!props.hideHeader"
:labels="props.labels"
mode="display"
@edit="emit('edit')"
@back="emit('back')" />
<div class="page-display-content" data-description="page rendered view">
<Markdown
v-if="markdownConfig"
class="px-3 pt-3"
:markdown-config="markdownConfig"
read-only
no-heading
download-endpoint="" />
</div>
</div>
</template>
<style scoped>
.page-display-content {
overflow: auto;
flex: 1 1 0;
}
</style>
@@ -0,0 +1,225 @@
import { createTestingPinia } from "@pinia/testing";
import { getLocalVue } from "@tests/vitest/helpers";
import { mount, type Wrapper } from "@vue/test-utils";
import flushPromises from "flush-promises";
import type { Pinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { HistoryPageDetails, PageRevisionSummary } from "@/api/pages.js";
import { usePageEditorStore } from "@/stores/pageEditorStore";
import { PAGE_LABELS } from "../Page/constants.js";
import PageDisplayToolbar from "./PageDisplayToolbar.vue";
const localVue = getLocalVue();
const HISTORY_ID = "history-1";
const PAGE_ID = "page-1";
const SELECTORS = {
EDITOR_TOOLBAR: "[data-description='page editor toolbar']",
DISPLAY_TOOLBAR: "[data-description='page display toolbar']",
TOOLBAR_TITLE: "[data-description='page editor title']",
SAVE_BUTTON: "[data-description='page save button']",
BACK_BUTTON: "[data-description='page back button']",
UNSAVED_INDICATOR: "[data-description='page unsaved indicator']",
REVISIONS_BUTTON: "[data-description='page revisions button']",
REVISIONS_BADGE: "[data-description='page revision count badge']",
EDIT_BUTTON: "[data-description='page edit button']",
RENAME_BUTTON: "[data-description='page rename button']",
RENAME_INPUT: "[data-description='galaxy notebook name input']",
PREVIEW_BUTTON: "[data-description='page preview button']",
} as const;
let pinia: Pinia;
function mountComponent(propsData: {
labels: (typeof PAGE_LABELS)[keyof typeof PAGE_LABELS];
mode: "editor" | "display";
}) {
return mount(PageDisplayToolbar as object, {
localVue,
propsData,
pinia,
});
}
describe("PageDisplayToolbar", () => {
function setupLoadedPage() {
const newStore = usePageEditorStore();
newStore.isLoadingList = false;
newStore.isLoadingPage = false;
newStore.error = null;
newStore.currentPage = {
id: PAGE_ID,
history_id: HISTORY_ID,
title: "My Page",
content: "# Hello",
update_time: "2024-01-01T00:00:00",
} as HistoryPageDetails;
newStore.currentContent = "# Hello";
newStore.currentTitle = "My Page";
newStore.revisions = [
{ id: "rev-1", page_id: PAGE_ID, edit_source: "user", create_time: "", update_time: "" },
{ id: "rev-2", page_id: PAGE_ID, edit_source: "user", create_time: "", update_time: "" },
] as PageRevisionSummary[];
return newStore;
}
let wrapper: Wrapper<Vue>;
let store: ReturnType<typeof usePageEditorStore>;
beforeEach(async () => {
pinia = createTestingPinia({ createSpy: vi.fn });
store = setupLoadedPage();
await flushPromises();
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("editor mode", () => {
beforeEach(async () => {
wrapper = mountComponent({ labels: PAGE_LABELS.history, mode: "editor" });
await flushPromises();
});
it("shows edit toolbar with Edit button pressed", async () => {
expect(wrapper.find(SELECTORS.EDITOR_TOOLBAR).exists()).toBe(true);
expect(wrapper.find(SELECTORS.EDIT_BUTTON).props("pressed")).toBe(true);
expect(wrapper.find(SELECTORS.PREVIEW_BUTTON).props("pressed")).toBe(false);
});
it("shows rename button and page title in toolbar", () => {
expect(wrapper.find(SELECTORS.RENAME_BUTTON).exists()).toBe(true);
expect(wrapper.find(SELECTORS.TOOLBAR_TITLE).text()).toBe("My Page");
});
it("shows 'Untitled Notebook' in title when currentTitle is empty", async () => {
store.currentTitle = "";
await wrapper.vm.$nextTick();
expect(wrapper.find(SELECTORS.TOOLBAR_TITLE).text()).toBe("Untitled Notebook");
});
it("shows 'Unsaved' indicator when store.isDirty is true", async () => {
await wrapper.vm.$nextTick();
expect(store.isDirty).toBe(true);
const unsaved = wrapper.find(SELECTORS.UNSAVED_INDICATOR);
expect(unsaved.exists()).toBe(true);
expect(unsaved.text()).toBe("Unsaved");
});
it("save button is disabled when store.canSave is false", async () => {
store.currentContent = "";
store.currentTitle = "";
await wrapper.vm.$nextTick();
expect(store.canSave).toBe(false);
const saveBtn = wrapper.find(SELECTORS.SAVE_BUTTON);
expect(saveBtn.attributes("aria-disabled")).toBe("true");
});
it("shows Preview button in toolbar", () => {
const previewBtn = wrapper.find(SELECTORS.PREVIEW_BUTTON);
expect(previewBtn.exists()).toBe(true);
expect(previewBtn.text()).toContain("Preview");
});
it("Preview button emits a preview event", async () => {
const previewBtn = wrapper.find(SELECTORS.PREVIEW_BUTTON);
await previewBtn.trigger("click");
expect(wrapper.emitted("preview")).toHaveLength(1);
});
it("rename button opens rename modal which renames the page", async () => {
expect(wrapper.find(SELECTORS.RENAME_BUTTON).exists()).toBe(true);
const renameBtn = wrapper.find(SELECTORS.RENAME_BUTTON);
await renameBtn.trigger("click");
await wrapper.vm.$nextTick();
const renameInput = wrapper.find(SELECTORS.RENAME_INPUT);
expect(renameInput.exists()).toBe(true);
expect((renameInput.element as HTMLInputElement).value).toBe("My Page");
(renameInput.element as HTMLInputElement).value = "Renamed Page";
await renameInput.trigger("input");
const renameBtnInModal = wrapper.find(".g-modal-confirm-buttons .g-blue");
await renameBtnInModal.trigger("click");
await wrapper.vm.$nextTick();
expect(store.updateTitle).toHaveBeenCalledWith("Renamed Page");
});
it("back button emits a back event", async () => {
const backBtn = wrapper.find(SELECTORS.BACK_BUTTON);
await backBtn.trigger("click");
expect(wrapper.emitted("back")).toHaveLength(1);
});
it("save button calls store.savePage", async () => {
const saveBtn = wrapper.find(SELECTORS.SAVE_BUTTON);
await saveBtn.trigger("click");
await flushPromises();
expect(store.savePage).toHaveBeenCalled();
});
it("back button text says whatever the label back button value is", () => {
const backBtn = wrapper.find(SELECTORS.BACK_BUTTON);
expect(backBtn.text()).toContain(PAGE_LABELS.history.editorBackLabel);
});
describe("revisions", () => {
it("shows Revisions button in toolbar", () => {
const revBtn = wrapper.find(SELECTORS.REVISIONS_BUTTON);
expect(revBtn.exists()).toBe(true);
expect(revBtn.text()).toContain("Revisions");
});
it("clicking Revisions button calls store.toggleRevisions", async () => {
const revBtn = wrapper.find(SELECTORS.REVISIONS_BUTTON);
await revBtn.trigger("click");
expect(store.toggleRevisions).toHaveBeenCalled();
});
it("revision badge shows count when revisions loaded", () => {
const badge = wrapper.find(SELECTORS.REVISIONS_BADGE);
expect(badge.exists()).toBe(true);
expect(badge.text()).toBe("2");
});
});
});
describe("display mode", () => {
beforeEach(async () => {
wrapper = mountComponent({ labels: PAGE_LABELS.history, mode: "display" });
await flushPromises();
});
it("shows display toolbar with Preview button pressed", async () => {
expect(wrapper.find(SELECTORS.DISPLAY_TOOLBAR).exists()).toBe(true);
expect(wrapper.find(SELECTORS.EDIT_BUTTON).props("pressed")).toBe(false);
expect(wrapper.find(SELECTORS.PREVIEW_BUTTON).props("pressed")).toBe(true);
});
it("Edit button emits an edit event", async () => {
const editBtn = wrapper.find(SELECTORS.EDIT_BUTTON);
await editBtn.trigger("click");
await wrapper.vm.$nextTick();
expect(wrapper.emitted("edit")).toHaveLength(1);
});
});
});
@@ -0,0 +1,190 @@
<script setup lang="ts">
import {
faArrowLeft,
faEdit,
faEye,
faHistory,
faInfo,
faPen,
faSave,
faSpinner,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BBadge } from "bootstrap-vue";
import { storeToRefs } from "pinia";
import { computed, ref } from "vue";
import type { PAGE_LABELS } from "@/components/Page/constants";
import { usePageEditorStore } from "@/stores/pageEditorStore.js";
import GButton from "../BaseComponents/GButton.vue";
import GButtonGroup from "../BaseComponents/GButtonGroup.vue";
import RenameModal from "../Common/RenameModal.vue";
const props = defineProps<{
labels: (typeof PAGE_LABELS)[keyof typeof PAGE_LABELS];
mode: "editor" | "display";
}>();
const pageEditorStore = usePageEditorStore();
const { canSave, currentPage, currentTitle, revisionCount, showRevisions, isDirty, isSaving } =
storeToRefs(pageEditorStore);
const emit = defineEmits<{
(e: "back"): void;
(e: "edit"): void;
(e: "preview"): void;
}>();
const showRename = ref(false);
const currentEntity = computed(() => {
return props.labels.entityName.toLowerCase();
});
const saveDisabledTitle = computed(() => {
if (props.mode === "display") {
return `Currently previewing this ${currentEntity.value}. Click 'Edit' to make changes and enable saving.`;
}
if (isSaving.value) {
return `This ${currentEntity.value} is being saved`;
}
if (!canSave.value) {
return `There are no changes to save to this ${currentEntity.value}`;
}
return "";
});
async function handleSave() {
await pageEditorStore.savePage();
}
function handleTitleChange(newTitle: string): Promise<void> {
return new Promise((resolve) => {
pageEditorStore.updateTitle(newTitle);
resolve();
});
}
</script>
<template>
<div class="page-display-toolbar border-bottom" :data-description="`page ${props.mode} toolbar`">
<div class="d-flex align-items-center flex-gapx-1 title-section">
<FontAwesomeIcon :icon="props.labels.titleIcon" fixed-width />
<b class="page-title" :data-description="`page ${props.mode} title`">
{{ currentTitle || props.labels.defaultTitle }}
</b>
<GButton
v-if="props.mode === 'editor'"
inline
transparent
data-description="page rename button"
:title="`Rename this ${currentEntity}`"
@click="showRename = true">
<FontAwesomeIcon :icon="faPen" />
</GButton>
</div>
<div class="d-flex align-items-center flex-gapx-1 flex-shrink-0">
<span
v-if="isDirty"
class="d-flex align-items-center flex-gapx-1 text-warning small"
data-description="page unsaved indicator">
<BBadge variant="warning" class="text-light">
<FontAwesomeIcon :icon="faInfo" />
</BBadge>
Unsaved
</span>
<GButton color="blue" transparent size="small" data-description="page back button" @click="emit('back')">
<FontAwesomeIcon :icon="faArrowLeft" />
{{ props.labels.editorBackLabel }}
</GButton>
<slot name="extra-actions" />
<GButton
v-if="props.mode === 'editor'"
color="blue"
outline
:pressed="showRevisions"
data-description="page revisions button"
size="small"
@click="pageEditorStore.toggleRevisions">
<FontAwesomeIcon :icon="faHistory" />
Revisions
<BBadge
v-if="revisionCount > 0"
variant="light"
style="top: 0"
data-description="page revision count badge">
{{ revisionCount }}
</BBadge>
</GButton>
<GButtonGroup data-description="revision view mode toggle">
<GButton
color="blue"
outline
:pressed="props.mode === 'editor'"
data-description="page edit button"
:disabled="currentPage?.deleted"
size="small"
@click="emit('edit')">
<FontAwesomeIcon :icon="faEdit" />
Edit
</GButton>
<GButton
color="blue"
outline
:pressed="props.mode === 'display'"
data-description="page preview button"
size="small"
@click="emit('preview')">
<FontAwesomeIcon :icon="faEye" />
Preview
</GButton>
</GButtonGroup>
<GButton
color="blue"
data-description="page save button"
:title="`Save ${props.labels.entityName}`"
tooltip
:disabled="props.mode === 'display' || !canSave"
:disabled-title="saveDisabledTitle"
size="small"
@click="handleSave">
<FontAwesomeIcon :icon="isSaving ? faSpinner : faSave" :spin="isSaving" />
Save
</GButton>
</div>
<RenameModal
v-if="showRename"
:item-type="currentEntity"
:name="currentTitle || props.labels.defaultTitle"
:rename-action="handleTitleChange"
@close="showRename = false" />
</div>
</template>
<style scoped>
.page-display-toolbar {
background: var(--color-grey-100);
display: flex;
align-items: center;
padding: 0.5rem 1rem;
gap: 0.5rem;
justify-content: space-between;
}
.title-section {
min-width: 0;
overflow: hidden;
}
.page-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -1,5 +1,5 @@
<template>
<PageEditorView :page-id="pageId" :display-only="displayOnly" />
<PageEditorView :page-id="pageId" :display-only="displayOnly" :hide-header="hideHeader" />
</template>
<script setup lang="ts">
@@ -8,5 +8,6 @@ import PageEditorView from "./PageEditorView.vue";
defineProps<{
pageId: string;
displayOnly?: boolean;
hideHeader?: boolean;
}>();
</script>
@@ -10,13 +10,12 @@ import { ref } from "vue";
import type { HistoryPageDetails, PageRevisionDetails, PageRevisionSummary } from "@/api/pages";
import { usePageEditorStore } from "@/stores/pageEditorStore";
import PageChatPanel from "./PageChatPanel.vue";
import GModal from "../BaseComponents/GModal.vue";
import PageDisplayOnly from "./PageDisplayOnly.vue";
import PageDisplayToolbar from "./PageDisplayToolbar.vue";
import PageEditorView from "./PageEditorView.vue";
import PageRevisionList from "./PageRevisionList.vue";
import PageRevisionView from "./PageRevisionView.vue";
import ClickToEdit from "@/components/ClickToEdit.vue";
import SplitView from "@/components/Common/SplitView.vue";
import Markdown from "@/components/Markdown/Markdown.vue";
import MarkdownEditor from "@/components/Markdown/MarkdownEditor.vue";
const mockConfig = ref<Record<string, unknown> | null>({ llm_api_configured: true });
@@ -49,6 +48,18 @@ vi.mock("@/stores/historyStore", () => ({
})),
}));
vi.mock("@/stores/userStore", () => ({
useUserStore: vi.fn(() => ({
matchesCurrentUsername: vi.fn((username: string) => username === "test-owner"),
})),
}));
vi.mock("@/composables/confirmDialog.js", () => ({
useConfirmDialog: vi.fn(() => ({
confirm: vi.fn().mockResolvedValue(true),
})),
}));
const mockGalaxyInstance = { frame: { active: false } };
vi.mock("@/app", () => ({
getGalaxyInstance: vi.fn(() => mockGalaxyInstance),
@@ -59,22 +70,6 @@ const localVue = getLocalVue();
const HISTORY_ID = "history-1";
const PAGE_ID = "page-1";
const SELECTORS = {
TOOLBAR: "[data-description='page editor toolbar']",
TOOLBAR_TITLE: "[data-description='page editor title']",
SAVE_BUTTON: "[data-description='page save button']",
BACK_BUTTON: "[data-description='page back button']",
UNSAVED_INDICATOR: "[data-description='page unsaved indicator']",
REVISIONS_BUTTON: "[data-description='page revisions button']",
REVISION_PANEL: ".page-revision-panel",
CHAT_BUTTON: "[data-description='page chat button']",
PREVIEW_BUTTON: "[data-description='page preview button']",
PERMISSIONS_BUTTON: "[data-description='page permissions button']",
SAVE_VIEW_BUTTON: "[data-description='page save-view button']",
EDIT_BUTTON: "[data-description='page edit button']",
DISPLAY_TOOLBAR: "[data-description='page display toolbar']",
};
let pinia: Pinia;
function mountComponent(propsData: { pageId: string; historyId?: string; displayOnly?: boolean }) {
@@ -96,9 +91,11 @@ function setupLoadedPage(historyId?: string) {
title: "My Page",
content: "# Hello",
update_time: "2024-01-01T00:00:00",
username: "test-owner",
} as Partial<HistoryPageDetails> as HistoryPageDetails;
store.currentContent = "# Hello";
store.currentTitle = "My Page";
vi.spyOn(store, "hasCurrentPage", "get").mockReturnValue(true);
return store;
}
@@ -124,43 +121,10 @@ describe("PageEditorView", () => {
});
it("shows toolbar and MarkdownEditor when page is loaded", () => {
expect(wrapper.find(SELECTORS.TOOLBAR).exists()).toBe(true);
expect(wrapper.findComponent(PageDisplayToolbar).exists()).toBe(true);
expect(wrapper.findComponent(MarkdownEditor).exists()).toBe(true);
});
it("shows ClickToEdit with page title in toolbar", () => {
const clickToEdit = wrapper.findComponent(ClickToEdit);
expect(clickToEdit.exists()).toBe(true);
expect(clickToEdit.props("value")).toBe("My Page");
});
it("shows 'Untitled Notebook' in ClickToEdit when currentTitle is empty", async () => {
store.currentTitle = "";
await wrapper.vm.$nextTick();
const clickToEdit = wrapper.findComponent(ClickToEdit);
expect(clickToEdit.props("value")).toBe("Untitled Notebook");
});
it("shows 'Unsaved' indicator when store.isDirty is true", async () => {
await wrapper.vm.$nextTick();
expect(store.isDirty).toBe(true);
const unsaved = wrapper.find(SELECTORS.UNSAVED_INDICATOR);
expect(unsaved.exists()).toBe(true);
expect(unsaved.text()).toBe("Unsaved");
});
it("save button is disabled when store.canSave is false", async () => {
store.currentContent = "";
store.currentTitle = "";
await wrapper.vm.$nextTick();
expect(store.canSave).toBe(false);
const saveBtn = wrapper.find(SELECTORS.SAVE_BUTTON);
expect(saveBtn.attributes("disabled")).toBe("true");
});
it("passes content to MarkdownEditor", () => {
const editor = wrapper.findComponent(MarkdownEditor);
expect(editor.props("markdownText")).toBe("# Hello");
@@ -171,50 +135,23 @@ describe("PageEditorView", () => {
expect(editor.props("mode")).toBe("page");
});
it("shows Preview button in toolbar", () => {
const previewBtn = wrapper.find(SELECTORS.PREVIEW_BUTTON);
expect(previewBtn.exists()).toBe(true);
expect(previewBtn.text()).toContain("Preview");
});
it("Preview button navigates to displayOnly mode", async () => {
const previewBtn = wrapper.find(SELECTORS.PREVIEW_BUTTON);
await previewBtn.trigger("click");
wrapper.findComponent(PageDisplayToolbar).vm.$emit("preview");
expect(mockPush).toHaveBeenCalledWith(`/histories/${HISTORY_ID}/pages/${PAGE_ID}?displayOnly=true`);
});
it("ClickToEdit input updates store title", async () => {
const clickToEdit = wrapper.findComponent(ClickToEdit);
clickToEdit.vm.$emit("input", "Renamed Page");
await wrapper.vm.$nextTick();
expect(store.updateTitle).toHaveBeenCalledWith("Renamed Page");
});
it("back button navigates to history pages list", async () => {
const backBtn = wrapper.find(SELECTORS.BACK_BUTTON);
await backBtn.trigger("click");
wrapper.findComponent(PageDisplayToolbar).vm.$emit("back");
expect(store.clearCurrentPage).toHaveBeenCalled();
expect(mockPush).toHaveBeenCalledWith(`/histories/${HISTORY_ID}/pages`);
});
it("save button calls store.savePage", async () => {
const saveBtn = wrapper.find(SELECTORS.SAVE_BUTTON);
await saveBtn.trigger("click");
await flushPromises();
expect(store.savePage).toHaveBeenCalled();
});
it("hides permissions button in history mode", () => {
expect(wrapper.find(SELECTORS.PERMISSIONS_BUTTON).exists()).toBe(false);
});
it("hides save & view button in history mode", () => {
expect(wrapper.find(SELECTORS.SAVE_VIEW_BUTTON).exists()).toBe(false);
});
// TODO: We won't have a Save & View but will implement a save changes or ignore modal
// it("hides save & view button in history mode", () => {
// expect(wrapper.find(SELECTORS.SAVE_VIEW_BUTTON).exists()).toBe(false);
// });
});
describe("Editor view (standalone mode)", () => {
@@ -227,7 +164,7 @@ describe("PageEditorView", () => {
});
it("shows toolbar and MarkdownEditor", () => {
expect(wrapper.find(SELECTORS.TOOLBAR).exists()).toBe(true);
expect(wrapper.findComponent(PageDisplayToolbar).exists()).toBe(true);
expect(wrapper.findComponent(MarkdownEditor).exists()).toBe(true);
});
@@ -236,100 +173,81 @@ describe("PageEditorView", () => {
expect(editor.props("mode")).toBe("page");
});
it("shows permissions button in standalone mode", () => {
expect(wrapper.find(SELECTORS.PERMISSIONS_BUTTON).exists()).toBe(true);
});
it("shows save & view button in standalone mode", () => {
expect(wrapper.find(SELECTORS.SAVE_VIEW_BUTTON).exists()).toBe(true);
});
// TODO: We won't have a Save & View but will implement a save changes or ignore modal
// it("shows save & view button in standalone mode", () => {
// expect(wrapper.find(SELECTORS.SAVE_VIEW_BUTTON).exists()).toBe(true);
// });
it("back button navigates to pages list", async () => {
const backBtn = wrapper.find(SELECTORS.BACK_BUTTON);
await backBtn.trigger("click");
wrapper.findComponent(PageDisplayToolbar).vm.$emit("back");
expect(mockPush).toHaveBeenCalledWith("/pages/list");
});
it("back button text says 'Back to Reports'", () => {
const backBtn = wrapper.find(SELECTORS.BACK_BUTTON);
expect(backBtn.text()).toContain("Back to Reports");
});
// TODO: We won't have a Save & View but will implement a save changes or ignore modal
// it("Save & View navigates to published page when WM inactive", async () => {
// const store = usePageEditorStore();
// store.currentPage = {
// id: PAGE_ID,
// history_id: null,
// title: "My Page",
// content: "# Hello",
// update_time: "2024-01-01T00:00:00",
// username: "testuser",
// slug: "my-page",
// } as Partial<HistoryPageDetails> as HistoryPageDetails;
// store.currentContent = "modified";
// store.currentTitle = "My Page";
it("Save & View navigates to published page when WM inactive", async () => {
const store = usePageEditorStore();
store.currentPage = {
id: PAGE_ID,
history_id: null,
title: "My Page",
content: "# Hello",
update_time: "2024-01-01T00:00:00",
username: "testuser",
slug: "my-page",
} as Partial<HistoryPageDetails> as HistoryPageDetails;
store.currentContent = "modified";
store.currentTitle = "My Page";
// const saveViewBtn = wrapper.find(SELECTORS.SAVE_VIEW_BUTTON);
// // Mock location.href assignment
// const hrefSpy = vi.spyOn(window, "location", "get").mockReturnValue({
// ...window.location,
// href: "",
// } as unknown as Location);
// await saveViewBtn.trigger("click");
// await flushPromises();
const saveViewBtn = wrapper.find(SELECTORS.SAVE_VIEW_BUTTON);
// Mock location.href assignment
const hrefSpy = vi.spyOn(window, "location", "get").mockReturnValue({
...window.location,
href: "",
} as unknown as Location);
await saveViewBtn.trigger("click");
await flushPromises();
// expect(store.savePage).toHaveBeenCalled();
// hrefSpy.mockRestore();
// });
expect(store.savePage).toHaveBeenCalled();
hrefSpy.mockRestore();
});
// it("Save & View uses router.push when WM is active", async () => {
// mockGalaxyInstance.frame.active = true;
// const store = usePageEditorStore();
// store.currentPage = {
// id: PAGE_ID,
// history_id: null,
// title: "My Page",
// content: "# Hello",
// update_time: "2024-01-01T00:00:00",
// } as Partial<HistoryPageDetails> as HistoryPageDetails;
// store.currentContent = "modified";
// store.currentTitle = "My Page";
it("Save & View uses router.push when WM is active", async () => {
mockGalaxyInstance.frame.active = true;
const store = usePageEditorStore();
store.currentPage = {
id: PAGE_ID,
history_id: null,
title: "My Page",
content: "# Hello",
update_time: "2024-01-01T00:00:00",
} as Partial<HistoryPageDetails> as HistoryPageDetails;
store.currentContent = "modified";
store.currentTitle = "My Page";
// const saveViewBtn = wrapper.find(SELECTORS.SAVE_VIEW_BUTTON);
// await saveViewBtn.trigger("click");
// await flushPromises();
const saveViewBtn = wrapper.find(SELECTORS.SAVE_VIEW_BUTTON);
await saveViewBtn.trigger("click");
await flushPromises();
expect(store.savePage).toHaveBeenCalled();
expect(mockPush).toHaveBeenCalledWith(
`/published/page?id=${PAGE_ID}&embed=true`,
expect.objectContaining({
title: "Report: My Page",
preventWindowManager: false,
}),
);
mockGalaxyInstance.frame.active = false;
});
// expect(store.savePage).toHaveBeenCalled();
// expect(mockPush).toHaveBeenCalledWith(
// `/published/page?id=${PAGE_ID}&embed=true`,
// expect.objectContaining({
// title: "Report: My Page",
// preventWindowManager: false,
// }),
// );
// mockGalaxyInstance.frame.active = false;
// });
});
describe("DisplayOnly mode", () => {
it("renders Markdown when displayOnly is true", async () => {
it("renders PageDisplayOnly when displayOnly is true", async () => {
setupLoadedPage(HISTORY_ID);
const wrapper = mountComponent({ pageId: PAGE_ID, historyId: HISTORY_ID, displayOnly: true });
await flushPromises();
expect(wrapper.findComponent(Markdown).exists()).toBe(true);
expect(wrapper.find(SELECTORS.TOOLBAR).exists()).toBe(false);
});
it("shows display toolbar with Edit button", async () => {
setupLoadedPage(HISTORY_ID);
const wrapper = mountComponent({ pageId: PAGE_ID, historyId: HISTORY_ID, displayOnly: true });
await flushPromises();
expect(wrapper.find(SELECTORS.DISPLAY_TOOLBAR).exists()).toBe(true);
const editBtn = wrapper.find(SELECTORS.EDIT_BUTTON);
expect(editBtn.exists()).toBe(true);
expect(wrapper.findComponent(PageDisplayOnly).exists()).toBe(true);
expect(wrapper.findComponent(PageDisplayToolbar).exists()).toBe(false);
});
it("does not clear editor state on unmount in displayOnly mode", async () => {
@@ -345,44 +263,22 @@ describe("PageEditorView", () => {
});
describe("Revision UI", () => {
it("shows Revisions button in toolbar", async () => {
setupLoadedPage(HISTORY_ID);
const wrapper = mountComponent({ pageId: PAGE_ID, historyId: HISTORY_ID });
await flushPromises();
const revBtn = wrapper.find(SELECTORS.REVISIONS_BUTTON);
expect(revBtn.exists()).toBe(true);
expect(revBtn.text()).toContain("Revisions");
});
it("clicking Revisions button calls store.toggleRevisions", async () => {
const store = setupLoadedPage(HISTORY_ID);
const wrapper = mountComponent({ pageId: PAGE_ID, historyId: HISTORY_ID });
await flushPromises();
const revBtn = wrapper.find(SELECTORS.REVISIONS_BUTTON);
await revBtn.trigger("click");
expect(store.toggleRevisions).toHaveBeenCalled();
});
it("shows revision panel when store.showRevisions is true", async () => {
it("shows revision list in a modal when store.showRevisions is true", async () => {
const store = setupLoadedPage(HISTORY_ID);
store.showRevisions = true;
store.revisions = [] as PageRevisionSummary[];
const wrapper = mountComponent({ pageId: PAGE_ID, historyId: HISTORY_ID });
await flushPromises();
expect(wrapper.find(SELECTORS.REVISION_PANEL).exists()).toBe(true);
expect(wrapper.findComponent(PageRevisionList).exists()).toBe(true);
expect(wrapper.findComponent(GModal).props("show")).toBe(true);
});
it("hides revision panel when store.showRevisions is false", async () => {
it("hides revision modal when store.showRevisions is false", async () => {
setupLoadedPage(HISTORY_ID);
const wrapper = mountComponent({ pageId: PAGE_ID, historyId: HISTORY_ID });
await flushPromises();
expect(wrapper.find(SELECTORS.REVISION_PANEL).exists()).toBe(false);
expect(wrapper.findComponent(GModal).props("show")).toBe(false);
});
it("shows PageRevisionView when selectedRevision is set", async () => {
@@ -401,21 +297,7 @@ describe("PageEditorView", () => {
await flushPromises();
expect(wrapper.findComponent(PageRevisionView).exists()).toBe(true);
expect(wrapper.find(SELECTORS.TOOLBAR).exists()).toBe(false);
});
it("revision badge shows count when revisions loaded", async () => {
const store = setupLoadedPage(HISTORY_ID);
store.revisions = [
{ id: "rev-1", page_id: PAGE_ID, edit_source: "user", create_time: "", update_time: "" },
{ id: "rev-2", page_id: PAGE_ID, edit_source: "user", create_time: "", update_time: "" },
] as PageRevisionSummary[];
const wrapper = mountComponent({ pageId: PAGE_ID, historyId: HISTORY_ID });
await flushPromises();
const badge = wrapper.find(SELECTORS.REVISIONS_BUTTON + " bbadge-stub");
expect(badge.exists()).toBe(true);
expect(badge.text()).toBe("2");
expect(wrapper.findComponent(MarkdownEditor).exists()).toBe(false);
});
it("PageRevisionView back emits clearSelectedRevision", async () => {
@@ -462,7 +344,7 @@ describe("PageEditorView", () => {
expect(store.restoreRevision).toHaveBeenCalledWith("rev-1");
});
it("revision panel select calls store.loadRevision", async () => {
it("revision list select calls store.loadRevision", async () => {
const store = setupLoadedPage(HISTORY_ID);
store.showRevisions = true;
store.revisions = [
@@ -480,41 +362,7 @@ describe("PageEditorView", () => {
});
describe("Revision UI (standalone mode)", () => {
it("shows Revisions button in standalone mode", async () => {
setupLoadedPage();
const wrapper = mountComponent({ pageId: PAGE_ID });
await flushPromises();
const revBtn = wrapper.find(SELECTORS.REVISIONS_BUTTON);
expect(revBtn.exists()).toBe(true);
expect(revBtn.text()).toContain("Revisions");
});
it("clicking Revisions button calls store.toggleRevisions in standalone mode", async () => {
const store = setupLoadedPage();
const wrapper = mountComponent({ pageId: PAGE_ID });
await flushPromises();
const revBtn = wrapper.find(SELECTORS.REVISIONS_BUTTON);
await revBtn.trigger("click");
expect(store.toggleRevisions).toHaveBeenCalled();
});
it("shows revision panel when store.showRevisions is true in standalone mode", async () => {
const store = setupLoadedPage();
store.showRevisions = true;
store.revisions = [
{ id: "rev-1", page_id: PAGE_ID, edit_source: "user", create_time: "", update_time: "" },
] as PageRevisionSummary[];
const wrapper = mountComponent({ pageId: PAGE_ID });
await flushPromises();
expect(wrapper.find(SELECTORS.REVISION_PANEL).exists()).toBe(true);
expect(wrapper.findComponent(PageRevisionList).exists()).toBe(true);
});
it("revision panel restore calls store.restoreRevision in standalone mode", async () => {
it("revision list restore calls store.restoreRevision in standalone mode", async () => {
const store = setupLoadedPage();
store.showRevisions = true;
store.revisions = [
@@ -531,110 +379,6 @@ describe("PageEditorView", () => {
});
});
describe("Chat Panel", () => {
it("shows Chat button in toolbar", async () => {
setupLoadedPage(HISTORY_ID);
const wrapper = mountComponent({ pageId: PAGE_ID, historyId: HISTORY_ID });
await flushPromises();
const chatBtn = wrapper.find(SELECTORS.CHAT_BUTTON);
expect(chatBtn.exists()).toBe(true);
expect(chatBtn.text()).toContain("Chat");
});
it("clicking Chat button calls store.toggleChatPanel", async () => {
const store = setupLoadedPage(HISTORY_ID);
const wrapper = mountComponent({ pageId: PAGE_ID, historyId: HISTORY_ID });
await flushPromises();
const chatBtn = wrapper.find(SELECTORS.CHAT_BUTTON);
await chatBtn.trigger("click");
await flushPromises();
expect(store.toggleChatPanel).toHaveBeenCalled();
});
it("renders split view when store.showChatPanel is true", async () => {
const store = setupLoadedPage(HISTORY_ID);
store.showChatPanel = true;
const wrapper = mountComponent({ pageId: PAGE_ID, historyId: HISTORY_ID });
await flushPromises();
expect(wrapper.findComponent(SplitView).exists()).toBe(true);
expect(wrapper.findComponent(PageChatPanel).exists()).toBe(true);
});
it("hides split view when store.showChatPanel is false", async () => {
const store = setupLoadedPage(HISTORY_ID);
store.showChatPanel = false;
const wrapper = mountComponent({ pageId: PAGE_ID, historyId: HISTORY_ID });
await flushPromises();
expect(wrapper.findComponent(SplitView).exists()).toBe(false);
});
it("hides Chat button when llm_api_configured is false", async () => {
setupLoadedPage(HISTORY_ID);
mockConfig.value = { llm_api_configured: false };
const wrapper = mountComponent({ pageId: PAGE_ID, historyId: HISTORY_ID });
await flushPromises();
expect(wrapper.find(SELECTORS.CHAT_BUTTON).exists()).toBe(false);
});
it("shows Chat button when llm_api_configured is true", async () => {
setupLoadedPage(HISTORY_ID);
mockConfig.value = { llm_api_configured: true };
const wrapper = mountComponent({ pageId: PAGE_ID, historyId: HISTORY_ID });
await flushPromises();
expect(wrapper.find(SELECTORS.CHAT_BUTTON).exists()).toBe(true);
});
it("hides Chat button when config is null (not yet loaded)", async () => {
setupLoadedPage(HISTORY_ID);
mockConfig.value = null;
const wrapper = mountComponent({ pageId: PAGE_ID, historyId: HISTORY_ID });
await flushPromises();
expect(wrapper.find(SELECTORS.CHAT_BUTTON).exists()).toBe(false);
});
it("passes hideToolbox=true to MarkdownEditor when chat is open", async () => {
const store = setupLoadedPage(HISTORY_ID);
store.showChatPanel = true;
const wrapper = mountComponent({ pageId: PAGE_ID, historyId: HISTORY_ID });
await flushPromises();
const editor = wrapper.findComponent(MarkdownEditor);
expect(editor.exists()).toBe(true);
expect(editor.props("hideToolbox")).toBe(true);
});
it("does not set hideToolbox when chat is closed", async () => {
const store = setupLoadedPage(HISTORY_ID);
store.showChatPanel = false;
const wrapper = mountComponent({ pageId: PAGE_ID, historyId: HISTORY_ID });
await flushPromises();
const editor = wrapper.findComponent(MarkdownEditor);
expect(editor.exists()).toBe(true);
expect(editor.props("hideToolbox")).toBeFalsy();
});
it("passes props to PageChatPanel", async () => {
const store = setupLoadedPage(HISTORY_ID);
store.showChatPanel = true;
const wrapper = mountComponent({ pageId: PAGE_ID, historyId: HISTORY_ID });
await flushPromises();
const panel = wrapper.findComponent(PageChatPanel);
expect(panel.props("historyId")).toBe(HISTORY_ID);
expect(panel.props("pageId")).toBe(PAGE_ID);
expect(panel.props("pageContent")).toBe("# Hello");
});
});
describe("Lifecycle", () => {
it("calls store.loadPage on mount", async () => {
const store = usePageEditorStore();
@@ -692,7 +436,7 @@ describe("PageEditorView", () => {
const errorAlert = wrapper.find("balert-stub[variant='danger']");
expect(errorAlert.exists()).toBe(true);
expect(errorAlert.text()).toContain("Save failed");
expect(wrapper.find(SELECTORS.TOOLBAR).exists()).toBe(true);
expect(wrapper.findComponent(PageDisplayToolbar).exists()).toBe(true);
expect(wrapper.findComponent(MarkdownEditor).exists()).toBe(true);
});
});
@@ -1,51 +1,46 @@
<script setup lang="ts">
import {
faArrowLeft,
faComments,
faEdit,
faEye,
faHistory,
faSave,
faSpinner,
faUsers,
} from "@fortawesome/free-solid-svg-icons";
import { faCopy, faSpinner, faUsers } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BAlert, BBadge, BButton } from "bootstrap-vue";
import { BAlert } from "bootstrap-vue";
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { useRouter } from "vue-router/composables";
import { getGalaxyInstance } from "@/app";
import type { RouterPushOptions } from "@/components/History/Content/router-push-options";
import { PAGE_LABELS, PERMISSIONS_LABELS } from "@/components/Page/constants";
import { useConfig } from "@/composables/config";
// import { getGalaxyInstance } from "@/app";
// import type { RouterPushOptions } from "@/components/History/Content/router-push-options";
import { PAGE_LABELS } from "@/components/Page/constants";
import { useConfirmDialog } from "@/composables/confirmDialog.js";
import { useWindowAwareNavigation } from "@/composables/windowAwareNavigation";
import { useHistoryStore } from "@/stores/historyStore";
import { type PageEditorMode, usePageEditorStore } from "@/stores/pageEditorStore";
import { useUserStore } from "@/stores/userStore.js";
import GButton from "../BaseComponents/GButton.vue";
import GModal from "../BaseComponents/GModal.vue";
import ObjectPermissionsModal from "./ObjectPermissionsModal.vue";
import PageChatPanel from "./PageChatPanel.vue";
import PageDisplayOnly from "./PageDisplayOnly.vue";
import PageDisplayToolbar from "./PageDisplayToolbar.vue";
import PageRevisionList from "./PageRevisionList.vue";
import PageRevisionView from "./PageRevisionView.vue";
import ClickToEdit from "@/components/ClickToEdit.vue";
import SplitView from "@/components/Common/SplitView.vue";
import Markdown from "@/components/Markdown/Markdown.vue";
import MarkdownEditor from "@/components/Markdown/MarkdownEditor.vue";
const props = defineProps<{
pageId: string;
historyId?: string;
invocationId?: string;
displayOnly?: boolean;
hideHeader?: boolean;
}>();
const { confirm } = useConfirmDialog();
const router = useRouter();
const { pushToFrameOrPage } = useWindowAwareNavigation();
const store = usePageEditorStore();
const historyStore = useHistoryStore();
const { config } = useConfig();
const userStore = useUserStore();
const agentsAvailable = computed(() => !!config.value?.llm_api_configured);
const editorMode = computed<PageEditorMode>(() => (props.historyId ? "history" : "standalone"));
const editorMode = computed<PageEditorMode>(() =>
props.invocationId ? "invocation" : props.historyId ? "history" : "standalone",
);
const isStandalone = computed(() => editorMode.value === "standalone");
const labels = computed(() => PAGE_LABELS[editorMode.value]);
@@ -53,7 +48,7 @@ const labels = computed(() => PAGE_LABELS[editorMode.value]);
const editorTitle = computed(() => {
if (props.historyId) {
const history = historyStore.getHistoryById(props.historyId);
return history?.name || labels.value.entityName;
return `History: ${history?.name}` || labels.value.entityName;
}
return store.currentTitle || labels.value.defaultTitle;
});
@@ -74,12 +69,14 @@ const markdownConfig = computed(() => {
};
});
const isOwnedPage = computed(() => userStore.matchesCurrentUsername(store.currentPage?.username));
const showPermissions = ref(false);
onMounted(async () => {
store.mode = editorMode.value;
if (props.historyId) {
store.historyId = props.historyId;
store.setCurrentContext(props.historyId);
}
await store.loadPage(props.pageId);
});
@@ -104,71 +101,105 @@ watch(
function handleBack() {
store.clearCurrentPage();
if (props.historyId) {
if (props.invocationId) {
router.push(`/workflows/invocations/${props.invocationId}/reports`);
} else if (props.historyId) {
router.push(`/histories/${props.historyId}/pages`);
} else {
router.push("/pages/list");
}
}
/** Open basic md display in window manager when active, else navigate inline according to context */
function handlePreview() {
if (props.historyId) {
router.push(`/histories/${props.historyId}/pages/${props.pageId}?displayOnly=true`);
const framedUrl = `/pages/editor?id=${props.pageId}&displayOnly=true&hideHeader=true`;
let inlineUrl: string;
if (props.invocationId) {
inlineUrl = `/workflows/invocations/${props.invocationId}/reports?id=${props.pageId}`;
} else if (props.historyId) {
inlineUrl = `/histories/${props.historyId}/pages/${props.pageId}?displayOnly=true`;
} else {
// Standalone: open in window manager when active, else navigate inline.
const previewUrl = `/pages/editor?id=${props.pageId}&displayOnly=true`;
pushToFrameOrPage({
framedUrl: previewUrl,
inlineUrl: previewUrl,
title: `${labels.value.entityName}: ${store.currentTitle || labels.value.defaultTitle}`,
inlineUrl = `/pages/editor?id=${props.pageId}&displayOnly=true`;
}
pushToFrameOrPage({
framedUrl,
inlineUrl,
title: `${labels.value.entityName}: ${store.currentTitle || labels.value.defaultTitle}`,
});
}
async function handleEdit() {
let editingPageId: string | undefined = props.pageId;
if (!isOwnedPage.value) {
const entity = labels.value.entityName;
const confirmed = await confirm(
`You are not the owner of this ${entity}. To edit it, a copy with its contents, owned by you, will be created. Do you want to proceed?`,
{
title: `Copy this ${entity}?`,
okText: `Copy ${entity}`,
okIcon: faCopy,
},
);
if (!confirmed) {
return;
}
const copiedPage = await store.createPage({
title: store.currentTitle ? `Copy of "${store.currentTitle}"` : labels.value.defaultTitle,
content: store.currentContent,
});
editingPageId = copiedPage?.id;
}
}
function handleEdit() {
if (props.historyId) {
router.push(`/histories/${props.historyId}/pages/${props.pageId}`);
} else {
router.push(`/pages/editor?id=${props.pageId}`);
}
}
async function handleSave() {
await store.savePage();
}
async function handleSaveAndView() {
await store.savePage();
if (store.currentPage) {
const Galaxy = getGalaxyInstance();
const isWmActive = Galaxy?.frame?.active;
if (isWmActive) {
const url = `/published/page?id=${props.pageId}&embed=true`;
const options: RouterPushOptions = {
title: `${labels.value.entityName}: ${store.currentTitle || labels.value.defaultTitle}`,
preventWindowManager: false,
};
// @ts-ignore - monkeypatched router
router.push(url, options);
if (editingPageId) {
if (props.historyId) {
router.push(`/histories/${props.historyId}/pages/${editingPageId}`);
} else {
const data = store.currentPage as any;
if (data.username && data.slug) {
window.location.href = `/u/${data.username}/p/${data.slug}`;
}
router.push(`/pages/editor?id=${editingPageId}`);
}
}
}
function handleTitleChange(newTitle: string) {
store.updateTitle(newTitle);
}
// TODO: Uncomment when router guards with unsaved changes protection are implemented
// Before, we had a Save & View button that is now removed.
// async function handleSaveAndView() {
// await store.savePage();
// if (props.invocationId) {
// router.push(`/workflows/invocations/${props.invocationId}/reports?id=${props.pageId}`);
// return;
// }
// if (store.currentPage) {
// const Galaxy = getGalaxyInstance();
// const isWmActive = Galaxy?.frame?.active;
// if (isWmActive) {
// const url = `/published/page?id=${props.pageId}&embed=true`;
// const options: RouterPushOptions = {
// title: `${labels.value.entityName}: ${store.currentTitle || labels.value.defaultTitle}`,
// preventWindowManager: false,
// };
// // @ts-ignore - monkeypatched router
// router.push(url, options);
// } else {
// const data = store.currentPage as any;
// if (data.username && data.slug) {
// window.location.href = `/u/${data.username}/p/${data.slug}`;
// }
// }
// }
// }
function handleContentUpdate(newContent: string) {
store.updateContent(newContent);
}
function handleRevisionSelect(revisionId: string) {
store.loadRevision(revisionId);
async function handleRevisionSelect(revisionId: string) {
await store.loadRevision(revisionId);
store.showRevisions = false;
}
function handleRevisionRestore(revisionId: string) {
@@ -188,166 +219,77 @@ function handleRevisionRestore(revisionId: string) {
</BAlert>
<!-- Display-only mode: rendered view -->
<template v-else-if="store.hasCurrentPage && displayOnly">
<div
class="page-display-toolbar d-flex align-items-center p-2 border-bottom"
data-description="page display toolbar">
<BButton variant="link" size="sm" data-description="page back button" @click="handleBack">
<FontAwesomeIcon :icon="faArrowLeft" />
{{ labels.editorBackLabel }}
</BButton>
<span class="flex-grow-1 text-center font-weight-bold">
{{ store.currentTitle || labels.defaultTitle }}
</span>
<BButton variant="outline-primary" size="sm" data-description="page edit button" @click="handleEdit">
<FontAwesomeIcon :icon="faEdit" />
Edit
</BButton>
</div>
<div class="page-display-content overflow-auto flex-grow-1" data-description="page rendered view">
<Markdown
v-if="markdownConfig"
:markdown-config="markdownConfig"
:read-only="true"
download-endpoint="" />
</div>
</template>
<!-- Viewing a specific revision -->
<template v-else-if="store.hasCurrentPage && store.selectedRevision">
<PageRevisionView
:revision="store.selectedRevision"
:current-content="store.currentContent"
:previous-content="store.previousRevisionContent"
:is-newest-revision="store.isNewestRevision"
:is-oldest-revision="store.isOldestRevision"
:view-mode="store.revisionViewMode"
:is-reverting="store.isReverting"
@back="store.clearSelectedRevision"
@restore="handleRevisionRestore"
@update:viewMode="store.revisionViewMode = $event" />
</template>
<PageDisplayOnly
v-else-if="store.hasCurrentPage && (displayOnly || !isOwnedPage || store.currentPage?.deleted)"
:labels="labels"
:markdown-config="markdownConfig || undefined"
:hide-header="props.hideHeader"
@back="handleBack"
@edit="handleEdit" />
<!-- Edit mode: toolbar + editor + optional chat/revision panels -->
<template v-else-if="store.hasCurrentPage">
<div
class="page-toolbar d-flex align-items-center p-2 border-bottom"
data-description="page editor toolbar">
<BButton variant="link" size="sm" data-description="page back button" @click="handleBack">
<FontAwesomeIcon :icon="faArrowLeft" />
{{ labels.editorBackLabel }}
</BButton>
<ClickToEdit
:value="store.currentTitle || labels.defaultTitle"
tag-name="span"
:placeholder="labels.defaultTitle"
class="flex-grow-1 text-center font-weight-bold"
data-description="page editor title"
@input="handleTitleChange" />
<BButton
variant="outline-primary"
size="sm"
class="mr-2"
data-description="page revisions button"
@click="store.toggleRevisions">
<FontAwesomeIcon :icon="faHistory" />
Revisions
<BBadge v-if="store.revisionCount > 0" variant="light" class="ml-1">
{{ store.revisionCount }}
</BBadge>
</BButton>
<BButton
variant="outline-primary"
size="sm"
class="mr-2"
data-description="page preview button"
@click="handlePreview">
<FontAwesomeIcon :icon="faEye" />
Preview
</BButton>
<BButton
v-if="agentsAvailable"
:variant="store.showChatPanel ? 'primary' : 'outline-primary'"
size="sm"
class="mr-2"
data-description="page chat button"
@click="store.toggleChatPanel">
<FontAwesomeIcon :icon="faComments" />
Chat
</BButton>
<template v-if="isStandalone">
<PageDisplayToolbar :labels="labels" mode="editor" @preview="handlePreview" @back="handleBack">
<template v-if="isStandalone" v-slot:extra-actions>
<ObjectPermissionsModal
id="object-permissions-modal"
v-model="showPermissions"
:markdown-content="store.currentContent" />
<BButton
<GButton
v-b-modal:object-permissions-modal
variant="outline-primary"
size="sm"
class="mr-2"
data-description="page permissions button">
color="blue"
outline
size="small"
data-description="page permissions button"
@click="showPermissions = true">
<FontAwesomeIcon :icon="faUsers" />
Permissions
</BButton>
</GButton>
</template>
<BButton
variant="primary"
size="sm"
:class="{ 'mr-2': isStandalone }"
data-description="page save button"
:disabled="!store.canSave"
@click="handleSave">
<FontAwesomeIcon :icon="store.isSaving ? faSpinner : faSave" :spin="store.isSaving" />
Save
</BButton>
<BButton
v-if="isStandalone"
variant="primary"
size="sm"
data-description="page save-view button"
:disabled="!store.canSave"
@click="handleSaveAndView">
<FontAwesomeIcon :icon="faEye" />
Save &amp; View
</BButton>
<span v-if="store.isDirty" class="ml-2 text-warning small" data-description="page unsaved indicator">
Unsaved
</span>
</div>
</PageDisplayToolbar>
<div class="page-body d-flex flex-grow-1 overflow-hidden">
<SplitView v-if="store.showChatPanel">
<template v-slot:left>
<div class="page-editor-pane">
<MarkdownEditor
:markdown-text="store.currentContent"
:mode="markdownEditorMode"
:title="editorTitle"
:hide-toolbox="true"
@update="handleContentUpdate" />
<div class="page-content flex-grow-1 overflow-auto">
<PageRevisionView
v-if="store.selectedRevision"
:revision="store.selectedRevision"
:current-content="store.currentContent"
:previous-content="store.previousRevisionContent"
:is-newest-revision="store.isNewestRevision"
:is-oldest-revision="store.isOldestRevision"
:view-mode="store.revisionViewMode"
:is-reverting="store.isReverting"
@back="store.clearSelectedRevision"
@restore="handleRevisionRestore"
@update:viewMode="store.revisionViewMode = $event" />
<MarkdownEditor
v-else
class="h-100"
:markdown-text="store.currentContent"
:mode="markdownEditorMode"
:title="editorTitle"
@update="handleContentUpdate" />
</div>
<GModal
data-description="page revisions modal"
fixed-height
:show.sync="store.showRevisions"
size="small"
:title="`${labels.entityName} Revisions`">
<template v-slot:header>
<div class="d-flex align-items-center flex-gapx-1">
<FontAwesomeIcon v-if="store.isLoadingRevision" :icon="faSpinner" spin fixed-width />
Click to select and view a revision in the editor
</div>
</template>
<template v-slot:right>
<PageChatPanel :history-id="historyId" :page-id="pageId" :page-content="store.currentContent" />
</template>
</SplitView>
<template v-else>
<div class="page-content flex-grow-1 overflow-auto">
<MarkdownEditor
:markdown-text="store.currentContent"
:mode="markdownEditorMode"
:title="editorTitle"
@update="handleContentUpdate" />
</div>
<div v-if="store.showRevisions" class="page-revision-panel border-left">
<PageRevisionList
:revisions="store.revisions"
:is-loading="store.isLoadingRevisions"
:is-reverting="store.isReverting"
@select="handleRevisionSelect"
@restore="handleRevisionRestore" />
</div>
</template>
<PageRevisionList
:revisions="store.revisions"
:is-loading="store.isLoadingRevisions"
:is-reverting="store.isReverting"
:selected-revision-id="store.selectedRevision?.id"
@select="handleRevisionSelect"
@restore="handleRevisionRestore" />
</GModal>
</div>
</template>
</div>
@@ -357,19 +299,6 @@ function handleRevisionRestore(revisionId: string) {
.page-editor-view {
background: var(--body-bg);
}
.page-toolbar,
.page-display-toolbar {
background: var(--panel-header-bg);
}
.page-content {
padding: 1rem;
}
.page-revision-panel {
width: 300px;
min-width: 300px;
overflow-y: auto;
background: var(--body-bg);
}
.page-editor-pane {
padding: 1rem;
display: flex;
@@ -11,6 +11,7 @@ const props = defineProps<{
revisions: PageRevisionSummary[];
isLoading: boolean;
isReverting: boolean;
selectedRevisionId?: string;
}>();
const emit = defineEmits<{
@@ -62,6 +63,9 @@ function onSelect(rev: PageRevisionSummary) {
<span class="font-weight-bold">{{ formatDate(rev.create_time) }}</span>
<span class="text-muted ml-1">({{ sourceLabel(rev.edit_source) }})</span>
<span v-if="index === 0" class="badge badge-primary ml-1">Current</span>
<span v-if="rev.id === props.selectedRevisionId" class="badge badge-secondary ml-1">
Viewing
</span>
</div>
</div>
<BButton
@@ -126,39 +126,42 @@ describe("PageRevisionView", () => {
expect(wrapper.find('[data-description="revision compare previous button"]').exists()).toBe(false);
});
it("preview button is primary in preview mode", () => {
it("preview button is pressed in preview mode", () => {
const wrapper = mountComponent({ viewMode: "preview" });
const previewBtn = wrapper.find('[data-description="revision preview button"]');
expect(previewBtn.attributes("variant")).toBe("primary");
expect(previewBtn.props("pressed")).toBe(true);
});
it("compare current button is primary in changes_current mode", () => {
it("compare current button is pressed in changes_current mode", () => {
const wrapper = mountComponent({ viewMode: "changes_current" });
const btn = wrapper.find('[data-description="revision compare current button"]');
expect(btn.attributes("variant")).toBe("primary");
expect(btn.props("pressed")).toBe(true);
});
it("compare previous button is primary in changes_previous mode", () => {
it("compare previous button is pressed in changes_previous mode", () => {
const wrapper = mountComponent({ viewMode: "changes_previous" });
const btn = wrapper.find('[data-description="revision compare previous button"]');
expect(btn.attributes("variant")).toBe("primary");
expect(btn.props("pressed")).toBe(true);
});
it("clicking Compare to Current emits update:viewMode with 'changes_current'", async () => {
const wrapper = mountComponent({ viewMode: "preview" });
await wrapper.find('[data-description="revision compare current button"]').trigger("click");
wrapper.find('[data-description="revision compare current button"]').vm.$emit("click");
await wrapper.vm.$nextTick();
expect(wrapper.emitted("update:viewMode")).toEqual([["changes_current"]]);
});
it("clicking Compare to Previous emits update:viewMode with 'changes_previous'", async () => {
const wrapper = mountComponent({ viewMode: "preview" });
await wrapper.find('[data-description="revision compare previous button"]').trigger("click");
wrapper.find('[data-description="revision compare previous button"]').vm.$emit("click");
await wrapper.vm.$nextTick();
expect(wrapper.emitted("update:viewMode")).toEqual([["changes_previous"]]);
});
it("clicking Preview emits update:viewMode with 'preview'", async () => {
const wrapper = mountComponent({ viewMode: "changes_current" });
await wrapper.find('[data-description="revision preview button"]').trigger("click");
wrapper.find('[data-description="revision preview button"]').vm.$emit("click");
await wrapper.vm.$nextTick();
expect(wrapper.emitted("update:viewMode")).toEqual([["preview"]]);
});
});
@@ -181,19 +184,22 @@ describe("PageRevisionView", () => {
it("restore button emits restore with revision id", async () => {
const wrapper = mountComponent();
await wrapper.find('[data-description="revision restore button"]').trigger("click");
wrapper.find('[data-description="revision restore button"]').vm.$emit("click");
await wrapper.vm.$nextTick();
expect(wrapper.emitted("restore")).toEqual([[REVISION.id]]);
});
it("back button emits back in preview mode", async () => {
const wrapper = mountComponent({ viewMode: "preview" });
await wrapper.find('[data-description="revision back button"]').trigger("click");
wrapper.find('[data-description="revision back button"]').vm.$emit("click");
await wrapper.vm.$nextTick();
expect(wrapper.emitted("back")).toHaveLength(1);
});
it("back button emits back in changes_current mode", async () => {
const wrapper = mountComponent({ viewMode: "changes_current" });
await wrapper.find('[data-description="revision back button"]').trigger("click");
wrapper.find('[data-description="revision back button"]').vm.$emit("click");
await wrapper.vm.$nextTick();
expect(wrapper.emitted("back")).toHaveLength(1);
});
});
@@ -1,13 +1,14 @@
<script setup lang="ts">
import { faArrowLeft, faExchangeAlt, faEye, faSpinner, faUndo } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BButton } from "bootstrap-vue";
import { computed } from "vue";
import type { PageRevisionDetails } from "@/api/pages";
import { computeLineDiff, diffStats } from "./sectionDiffUtils";
import GButtonGroup from "../BaseComponents/GButtonGroup.vue";
import GButton from "@/components/BaseComponents/GButton.vue";
import Markdown from "@/components/Markdown/Markdown.vue";
type ViewMode = "preview" | "changes_current" | "changes_previous";
@@ -56,56 +57,83 @@ const activeHasNoChanges = computed(() =>
<template>
<div class="page-revision-view d-flex flex-column h-100" data-description="page revision view">
<div class="revision-view-toolbar d-flex align-items-center p-2 border-bottom">
<BButton variant="link" size="sm" data-description="revision back button" @click="emit('back')">
<FontAwesomeIcon :icon="faArrowLeft" />
Back to revisions
</BButton>
<span class="d-flex mx-2" data-description="revision view mode toggle">
<BButton
:variant="viewMode === 'preview' ? 'primary' : 'outline-primary'"
size="sm"
data-description="revision preview button"
@click="emit('update:viewMode', 'preview')">
<FontAwesomeIcon :icon="faEye" />
Preview
</BButton>
<BButton
v-if="!isNewestRevision"
:variant="viewMode === 'changes_current' ? 'primary' : 'outline-primary'"
size="sm"
class="ml-1"
data-description="revision compare current button"
@click="emit('update:viewMode', 'changes_current')">
<FontAwesomeIcon :icon="faExchangeAlt" />
Compare to Current
</BButton>
<BButton
v-if="!isOldestRevision"
:variant="viewMode === 'changes_previous' ? 'primary' : 'outline-primary'"
size="sm"
class="ml-1"
data-description="revision compare previous button"
@click="emit('update:viewMode', 'changes_previous')">
<FontAwesomeIcon :icon="faExchangeAlt" />
Compare to Previous
</BButton>
</span>
<span class="flex-grow-1"></span>
<BButton
variant="primary"
size="sm"
data-description="revision restore button"
:disabled="isReverting"
@click="emit('restore', revision.id)">
<FontAwesomeIcon :icon="isReverting ? faSpinner : faUndo" :spin="isReverting" />
Restore this version
</BButton>
<div class="revision-view-toolbar border-bottom">
<div class="d-flex">
<GButtonGroup data-description="revision view mode toggle">
<GButton
color="blue"
outline
:pressed="viewMode === 'preview'"
data-description="revision preview button"
size="small"
@click="emit('update:viewMode', 'preview')">
<FontAwesomeIcon :icon="faEye" />
Preview
</GButton>
<GButton
v-if="!isNewestRevision"
color="blue"
outline
:pressed="viewMode === 'changes_current'"
data-description="revision compare current button"
size="small"
@click="emit('update:viewMode', 'changes_current')">
<FontAwesomeIcon :icon="faExchangeAlt" />
Compare to Current
</GButton>
<GButton
v-if="!isOldestRevision"
color="blue"
outline
:pressed="viewMode === 'changes_previous'"
data-description="revision compare previous button"
size="small"
@click="emit('update:viewMode', 'changes_previous')">
<FontAwesomeIcon :icon="faExchangeAlt" />
Compare to Previous
</GButton>
</GButtonGroup>
<div v-if="!activeHasNoChanges" class="d-flex align-items-center px-2">
<span class="diff-stats">
<span class="text-success">+{{ activeStats.additions }}</span>
<span class="mx-1">/</span>
<span class="text-danger">-{{ activeStats.deletions }}</span>
lines
</span>
</div>
</div>
<div class="d-flex flex-gapx-1 align-items-center">
<GButton
color="blue"
transparent
size="small"
data-description="revision back button"
@click="emit('back')">
<FontAwesomeIcon :icon="faArrowLeft" />
Exit Revision
</GButton>
<GButton
color="blue"
size="small"
title="Restore this version"
tooltip
data-description="revision restore button"
:disabled="isReverting"
@click="emit('restore', revision.id)">
<FontAwesomeIcon :icon="isReverting ? faSpinner : faUndo" :spin="isReverting" />
Restore
</GButton>
</div>
</div>
<div class="revision-view-content overflow-auto flex-grow-1">
<Markdown
v-if="viewMode === 'preview'"
class="px-3 pt-3"
:markdown-config="markdownConfig"
no-heading
:read-only="true"
download-endpoint="" />
<div
@@ -120,14 +148,6 @@ const activeHasNoChanges = computed(() =>
{{ viewMode === "changes_current" ? "current content" : "previous revision" }}.
</div>
<template v-else>
<div class="diff-header d-flex align-items-center p-2">
<span class="diff-stats">
<span class="text-success">+{{ activeStats.additions }}</span>
<span class="mx-1">/</span>
<span class="text-danger">-{{ activeStats.deletions }}</span>
lines
</span>
</div>
<div class="diff-content">
<div v-for="(change, idx) in activeChanges" :key="idx" class="diff-block">
<pre
@@ -151,18 +171,18 @@ const activeHasNoChanges = computed(() =>
<style scoped>
.revision-view-toolbar {
background: var(--panel-header-bg);
background: var(--color-grey-100);
display: flex;
align-items: center;
padding: 0.5rem 1rem;
gap: 0.5rem;
justify-content: space-between;
}
.revision-diff-view {
font-size: 0.85rem;
}
.diff-header {
background: var(--panel-header-bg, #f8f9fa);
border-bottom: 1px solid var(--border-color, #dee2e6);
}
.diff-content {
overflow: auto;
padding: 0;
@@ -215,3 +215,11 @@ export function diffStats(changes: Change[]): DiffStats {
}
return { additions, deletions };
}
export function djb2Hash(s: string): string {
let h = 5381;
for (let i = 0; i < s.length; i++) {
h = (h * 33 + s.charCodeAt(i)) >>> 0;
}
return h.toString(16).padStart(8, "0");
}
@@ -0,0 +1,27 @@
import type { HistoryPageSummary } from "@/api/pages";
export const FAKE_PAGE_SUMMARY: HistoryPageSummary = {
id: "page-1",
history_id: "history-1",
title: "My Analysis",
slug: null,
source_invocation_id: null,
published: false,
importable: false,
deleted: false,
latest_revision_id: "rev-1",
revision_ids: ["rev-1"],
create_time: "2025-06-15T10:30:00Z",
update_time: "2025-06-15T12:45:00Z",
username: "test",
email_hash: "",
author_deleted: false,
model_class: "Page",
tags: [],
};
export const FAKE_PAGE_UNTITLED: HistoryPageSummary = {
...FAKE_PAGE_SUMMARY,
id: "page-2",
title: "",
};
@@ -3,11 +3,10 @@ import { computed, ref } from "vue";
import { useRouter } from "vue-router/composables";
import type { CardBadge } from "@/components/Common/GCard.types";
import { useConfig } from "@/composables/config";
import { useUploadStagingCounts } from "@/composables/upload/useUploadStaging";
import type { UploadMethodConfig } from "./types";
import { useAllUploadMethods } from "./uploadMethodRegistry";
import { useFilteredUploadMethods } from "./uploadMethodRegistry";
import DelayedInput from "@/components/Common/DelayedInput.vue";
import GCard from "@/components/Common/GCard.vue";
@@ -25,29 +24,14 @@ const props = withDefaults(defineProps<Props>(), {
searchTeleportTarget: undefined,
});
const { config, isConfigLoaded } = useConfig();
const router = useRouter();
const query = ref("");
const searchInputClass = computed(() => (props.searchTeleportTarget ? "my-2" : props.inPanel ? "my-2" : "mb-3"));
const allUploadMethods = useAllUploadMethods();
const availableMethods = useFilteredUploadMethods();
const stagedCountsByMode = useUploadStagingCounts();
const availableMethods = computed(() => {
if (!isConfigLoaded.value) {
return allUploadMethods.value;
}
return allUploadMethods.value.filter((method: UploadMethodConfig) => {
// Filter based on config requirements
if (method.requiresConfig) {
return method.requiresConfig.every((configKey) => config.value[configKey]);
}
return true;
});
});
const filteredMethods = computed(() => {
const rawTokens = query.value.trim().split(/\s+/).filter(Boolean);
if (rawTokens.length === 0) {
@@ -117,6 +101,8 @@ function getStagingBadges(method: UploadMethodConfig): CardBadge[] {
:title-icon="{ icon: method.icon, class: 'text-primary', size: 'lg' }"
:badges="getStagingBadges(method)"
:data-method-id="method.id"
:disabled="method.disabled"
:disabled-title="method.disabledTitle"
@click="selectUploadMethod(method)" />
</template>
</ScrollList>
@@ -40,7 +40,8 @@ const { currentHistoryId } = storeToRefs(historyStore);
const { submitPreparedUpload } = useUploadSubmission();
const modalConfig = computed<UploadModalConfig>(() => props.config ?? {});
const availableMethods = useFilteredUploadMethods(modalConfig);
const allowedMethods = computed(() => modalConfig.value.allowedMethods);
const availableMethods = useFilteredUploadMethods(allowedMethods);
const selectedMethod = ref<UploadMethodConfig | null>(null);
const uploadMethodRef = ref<UploadMethodComponent | null>(null);
const canUpload = ref(false);
@@ -210,6 +211,8 @@ async function handleStartClick() {
:description="method.description"
:title-icon="{ icon: method.icon, class: 'text-primary', size: 'lg' }"
:selected="selectedMethod?.id === method.id"
:disabled="method.disabled"
:disabled-title="method.disabledTitle"
@click="selectMethod(method)" />
</div>
</aside>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { BPagination } from "bootstrap-vue";
import { usePagination } from "@/composables/pagination";
import { useUploadBatchOperations } from "@/composables/upload/useUploadBatchOperations";
import { useUserLocalStorage } from "@/composables/userLocalStorage";
@@ -14,11 +15,14 @@ import BreadcrumbHeading from "@/components/Common/BreadcrumbHeading.vue";
const uploadBatchOperations = useUploadBatchOperations();
const uploadState = useUploadState();
const { orderedUploadItems, batchesWithProgress, standaloneUploads, activeItems, hasCompleted } = uploadState;
const { orderedUploadItems, batchesWithProgress, activeItems, hasCompleted } = uploadState;
const { paginatedItems, currentPage, itemsPerPage, showPagination, onPageChange } = usePagination(orderedUploadItems, {
itemsPerPage: 24,
});
const breadcrumbItems = [getUploadRootBreadcrumb("/upload"), { title: "Upload Progress" }];
const fileListRef = ref<HTMLElement | null>(null);
const expandedBatches = useUserLocalStorage<string[]>("uploadPanel.expandedBatches", []);
function cleanupExpandedBatches() {
@@ -55,29 +59,6 @@ function onClearAll() {
async function retryBatch(batchId: string) {
await uploadBatchOperations.retryCollectionCreation(batchId);
}
const uploadItemCount = computed(() => {
return batchesWithProgress.value.length + standaloneUploads.value.length;
});
watch(uploadItemCount, async (newCount, oldCount) => {
if (newCount > oldCount) {
await nextTick();
fileListRef.value?.scrollTo({
top: fileListRef.value.scrollHeight,
behavior: "smooth",
});
}
});
onMounted(() => {
nextTick(() => {
fileListRef.value?.scrollTo({
top: fileListRef.value.scrollHeight,
behavior: "auto",
});
});
});
</script>
<template>
@@ -93,10 +74,8 @@ onMounted(() => {
<div class="upload-progress-content flex-grow-1 overflow-auto p-3">
<div v-if="activeItems.length > 0 || batchesWithProgress.length > 0" class="h-100 d-flex flex-column">
<div ref="fileListRef" class="file-details-list flex-grow-1 overflow-auto">
<div
v-for="item in orderedUploadItems"
:key="item.type === 'batch' ? item.batch.id : item.upload.id">
<div class="file-details-list flex-grow-1 overflow-auto pt-1">
<div v-for="item in paginatedItems" :key="item.type === 'batch' ? item.batch.id : item.upload.id">
<BatchUploadGroup
v-if="item.type === 'batch'"
:batch="item.batch"
@@ -107,6 +86,18 @@ onMounted(() => {
<UploadFileRow v-else :file="item.upload" />
</div>
</div>
<div v-if="showPagination" class="d-flex justify-content-center mt-3">
<BPagination
:value="currentPage"
:total-rows="orderedUploadItems.length"
:per-page="itemsPerPage"
align="center"
size="sm"
first-number
last-number
@change="onPageChange" />
</div>
</div>
<div v-else class="d-flex flex-column align-items-center justify-content-center h-100 text-muted">
<p class="h-lg mb-3">No uploads in progress</p>
@@ -11,11 +11,13 @@ const emit = defineEmits<{
(e: "show-details"): void;
}>();
const hasActiveUploads = computed(() => uploads.value.some((f) => f.status !== "completed" && f.status !== "error"));
const statusIcon = computed(() => {
if (errorCount.value > 0) {
return faTimes;
}
if (uploadingCount.value > 0) {
if (hasActiveUploads.value) {
return faSpinner;
}
return faCheck;
@@ -25,14 +27,14 @@ const statusClass = computed(() => {
if (errorCount.value > 0) {
return "text-danger";
}
if (uploadingCount.value > 0) {
if (hasActiveUploads.value) {
return "text-primary";
}
return "text-success";
});
const statusText = computed(() => {
if (uploadingCount.value > 0 || uploads.value.some((f) => f.status === "queued")) {
if (hasActiveUploads.value) {
return "Uploading";
}
if (uploads.value.length > 0 && completedCount.value === uploads.value.length) {
@@ -84,7 +84,13 @@ function createPasteUrlItem(id: number, url: string, name: string): PasteUrlItem
const urlItems = ref<PasteUrlItem[]>([]);
const urlText = ref("");
const showInputArea = ref(true);
const showInputOverride = ref(false);
const showInputArea = computed(() => {
if (urlItems.value.length > 0) {
return showInputOverride.value;
}
return true;
});
const { clear: clearStaging } = useUploadStaging<PasteUrlItem>(props.method.id, urlItems, {
disableStore: props.transient,
});
@@ -143,16 +149,16 @@ function addUrlsFromText() {
}
urlText.value = "";
showInputArea.value = false;
showInputOverride.value = false;
scrollToBottom();
}
function showUrlInput() {
showInputArea.value = true;
showInputOverride.value = true;
}
function showUrlList() {
showInputArea.value = false;
showInputOverride.value = false;
}
function scrollToBottom() {
@@ -168,7 +174,7 @@ function removeItem(id: number) {
urlItems.value = urlItems.value.filter((item) => item.id !== id);
if (urlItems.value.length === 0) {
showInputArea.value = true;
showInputOverride.value = false;
resetCollection();
}
}
@@ -221,7 +227,7 @@ function reset() {
urlItems.value = [];
urlText.value = "";
clearStaging();
showInputArea.value = true;
showInputOverride.value = false;
resetCollection();
}
@@ -713,6 +713,7 @@ defineExpose<UploadMethodComponent>({ prepareUpload, reset });
class="mr-2 text-warning"
fixed-width />
<span
class="remote-files-browser-label"
data-test-id="remote-files-browser-label"
:data-label="item.label"
:data-entry-kind="item.isLeaf ? 'file' : 'directory'">
@@ -942,6 +943,10 @@ defineExpose<UploadMethodComponent>({ prepareUpload, reset });
background-color: rgba($brand-primary, 0.05);
}
}
.remote-files-browser-label {
text-align: start;
}
}
.browser-actions {
@@ -83,6 +83,24 @@ export interface UploadMethodConfig {
*/
requiresAdvancedMode?: boolean;
/**
* When true, this upload method requires the user to be logged in.
*
* Methods with this flag will be disabled for anonymous users.
*/
requiresLogin?: boolean;
/**
* When true, this upload method is disabled and not interactive.
* Set reactively by `useFilteredUploadMethods` based on login state.
*/
disabled?: boolean;
/**
* Tooltip text displayed when the method is disabled.
*/
disabledTitle?: string;
/**
* When true, the Start button in the footer will be shown.
*
@@ -1,7 +1,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ref } from "vue";
import { reactive, ref } from "vue";
import { getUploadMethod, uploadMethodRegistry, useAllUploadMethods } from "./uploadMethodRegistry";
import {
getUploadMethod,
uploadMethodRegistry,
useAllUploadMethods,
useFilteredUploadMethods,
} from "./uploadMethodRegistry";
// The advancedMode ref is declared before the vi.mock call so both the mock factory
// and the test bodies share the same reactive reference.
@@ -11,11 +16,27 @@ vi.mock("@/composables/upload/uploadAdvancedMode", () => ({
useUploadAdvancedMode: () => ({ advancedMode }),
}));
// Reactive state object for useUserStore mock — storeToRefs expects a reactive object.
const userStoreState = reactive({ isAnonymous: true });
vi.mock("@/stores/userStore", () => ({
useUserStore: () => userStoreState,
}));
// Reactive refs for useConfig mock.
const galaxyConfig = ref({});
const isConfigLoaded = ref(true);
vi.mock("@/composables/config", () => ({
useConfig: () => ({ config: galaxyConfig, isConfigLoaded }),
}));
// Derive method ID lists from the registry so they stay in sync automatically
// whenever a method is added, removed, or promoted/demoted to advanced mode.
const ALL_METHOD_IDS = Object.keys(uploadMethodRegistry) as Array<keyof typeof uploadMethodRegistry>;
const ADVANCED_METHOD_IDS = ALL_METHOD_IDS.filter((id) => uploadMethodRegistry[id].requiresAdvancedMode);
const STANDARD_METHOD_IDS = ALL_METHOD_IDS.filter((id) => !uploadMethodRegistry[id].requiresAdvancedMode);
const LOGIN_METHOD_IDS = ALL_METHOD_IDS.filter((id) => uploadMethodRegistry[id].requiresLogin);
describe("uploadMethodRegistry", () => {
describe("getUploadMethod", () => {
@@ -57,4 +78,45 @@ describe("uploadMethodRegistry", () => {
expect(methods.value).toHaveLength(STANDARD_METHOD_IDS.length);
});
});
describe("useFilteredUploadMethods", () => {
beforeEach(() => {
advancedMode.value = false;
userStoreState.isAnonymous = true;
isConfigLoaded.value = true;
});
it("disables login-required methods when user is anonymous", () => {
const methods = useFilteredUploadMethods();
for (const loginId of LOGIN_METHOD_IDS) {
const method = methods.value.find((m) => m.id === loginId);
expect(method).toBeDefined();
expect(method?.disabled).toBe(true);
expect(method?.disabledTitle).toBeDefined();
}
});
it("includes login-required methods without disabled when user is logged in", () => {
userStoreState.isAnonymous = false;
const methods = useFilteredUploadMethods();
for (const loginId of LOGIN_METHOD_IDS) {
const method = methods.value.find((m) => m.id === loginId);
expect(method).toBeDefined();
expect(method?.disabled).toBeFalsy();
expect(method?.disabledTitle).toBeUndefined();
}
});
it("filters by allowedMethods when provided", () => {
const allowedMethods = ref(["local-file", "paste-content"] as ["local-file", "paste-content"]);
const methods = useFilteredUploadMethods(allowedMethods);
const ids = methods.value.map((m) => m.id);
expect(ids).toContain("local-file");
expect(ids).toContain("paste-content");
expect(ids).not.toContain("paste-links");
});
});
});
@@ -11,13 +11,14 @@ import {
faSitemap,
faTable,
} from "@fortawesome/free-solid-svg-icons";
import { storeToRefs } from "pinia";
import { computed, type ComputedRef, defineAsyncComponent, type Ref } from "vue";
import { useConfig } from "@/composables/config";
import { useUploadAdvancedMode } from "@/composables/upload/uploadAdvancedMode";
import { useUserStore } from "@/stores/userStore";
import type { UploadMethod, UploadMethodConfig } from "./types";
import type { UploadModalConfig } from "./uploadModalTypes";
export const uploadMethodRegistry: Record<UploadMethod, UploadMethodConfig> = {
"local-file": {
@@ -148,6 +149,7 @@ export const uploadMethodRegistry: Record<UploadMethod, UploadMethodConfig> = {
icon: faHdd,
headerAction: "Import History",
requiresTargetHistory: false,
requiresLogin: true,
showStartButton: false,
tips: [
"Import a complete Galaxy history from a previously exported file or URL",
@@ -163,6 +165,7 @@ export const uploadMethodRegistry: Record<UploadMethod, UploadMethodConfig> = {
icon: faSitemap,
headerAction: "Import Workflow",
requiresTargetHistory: false,
requiresLogin: true,
showStartButton: false,
tips: [
"Import workflows from Galaxy Workflow files or public URLs",
@@ -207,25 +210,48 @@ export function useAllUploadMethods(): ComputedRef<UploadMethodConfig[]> {
}
/**
* Reactive list of upload methods filtered by modal config and Galaxy config requirements.
* Reactive list of upload methods filtered by allowed methods, Galaxy config, and login requirements.
*
* @param allowedMethods - Optional list of allowed upload method IDs. When `undefined`,
* only `requiresLogin`, `requiresConfig`, and `requiresAdvancedMode` filters are applied.
*/
export function useFilteredUploadMethods(config: Ref<UploadModalConfig>): ComputedRef<UploadMethodConfig[]> {
export function useFilteredUploadMethods(
allowedMethods?: Ref<UploadMethod[] | undefined>,
): ComputedRef<UploadMethodConfig[]> {
const allMethods = useAllUploadMethods();
const { config: galaxyConfig, isConfigLoaded } = useConfig();
const { isAnonymous } = storeToRefs(useUserStore());
return computed(() => {
const allowedMethods = config.value.allowedMethods;
return allMethods.value
.map((method) => {
let disabled = false;
let disabledTitle: string | undefined;
return allMethods.value.filter((method) => {
if (allowedMethods && !allowedMethods.some((allowedMethod) => allowedMethod === method.id)) {
return false;
}
if (method.requiresLogin && isAnonymous.value) {
disabled = true;
disabledTitle = "You must be logged in to use this feature";
}
if (!isConfigLoaded.value || !method.requiresConfig) {
return true;
}
if (disabled) {
return { ...method, disabled, disabledTitle };
}
return method.requiresConfig.every((configKey) => Boolean(galaxyConfig.value[configKey]));
});
return method;
})
.filter((method) => {
if (
allowedMethods?.value &&
!allowedMethods.value.some((allowedMethod) => allowedMethod === method.id)
) {
return false;
}
if (!isConfigLoaded.value || !method.requiresConfig) {
return true;
}
return method.requiresConfig.every((configKey) => Boolean(galaxyConfig.value[configKey]));
});
});
}
@@ -1,6 +1,7 @@
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import {
faCheck,
faClock,
faCloud,
faExclamationTriangle,
faLayerGroup,
@@ -45,7 +46,7 @@ export interface BatchWithProgress extends CollectionBatchState {
const FILE_PROGRESS_UI: Record<UploadItem["status"], FileProgressUi> = {
queued: {
icon: faSpinner,
icon: faClock,
textClass: "text-muted",
spin: false,
},
@@ -192,7 +192,7 @@ export function useUploadState() {
upload,
}));
return [...batchItems, ...standaloneItems].sort((a, b) => a.createdAt - b.createdAt);
return [...batchItems, ...standaloneItems].sort((a, b) => b.createdAt - a.createdAt);
});
/**
@@ -309,7 +309,7 @@ export function useUploadState() {
*/
function updateProgress(id: string, progress: number) {
const item = items.value.find((u) => u.id === id);
if (item) {
if (item && item.status !== "completed") {
item.progress = Math.max(0, Math.min(100, Math.round(progress)));
if (item.progress >= 100 && item.status !== "error") {
item.status = "completed";
+1 -1
View File
@@ -1,6 +1,6 @@
<template>
<span>
<span v-if="!referenceEl" ref="reference">
<span v-if="!referenceEl" ref="reference" class="popper-reference-container">
<slot name="reference" />
</span>
<div v-show="!disabled && visible" ref="popper" class="popper-element" :class="`popper-element-${mode}`">
File diff suppressed because one or more lines are too long
@@ -0,0 +1,149 @@
import { createTestingPinia } from "@pinia/testing";
import { getLocalVue } from "@tests/vitest/helpers";
import { mount } from "@vue/test-utils";
import { setActivePinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useWindowManagerStore } from "@/stores/windowManagerStore";
import WindowManagerWindow from "./WindowManagerWindow.vue";
const localVue = getLocalVue();
// happy-dom does not implement pointer capture, so stub it. Capturing the
// pointer is what keeps drag/resize alive when the cursor crosses an iframe
// (the center frame or the window's own body), so we assert on these calls.
const setPointerCapture = vi.fn();
function pointerEvent(type: string, options: PointerEventInit = {}) {
return new PointerEvent(type, { bubbles: true, cancelable: true, button: 0, pointerId: 7, ...options });
}
function mountWindow() {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false });
setActivePinia(pinia);
const store = useWindowManagerStore();
store.add({ title: "GalaxyAI", url: "/galaxyai", x: 50, y: 60, width: 400, height: 300 });
const win = store.windows[0]!;
const wrapper = mount(WindowManagerWindow as object, {
localVue,
pinia,
propsData: { window: win },
});
return { wrapper, win };
}
describe("WindowManagerWindow", () => {
beforeEach(() => {
(HTMLElement.prototype as any).setPointerCapture = setPointerCapture;
});
afterEach(() => {
vi.clearAllMocks();
delete (HTMLElement.prototype as any).setPointerCapture;
});
it("resizes with the pointer and captures it on the handle", () => {
const { wrapper, win } = mountWindow();
const handle = wrapper.find(".window-manager-resize-handle");
handle.element.dispatchEvent(pointerEvent("pointerdown", { clientX: 450, clientY: 360 }));
expect(setPointerCapture).toHaveBeenCalledWith(7);
document.dispatchEvent(pointerEvent("pointermove", { clientX: 550, clientY: 410 }));
expect(win.width).toBe(500);
expect(win.height).toBe(350);
document.dispatchEvent(pointerEvent("pointerup", { clientX: 550, clientY: 410 }));
document.dispatchEvent(pointerEvent("pointermove", { clientX: 650, clientY: 460 }));
expect(win.width).toBe(500);
expect(win.height).toBe(350);
});
it("drags with the pointer and captures it on the header", () => {
const { wrapper, win } = mountWindow();
const header = wrapper.find(".window-manager-window-header");
header.element.dispatchEvent(pointerEvent("pointerdown", { clientX: 200, clientY: 80 }));
expect(setPointerCapture).toHaveBeenCalledWith(7);
document.dispatchEvent(pointerEvent("pointermove", { clientX: 230, clientY: 120 }));
expect(win.x).toBe(80);
expect(win.y).toBe(100);
document.dispatchEvent(pointerEvent("pointerup", { clientX: 230, clientY: 120 }));
document.dispatchEvent(pointerEvent("pointermove", { clientX: 300, clientY: 200 }));
expect(win.x).toBe(80);
expect(win.y).toBe(100);
});
it("stops resizing when the pointer is cancelled", () => {
const { wrapper, win } = mountWindow();
const handle = wrapper.find(".window-manager-resize-handle");
handle.element.dispatchEvent(pointerEvent("pointerdown", { clientX: 450, clientY: 360 }));
document.dispatchEvent(pointerEvent("pointermove", { clientX: 550, clientY: 410 }));
expect(win.width).toBe(500);
document.dispatchEvent(pointerEvent("pointercancel", { clientX: 550, clientY: 410 }));
document.dispatchEvent(pointerEvent("pointermove", { clientX: 650, clientY: 460 }));
expect(win.width).toBe(500);
expect(win.height).toBe(350);
});
it("does not start a drag from the window controls", () => {
const { wrapper, win } = mountWindow();
const controls = wrapper.find(".window-manager-window-controls");
controls.element.dispatchEvent(pointerEvent("pointerdown", { clientX: 420, clientY: 70 }));
document.dispatchEvent(pointerEvent("pointermove", { clientX: 500, clientY: 200 }));
expect(win.x).toBe(50);
expect(win.y).toBe(60);
});
it("ignores a second pointer while dragging", () => {
const { wrapper, win } = mountWindow();
const header = wrapper.find(".window-manager-window-header");
header.element.dispatchEvent(pointerEvent("pointerdown", { clientX: 200, clientY: 80 }));
header.element.dispatchEvent(pointerEvent("pointerdown", { pointerId: 9, clientX: 300, clientY: 200 }));
document.dispatchEvent(pointerEvent("pointermove", { pointerId: 9, clientX: 400, clientY: 300 }));
expect(win.x).toBe(50);
expect(win.y).toBe(60);
document.dispatchEvent(pointerEvent("pointermove", { clientX: 230, clientY: 120 }));
expect(win.x).toBe(80);
expect(win.y).toBe(100);
document.dispatchEvent(pointerEvent("pointerup", { clientX: 230, clientY: 120 }));
});
it("does not steal focus when clicking the controls of an unfocused window", () => {
const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false });
setActivePinia(pinia);
const store = useWindowManagerStore();
store.add({ title: "First", url: "/a", x: 50, y: 60, width: 400, height: 300 });
store.add({ title: "Second", url: "/b" });
const first = store.windows[0]!;
const second = store.windows[1]!;
const wrapper = mount(WindowManagerWindow as object, {
localVue,
pinia,
propsData: { window: first },
});
expect(store.focusedId).toBe(second.id);
// a real click delivers pointerdown plus a compatibility mousedown;
// neither may bubble to the root focus handler from the controls
const controls = wrapper.find(".window-manager-window-controls");
controls.element.dispatchEvent(pointerEvent("pointerdown", { clientX: 420, clientY: 70 }));
controls.element.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 }));
expect(store.focusedId).toBe(second.id);
const header = wrapper.find(".window-manager-window-header");
header.element.dispatchEvent(pointerEvent("pointerdown", { clientX: 200, clientY: 80 }));
expect(store.focusedId).toBe(first.id);
document.dispatchEvent(pointerEvent("pointerup", { clientX: 200, clientY: 80 }));
});
});
@@ -56,23 +56,31 @@ const windowStyle = computed(() => {
const iframeSrc = computed(() => store.buildUrl(props.window.url));
// --- Drag ---
let dragPointerId = -1;
let dragStartMouseX = 0;
let dragStartMouseY = 0;
let dragStartX = 0;
let dragStartY = 0;
function onDragStart(e: MouseEvent) {
if (props.window.maximized || props.window.minimized) {
function onDragStart(e: PointerEvent) {
// ignore further pointers while a drag is active (first pointer wins)
if (props.window.maximized || props.window.minimized || dragPointerId !== -1) {
return;
}
e.preventDefault();
store.focus(props.window.id);
dragPointerId = e.pointerId;
dragStartMouseX = e.clientX;
dragStartMouseY = e.clientY;
dragStartX = props.window.x;
dragStartY = props.window.y;
document.addEventListener("mousemove", onDragMove);
document.addEventListener("mouseup", onDragEnd);
// Capture the pointer so move/up events keep reaching this document even
// when the cursor crosses an iframe (the center frame or this window's own
// body), which would otherwise swallow them and leave the drag stuck.
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
document.addEventListener("pointermove", onDragMove);
document.addEventListener("pointerup", onDragEnd);
document.addEventListener("pointercancel", onDragEnd);
}
function clampPosition(rawX: number, rawY: number): { x: number; y: number } {
@@ -86,42 +94,57 @@ function clampPosition(rawX: number, rawY: number): { x: number; y: number } {
};
}
function onDragMove(e: MouseEvent) {
function onDragMove(e: PointerEvent) {
if (e.pointerId !== dragPointerId) {
return;
}
const rawX = dragStartX + (e.clientX - dragStartMouseX);
const rawY = dragStartY + (e.clientY - dragStartMouseY);
const { x, y } = clampPosition(rawX, rawY);
store.updatePosition(props.window.id, x, y);
}
function onDragEnd() {
document.removeEventListener("mousemove", onDragMove);
document.removeEventListener("mouseup", onDragEnd);
function onDragEnd(e?: PointerEvent) {
if (e && e.pointerId !== dragPointerId) {
return;
}
dragPointerId = -1;
document.removeEventListener("pointermove", onDragMove);
document.removeEventListener("pointerup", onDragEnd);
document.removeEventListener("pointercancel", onDragEnd);
}
// --- Resize ---
const MIN_WIDTH = 200;
const MIN_HEIGHT = 120;
let resizePointerId = -1;
let resizeStartMouseX = 0;
let resizeStartMouseY = 0;
let resizeStartW = 0;
let resizeStartH = 0;
function onResizeStart(e: MouseEvent) {
if (props.window.maximized) {
function onResizeStart(e: PointerEvent) {
if (props.window.maximized || resizePointerId !== -1) {
return;
}
e.preventDefault();
e.stopPropagation();
store.focus(props.window.id);
resizePointerId = e.pointerId;
resizeStartMouseX = e.clientX;
resizeStartMouseY = e.clientY;
resizeStartW = props.window.width;
resizeStartH = props.window.height;
document.addEventListener("mousemove", onResizeMove);
document.addEventListener("mouseup", onResizeEnd);
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
document.addEventListener("pointermove", onResizeMove);
document.addEventListener("pointerup", onResizeEnd);
document.addEventListener("pointercancel", onResizeEnd);
}
function onResizeMove(e: MouseEvent) {
function onResizeMove(e: PointerEvent) {
if (e.pointerId !== resizePointerId) {
return;
}
const maxW = window.innerWidth - props.window.x;
const maxH = window.innerHeight - props.window.y;
store.updateSize(
@@ -131,9 +154,14 @@ function onResizeMove(e: MouseEvent) {
);
}
function onResizeEnd() {
document.removeEventListener("mousemove", onResizeMove);
document.removeEventListener("mouseup", onResizeEnd);
function onResizeEnd(e?: PointerEvent) {
if (e && e.pointerId !== resizePointerId) {
return;
}
resizePointerId = -1;
document.removeEventListener("pointermove", onResizeMove);
document.removeEventListener("pointerup", onResizeEnd);
document.removeEventListener("pointercancel", onResizeEnd);
}
// --- Actions ---
@@ -154,10 +182,8 @@ function onMaximize() {
}
onBeforeUnmount(() => {
document.removeEventListener("mousemove", onDragMove);
document.removeEventListener("mouseup", onDragEnd);
document.removeEventListener("mousemove", onResizeMove);
document.removeEventListener("mouseup", onResizeEnd);
onDragEnd();
onResizeEnd();
});
</script>
@@ -170,10 +196,12 @@ onBeforeUnmount(() => {
<!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -->
<div
class="window-manager-window-header"
@mousedown.left="window.minimized ? onMinimize() : onDragStart($event)">
@pointerdown.left="window.minimized ? onMinimize() : onDragStart($event)">
<span class="window-manager-window-title">{{ window.title }}</span>
<!-- pointerdown.stop keeps control presses from starting a drag; mousedown.stop
keeps the compatibility mouse event from reaching the root focus handler -->
<!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -->
<div class="window-manager-window-controls" @mousedown.stop>
<div class="window-manager-window-controls" @pointerdown.stop @mousedown.stop>
<button class="window-manager-window-btn" aria-label="Minimize" @click="onMinimize">&#8722;</button>
<button class="window-manager-window-btn" aria-label="Maximize" @click="onMaximize">
<span v-if="window.maximized">&#9724;</span>
@@ -193,7 +221,7 @@ onBeforeUnmount(() => {
<div v-if="!isFocused" class="iframe-focus-overlay" @mousedown="onFocus" />
</div>
<!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -->
<div v-if="!window.maximized" class="window-manager-resize-handle" @mousedown.left="onResizeStart" />
<div v-if="!window.maximized" class="window-manager-resize-handle" @pointerdown.left="onResizeStart" />
</template>
</div>
</template>
@@ -234,6 +262,7 @@ onBeforeUnmount(() => {
border-bottom: 1px solid var(--color-grey-200);
cursor: move;
user-select: none;
touch-action: none;
}
.window-manager-window-title {
@@ -303,5 +332,6 @@ onBeforeUnmount(() => {
width: 12px;
height: 12px;
cursor: nwse-resize;
touch-action: none;
}
</style>
@@ -1,5 +1,5 @@
import { getLocalVue } from "@tests/vitest/helpers";
import { shallowMount, type Wrapper } from "@vue/test-utils";
import { mount, type Wrapper } from "@vue/test-utils";
import flushPromises from "flush-promises";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -29,7 +29,7 @@ describe("RefactorConfirmationModal.vue", () => {
beforeEach(() => {
vi.clearAllMocks();
mockConfirm.mockReset();
wrapper = shallowMount(RefactorConfirmationModal as object, {
wrapper = mount(RefactorConfirmationModal as object, {
propsData: {
refactorActions: [],
workflowId: TEST_WORKFLOW_ID,
@@ -87,7 +87,7 @@ describe("RefactorConfirmationModal.vue", () => {
expect(wrapper.emitted().onRefactor!.length).toBe(1);
});
it("should show confirmation dialog if there are messages from the server", async () => {
it("should show confirmation modal if there are messages from the server", async () => {
vi.mocked(refactor).mockReturnValue(
new Promise((then) => {
then({
@@ -121,7 +121,19 @@ describe("RefactorConfirmationModal.vue", () => {
// but didn't follow up with executing the action because we need to confirm
expect(vi.mocked(refactor).mock.calls.length).toBe(1);
// Modal should now be shown with the message from the server
expect(wrapper.findComponent(GModal).props().show).toBeTruthy();
expect(wrapper.find(".workflow-refactor-modal").text()).toContain(
"hey, a connection was dropped - better respond",
);
// Simulate confirming the modal
const saveButton = wrapper.findComponent(GModal).find(".g-modal-confirm-buttons .g-button.g-blue");
await saveButton.trigger("click");
// On save, refactor should be called again to execute the action (not a dry run this time)
expect(vi.mocked(refactor).mock.calls[1]![0]).toEqual(TEST_WORKFLOW_ID);
expect(vi.mocked(refactor).mock.calls[1]![3]).toBeFalsy();
});
it("should show confirm dialog when refactoring a non-latest version", async () => {
@@ -118,7 +118,7 @@ async function executeRefactoring() {
</script>
<template>
<GModal :show.sync="show" :title="title" fixed-height ok-text="Save" @ok="executeRefactoring">
<GModal confirm :show.sync="show" :title="title" fixed-height ok-text="Save" @ok="executeRefactoring">
<div class="workflow-refactor-modal">
{{ message }}
<ul>
@@ -1,44 +1,40 @@
<script setup lang="ts">
import { faCloudUploadAlt } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import axios from "axios";
import { BAlert, BForm, BFormFile, BFormGroup } from "bootstrap-vue";
import { BForm, BFormFile, BFormGroup } from "bootstrap-vue";
import { computed, type Ref, ref, watch } from "vue";
import { useRouter } from "vue-router/composables";
import { getRedirectOnImportPath } from "@/components/Workflow/redirectPath";
import { useFileDrop } from "@/composables/fileDrop";
import { withPrefix } from "@/utils/redirect";
import { errorMessageAsString } from "@/utils/simple-error";
import { validateWorkflowFile, WORKFLOW_FILE_ACCEPT } from "./workflowValidation";
import GAlert from "@/components/BaseComponents/GAlert.vue";
import GButton from "@/components/BaseComponents/GButton.vue";
import LoadingSpan from "@/components/LoadingSpan.vue";
interface Props {
hideSubmitButton?: boolean;
/** A file dropped on the parent's method card, to be consumed on mount */
droppedFile?: File | null;
}
const props = withDefaults(defineProps<Props>(), {
hideSubmitButton: false,
});
const props = defineProps<Props>();
const emit = defineEmits<{
(e: "input-valid", valid: boolean): void;
}>();
const loading = ref(false);
const sourceFile: Ref<string | null> = ref(null);
const sourceFile: Ref<File | null> = ref(null);
const errorMessage: Ref<string | null> = ref(null);
const acceptedWorkflowFormats = ".ga, .yml";
const dropZoneRef = ref<HTMLElement | null>(null);
const fileSetViaDrop = ref(false);
const isImportDisabled = computed(() => {
return !sourceFile.value;
});
const importTooltip = computed(() => {
return isImportDisabled.value ? "You must provide a workflow file." : "Import workflow from File";
});
const hasErrorMessage = computed(() => {
return errorMessage.value != null;
});
const hasErrorMessage = computed(() => errorMessage.value != null);
// Validation state for wizard mode
const isValid = computed(() => sourceFile.value !== null);
@@ -47,15 +43,65 @@ watch(isValid, (newValue) => {
emit("input-valid", newValue);
});
// Hide submit button in wizard mode (or when hideSubmitButton is true)
const showSubmitButton = computed(() => {
return !props.hideSubmitButton;
// Consume a file dropped on the parent's method card
if (props.droppedFile) {
sourceFile.value = props.droppedFile;
fileSetViaDrop.value = true;
}
// Drop zone handling
async function onDrop(evt: DragEvent) {
if (evt.dataTransfer?.files?.length) {
const file = evt.dataTransfer.files[0];
if (!file) {
return;
}
const result = await validateWorkflowFile(file, { checkContent: true });
if (result.valid) {
sourceFile.value = file;
fileSetViaDrop.value = true;
} else {
errorMessage.value = result.error ?? "Invalid workflow file.";
}
}
}
const { isFileOverDropZone } = useFileDrop({
dropZone: dropZoneRef,
onDrop,
onDropCancel: () => {},
solo: false,
idleTime: 10000,
ignoreChildrenOnLeave: true,
});
/** When the user selects a file via the native input, validate it */
async function onFileInput(file: File | null) {
if (!file) {
sourceFile.value = null;
fileSetViaDrop.value = false;
return;
}
const result = await validateWorkflowFile(file, { checkContent: true });
if (result.valid) {
sourceFile.value = file;
fileSetViaDrop.value = false;
} else {
sourceFile.value = null;
fileSetViaDrop.value = false;
errorMessage.value = result.error ?? "Invalid workflow file.";
}
}
/** Clear the current file and reset to the browse state */
function clearFile() {
sourceFile.value = null;
fileSetViaDrop.value = false;
}
const router = useRouter();
async function submit(ev: SubmitEvent) {
ev.preventDefault();
async function submit() {
const formData = new FormData();
if (sourceFile.value) {
@@ -78,38 +124,71 @@ async function submit(ev: SubmitEvent) {
// Expose method for wizard submit
async function attemptImport() {
await submit(new Event("submit") as SubmitEvent);
await submit();
}
defineExpose({ attemptImport });
</script>
<template>
<BForm class="mt-4 workflow-import-file" @submit="submit">
<h2 class="h-sm">Import from a workflow file</h2>
<BForm class="mt-4 workflow-import-file" @submit.prevent="submit">
<div
ref="dropZoneRef"
class="workflow-drop-zone"
:class="{ 'workflow-drop-zone-active': isFileOverDropZone }"
data-galaxy-file-drop-target>
<div v-if="!sourceFile" class="mb-3">
<FontAwesomeIcon :icon="faCloudUploadAlt" size="3x" class="text-muted" />
<p class="mt-2 mb-1 font-weight-medium">Drag a workflow file here</p>
<p class="text-muted small mb-2">or</p>
</div>
<BFormGroup :label="sourceFile ? 'Workflow File' : 'Browse for a file'">
<!-- When a file was set via drop, the native file input can't reflect
it programmatically, so we show the filename manually instead. -->
<div v-if="fileSetViaDrop && sourceFile" class="text-center py-2">
<span class="font-weight-bold text-break">{{ sourceFile.name }}</span>
<div class="mt-1">
<GButton transparent size="small" @click="clearFile">Choose another</GButton>
</div>
</div>
<BFormFile
v-show="!fileSetViaDrop"
v-model="sourceFile"
:accept="WORKFLOW_FILE_ACCEPT"
@input="onFileInput" />
<span v-if="!sourceFile" class="text-muted small">
Accepted formats: <code>*.ga</code>, <code>*.yml</code>, <code>*.yaml</code>
</span>
</BFormGroup>
</div>
<BFormGroup label="Archived Workflow File">
<BFormFile v-model="sourceFile" :accept="acceptedWorkflowFormats" />
If the workflow is in a file on your computer, choose it and then click Import.
</BFormGroup>
<BAlert :show="hasErrorMessage" variant="danger">
<GAlert :show="hasErrorMessage" variant="danger">
{{ errorMessage }}
</BAlert>
</GAlert>
<BAlert v-if="loading" show variant="info">
<GAlert v-if="loading" variant="info">
<LoadingSpan message="Loading your workflow, this may take a while - please be patient." />
</BAlert>
<GButton
v-if="showSubmitButton"
id="workflow-import-button"
type="submit"
:disabled="isImportDisabled"
:title="importTooltip"
tooltip
color="blue">
Import workflow
</GButton>
</GAlert>
</BForm>
</template>
<style scoped lang="scss">
@import "@/style/scss/theme/blue.scss";
.workflow-drop-zone {
border: 2px dashed $border-color;
border-radius: $border-radius-large;
text-align: center;
background-color: $gray-100;
padding: 1.5rem;
margin-bottom: 1rem;
transition:
border-color 0.3s ease,
background-color 0.3s ease;
&.workflow-drop-zone-active {
border-color: $brand-primary;
background-color: lighten($brand-primary, 60%);
}
}
</style>
@@ -1,6 +1,7 @@
import { getLocalVue } from "@tests/vitest/helpers";
import { mount } from "@vue/test-utils";
import { describe, expect, it, vi } from "vitest";
import VueRouter from "vue-router";
import FromFile from "./FromFile.vue";
import FromUrl from "./FromUrl.vue";
@@ -17,6 +18,7 @@ vi.mock("axios", () => ({
}));
const localVue = getLocalVue(true);
localVue.use(VueRouter);
const sharedUrl = "http://127.0.0.1:8081/u/admin/w/unnamed-workflow";
const sharedUrlTrailingSlash = "http://127.0.0.1:8081/u/admin/w/unnamed-workflow/";
@@ -25,7 +27,8 @@ const invalidUrl = "http://127.0.0.1:8081/u/admin/w/unnamed-workflow/additional-
describe("FromUrl", () => {
it("converts shared urls to json urls", async () => {
const wrapper = mount(FromUrl as object, { localVue });
const router = new VueRouter();
const wrapper = mount(FromUrl as object, { localVue, router });
{
const input = wrapper.find("#workflow-import-url-input");
@@ -71,7 +74,8 @@ describe("FromUrl", () => {
describe("FromFile", () => {
it("can mount the component", async () => {
const wrapper = mount(FromFile as object, { localVue });
expect(wrapper.find("#workflow-import-button").exists()).toBe(true);
const router = new VueRouter();
const wrapper = mount(FromFile as object, { localVue, router });
expect(wrapper.find("form").exists()).toBe(true);
});
});
@@ -2,6 +2,7 @@
import axios from "axios";
import { BAlert, BForm, BFormGroup, BFormInput } from "bootstrap-vue";
import { computed, type Ref, ref, watch } from "vue";
import { RouterLink } from "vue-router";
import { useRouter } from "vue-router/composables";
import { getRedirectOnImportPath } from "@/components/Workflow/redirectPath";
@@ -9,14 +10,6 @@ import { withPrefix } from "@/utils/redirect";
import LoadingSpan from "@/components/LoadingSpan.vue";
interface Props {
hideSubmitButton?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
hideSubmitButton: false,
});
const emit = defineEmits<{
(e: "input-valid", valid: boolean): void;
}>();
@@ -25,14 +18,6 @@ const loading = ref(false);
const sourceURL: Ref<string | null> = ref(null);
const errorMessage: Ref<string | null> = ref(null);
const isImportDisabled = computed(() => {
return !sourceURL.value;
});
const importTooltip = computed(() => {
return isImportDisabled.value ? "You must provide a workflow archive URL." : "Import workflow from URL";
});
const hasErrorMessage = computed(() => {
return errorMessage.value != null;
});
@@ -46,11 +31,6 @@ watch(isValid, (newValue) => {
emit("input-valid", newValue);
});
// Hide submit button in wizard mode (or when hideSubmitButton is true)
const showSubmitButton = computed(() => {
return !props.hideSubmitButton;
});
function autoAppendJson(urlString: string): string {
const sharedWorkflowRegex = /^(https?:\/\/[\S]+\/u\/[\S]+\/w\/[^\s/]+)\/?$/;
const matches = urlString.match(sharedWorkflowRegex);
@@ -65,8 +45,7 @@ function autoAppendJson(urlString: string): string {
const router = useRouter();
async function submit(ev: SubmitEvent) {
ev.preventDefault();
async function submit() {
const formData = new FormData();
if (sourceURL.value) {
@@ -94,14 +73,14 @@ async function submit(ev: SubmitEvent) {
// Expose method for wizard submit
async function attemptImport() {
await submit(new Event("submit") as SubmitEvent);
await submit();
}
defineExpose({ attemptImport });
</script>
<template>
<BForm class="mt-4 workflow-import-url" @submit="submit">
<BForm class="mt-4 workflow-import-url" @submit.prevent="submit">
<h2 class="h-sm">Import from a Galaxy workflow export URL</h2>
<BFormGroup label="Workflow Archive URL">
@@ -111,7 +90,7 @@ defineExpose({ attemptImport });
aria-label="Workflow Import URL"
type="url" />
If your URL is from a workflow repository and doesn't end in <code>.ga</code>, you might need to use the
<a href="/workflows/import?trs_url=Enter%20a%20TRS%20URL">TRS import method</a>
<RouterLink to="/workflows/import?trs_url=Enter%20a%20TRS%20URL">TRS import method</RouterLink>
instead.
</BFormGroup>
@@ -122,16 +101,5 @@ defineExpose({ attemptImport });
<BAlert v-if="loading" show variant="info">
<LoadingSpan message="Loading your workflow, this may take a while - please be patient." />
</BAlert>
<GButton
v-if="showSubmitButton"
id="workflow-import-button"
type="submit"
:disabled="isImportDisabled"
:title="importTooltip"
tooltip
color="blue">
Import workflow
</GButton>
</BForm>
</template>
@@ -134,8 +134,6 @@ defineExpose({ triggerImport });
<template>
<div class="workflow-import-trs-id">
<h2 class="h-sm">Import from TRS ID</h2>
<div>
<b>TRS Server:</b>
@@ -2,16 +2,12 @@
import { BForm, BFormGroup, BFormInput } from "bootstrap-vue";
import { computed, ref, watch } from "vue";
import GButton from "@/components/BaseComponents/GButton.vue";
interface Props {
queryTrsUrl?: string;
hideSubmitButton?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
queryTrsUrl: "",
hideSubmitButton: false,
});
const emit = defineEmits<{
@@ -21,14 +17,6 @@ const emit = defineEmits<{
const trsUrl = ref(props.queryTrsUrl);
const isImportDisabled = computed(() => {
return !trsUrl.value;
});
const importTooltip = computed(() => {
return isImportDisabled.value ? "You must provide a TRS URL." : "Import workflow from TRS URL";
});
// Validation state for wizard mode
const isValid = computed(() => {
return trsUrl.value !== null && trsUrl.value !== undefined && trsUrl.value.length > 0;
@@ -42,11 +30,6 @@ watch(
{ immediate: true },
);
// Hide submit button in wizard mode (or when hideSubmitButton is true)
const showSubmitButton = computed(() => {
return !props.hideSubmitButton;
});
function submit(ev: SubmitEvent) {
ev.preventDefault();
@@ -67,22 +50,9 @@ defineExpose({ triggerImport });
<template>
<BForm class="mt-4" @submit="submit">
<h2 class="h-sm">Import from TRS URL</h2>
<BFormGroup label="TRS URL:" label-class="font-weight-bold">
<BFormInput id="trs-import-url-input" v-model="trsUrl" aria-label="TRS URL" type="url" />
If the workflow is accessible via a TRS URL, enter the URL above and click Import.
</BFormGroup>
<GButton
v-if="showSubmitButton"
id="trs-url-import-button"
type="submit"
:disabled="isImportDisabled"
:title="importTooltip"
tooltip
color="blue">
Import workflow
</GButton>
</BForm>
</template>
@@ -0,0 +1,69 @@
/** Accepted workflow file extensions */
export const WORKFLOW_FILE_EXTENSIONS = [".ga", ".gxwf.yml", ".gxwf.yaml", ".yml", ".yaml"] as const;
/** Human-readable list of accepted formats for file inputs */
export const WORKFLOW_FILE_ACCEPT = ".ga, .yml, .yaml";
/**
* Check if a filename has a valid workflow file extension.
* Supports .ga (Galaxy Archive), .yml/.yaml (Galaxy Workflow Format), and .gxwf.yml/.gxwf.yaml
*/
export function hasWorkflowFileExtension(fileName: string): boolean {
const lowerName = fileName.toLowerCase();
return WORKFLOW_FILE_EXTENSIONS.some((ext) => lowerName.endsWith(ext));
}
/**
* Validate a workflow file by checking its extension and optionally its content.
*
* For .ga files (JSON format), checks for `"a_galaxy_workflow": "true"`.
* For .yml/.yaml files (YAML format), checks for `class: GalaxyWorkflow` or `class: GalaxyWorkflow`.
*
* Content validation reads only the first 1KB of the file for efficiency.
*/
export async function validateWorkflowFile(
file: File,
options: { checkContent?: boolean } = {},
): Promise<{ valid: boolean; error?: string }> {
if (!hasWorkflowFileExtension(file.name)) {
return { valid: false, error: `Invalid file type. Accepted formats: ${WORKFLOW_FILE_ACCEPT}` };
}
if (options.checkContent) {
const lowerName = file.name.toLowerCase();
if (lowerName.endsWith(".ga")) {
const hasMarker = await readFileMarker(file, '"a_galaxy_workflow"');
if (!hasMarker) {
return {
valid: false,
error: "File does not appear to be a Galaxy workflow (.ga). Missing workflow marker.",
};
}
} else if (lowerName.endsWith(".yml") || lowerName.endsWith(".yaml")) {
const hasMarker = await readFileMarker(file, "class:");
if (!hasMarker) {
return {
valid: false,
error: "File does not appear to be a Galaxy workflow (.yml/.yaml). Missing 'class:' marker.",
};
}
}
}
return { valid: true };
}
/**
* Read the first 1KB of a file and check if it contains the given marker string.
* This is efficient and avoids loading the entire file into memory.
*/
async function readFileMarker(file: File, marker: string): Promise<boolean> {
try {
const slice = file.slice(0, 1024);
const text = await slice.text();
return text.includes(marker);
} catch {
return false;
}
}
@@ -1,27 +1,50 @@
<script setup lang="ts">
import { faEdit, faFileContract } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { storeToRefs } from "pinia";
import { computed, provide, ref } from "vue";
import { useRouter } from "vue-router/composables";
import { fetchInvocationReport } from "@/api/invocations";
import { useConfig } from "@/composables/config";
import { useConfirmDialog } from "@/composables/confirmDialog.js";
import { useToast } from "@/composables/toast";
import { usePageEditorStore } from "@/stores/pageEditorStore.js";
import { errorMessageAsString } from "@/utils/simple-error.js";
import GButton from "../BaseComponents/GButton.vue";
import HelpText from "../Help/HelpText.vue";
import Markdown from "@/components/Markdown/Markdown.vue";
const props = defineProps<{
invocationId: string;
/** Whether to enforce displaying a "Runtime Report" heading with help text */
fromRuntimeReport?: boolean;
historyId?: string;
}>();
const { config, isConfigLoaded } = useConfig(true);
const { confirm } = useConfirmDialog();
const Toast = useToast();
const router = useRouter();
const pageEditorStore = usePageEditorStore();
const { isLoadingPage } = storeToRefs(pageEditorStore);
const markdownConfig = ref({});
const isCreatingReport = ref(false);
const exportUrl = computed(() => `/api/invocations/${props.invocationId}/report.pdf`);
const editButtonConfig = computed(() => ({
tooltip: "Click to create an editable report from this invocation's Runtime Report",
label: "Edit",
disabled: isLoadingPage.value,
}));
fetchReport();
async function fetchReport() {
@@ -33,8 +56,40 @@ async function fetchReport() {
}
}
function onEdit() {
router.push(`/pages/create?invocation_id=${props.invocationId}`);
async function onEdit() {
if (props.historyId) {
isCreatingReport.value = true;
try {
const confirmed = await confirm(
"Are you sure you want to create a new Invocation Report? This report will have context of the history and invocation that created the Runtime Report.",
{
title: "Create a new Invocation Report?",
okText: "Create Report",
okIcon: faEdit,
},
);
if (!confirmed) {
return;
}
pageEditorStore.setCurrentContext(props.historyId, props.invocationId);
const page = await pageEditorStore.createPage();
if (page) {
const editUrl =
`/histories/${props.historyId}/pages/${page.id}` +
(props.invocationId ? `?invocation_id=${props.invocationId}` : "");
router.push(editUrl);
} else {
pageEditorStore.clearCurrentPage();
}
} catch (error) {
Toast.error(errorMessageAsString(error), "Failed to create a Galaxy Notebook");
} finally {
isCreatingReport.value = false;
}
} else {
router.push(`/pages/create?invocation_id=${props.invocationId}`);
}
}
// Provide invocationId to all descendant components for inline directive resolution
@@ -45,9 +100,28 @@ provide("invocationId", props.invocationId);
<Markdown
v-if="isConfigLoaded"
:markdown-config="markdownConfig"
:edit-button-config="editButtonConfig"
:enable_beta_markdown_export="config.enable_beta_markdown_export"
:export-link="exportUrl"
:download-endpoint="exportUrl"
direct-download-link
@onEdit="onEdit" />
@onEdit="onEdit">
<template v-if="props.fromRuntimeReport" v-slot:heading>
Runtime Report
<HelpText uri="galaxy.invocations.reports.runtimeReport" info-icon />
</template>
<template v-slot:extra-actions>
<GButton
size="small"
tooltip
title="Click to view existing/edited reports for this workflow invocation"
outline
color="blue"
@click="router.push(`/workflows/invocations/${props.invocationId}/reports`)">
View Existing Reports
<FontAwesomeIcon :icon="faFileContract" />
</GButton>
</template>
</Markdown>
</template>
@@ -1,10 +1,14 @@
<script setup lang="ts">
import { faCloudUploadAlt, faFileImport, faIdBadge, faLink, faSearch } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { computed, nextTick, onMounted, type Ref, ref } from "vue";
import { useRoute } from "vue-router/composables";
import { useWizard } from "@/components/Common/Wizard/useWizard";
import type { TrsSelection } from "@/components/Workflow/Import/types";
import { validateWorkflowFile } from "@/components/Workflow/Import/workflowValidation";
import { Services } from "@/components/Workflow/services";
import { useFileDrop } from "@/composables/fileDrop";
import GCard from "@/components/Common/GCard.vue";
import GenericWizard from "@/components/Common/Wizard/GenericWizard.vue";
@@ -22,6 +26,8 @@ const trsServers: Ref<TrsSelection[]> = ref([]);
const fileComponent = ref<InstanceType<typeof FromFile>>();
const urlComponent = ref<InstanceType<typeof FromUrl>>();
const trsComponent = ref<InstanceType<typeof TrsImport>>();
const uploadCardRef = ref<HTMLElement | null>(null);
const droppedFile: Ref<File | null> = ref(null);
// Validation states for each step
const uploadValid = ref(false);
@@ -30,6 +36,32 @@ const trsSearchValid = ref(false);
const trsUrlValid = ref(false);
const trsIdValid = ref(false);
// Drop zone on the "Upload file" method card — allows dropping a workflow file
// directly on the card to skip straight to the upload step with the file pre-populated
async function onUploadCardDrop(evt: DragEvent) {
if (evt.dataTransfer?.files?.length) {
const file = evt.dataTransfer.files[0];
if (!file) {
return;
}
const result = await validateWorkflowFile(file, { checkContent: true });
if (result.valid) {
droppedFile.value = file;
selectMethod("upload");
}
// If invalid, the drop is silently ignored — the user can still click the card
}
}
const { isFileOverDropZone: isFileOverUploadCard } = useFileDrop({
dropZone: uploadCardRef,
onDrop: onUploadCardDrop,
onDropCancel: () => {},
solo: false,
idleTime: 10000,
ignoreChildrenOnLeave: true,
});
const queryParams = computed(() => ({
trsId: route.query.trs_id as string | undefined,
trsUrl: route.query.trs_url as string | undefined,
@@ -153,10 +185,26 @@ onMounted(async () => {
function selectMethod(method: ImportMethod) {
selectedMethod.value = method;
// Navigate to the appropriate step based on the selected method
if (method === "upload") {
wizard.goTo("upload-file");
} else if (method === "fetch") {
wizard.goTo("fetch-url");
} else if (method === "repository") {
wizard.goTo("select-trs-method");
}
}
function selectTrsMethod(method: TrsMethod) {
selectedTrsMethod.value = method;
// Navigate to the appropriate TRS step
if (method === "search") {
wizard.goTo("trs-search");
} else if (method === "url") {
wizard.goTo("trs-url");
} else if (method === "id") {
wizard.goTo("trs-id");
}
}
function onSubmit() {
@@ -202,17 +250,24 @@ function onTrsIdValid(e: boolean) {
<div v-if="wizard.isCurrent('select-method')" class="method-selection">
<div class="row">
<div class="col-xl-4 my-5">
<GCard
class="h-100 workflow-import-file-link text-center"
:class="{ selected: selectedMethod === 'upload' }"
:clickable="true"
@click="selectMethod('upload')">
<h4>Upload file</h4>
<p class="text-muted mb-0">
Upload a <code>*.ga</code> file from your computer. These files can be downloaded from
Galaxy servers or workflow repositories.
</p>
</GCard>
<div
ref="uploadCardRef"
class="h-100"
:class="{ 'drop-zone-active': isFileOverUploadCard }"
data-galaxy-file-drop-target>
<GCard
class="h-100 workflow-import-file-link text-center"
:class="{ selected: selectedMethod === 'upload' }"
:clickable="true"
@click="selectMethod('upload')">
<FontAwesomeIcon :icon="faFileImport" size="3x" class="mb-3 text-muted" />
<h4>Upload file</h4>
<p class="text-muted mb-0">
Upload a <code>*.ga</code> or <code>*.gxwf.yml</code> file from your computer. You
can also drag and drop a file here.
</p>
</GCard>
</div>
</div>
<div class="col-xl-4 my-5">
@@ -221,10 +276,11 @@ function onTrsIdValid(e: boolean) {
:class="{ selected: selectedMethod === 'fetch' }"
:clickable="true"
@click="selectMethod('fetch')">
<FontAwesomeIcon :icon="faLink" size="3x" class="mb-3 text-muted" />
<h4>Fetch URL</h4>
<p class="text-muted mb-0">
Fetch a remote <code>*.ga</code> file from any publicly accessible URL. This can be
generated by any Galaxy server, or public repositories like GitHub.
Fetch a remote <code>Galaxy Workflow</code> file from any publicly accessible URL. This
can be generated by any Galaxy server, or public repositories like GitHub.
</p>
</GCard>
</div>
@@ -235,6 +291,7 @@ function onTrsIdValid(e: boolean) {
:class="{ selected: selectedMethod === 'repository' }"
:clickable="true"
@click="selectMethod('repository')">
<FontAwesomeIcon :icon="faCloudUploadAlt" size="3x" class="mb-3 text-muted" />
<h4>Import from repository</h4>
<p class="text-muted mb-0">
Search and import workflows from our configured workflow repositories ({{
@@ -248,7 +305,7 @@ function onTrsIdValid(e: boolean) {
<div v-else-if="wizard.isCurrent('upload-file')" class="import-form">
<div class="container-narrow">
<FromFile ref="fileComponent" @input-valid="onUploadValid" />
<FromFile ref="fileComponent" :dropped-file="droppedFile" @input-valid="onUploadValid" />
</div>
</div>
@@ -266,6 +323,7 @@ function onTrsIdValid(e: boolean) {
:class="{ selected: selectedTrsMethod === 'search' }"
:clickable="true"
@click="selectTrsMethod('search')">
<FontAwesomeIcon :icon="faSearch" size="3x" class="mb-3 text-muted" />
<h4>Search workflow registries</h4>
<p class="text-muted mb-0">Search for workflows across configured GA4GH servers.</p>
</GCard>
@@ -277,6 +335,7 @@ function onTrsIdValid(e: boolean) {
:class="{ selected: selectedTrsMethod === 'url' }"
:clickable="true"
@click="selectTrsMethod('url')">
<FontAwesomeIcon :icon="faLink" size="3x" class="mb-3 text-muted" />
<h4>TRS URL</h4>
<p class="text-muted mb-0">Import directly from any GA4GH server with a TRS URL.</p>
</GCard>
@@ -288,6 +347,7 @@ function onTrsIdValid(e: boolean) {
:class="{ selected: selectedTrsMethod === 'id' }"
:clickable="true"
@click="selectTrsMethod('id')">
<FontAwesomeIcon :icon="faIdBadge" size="3x" class="mb-3 text-muted" />
<h4>TRS ID</h4>
<p class="text-muted mb-0">
When you know the TRS ID for a workflow in one of the configured GA4GH servers.
@@ -355,4 +415,9 @@ function onTrsIdValid(e: boolean) {
width: 100%;
max-width: 600px;
}
.drop-zone-active {
border-radius: 0.3rem;
outline: 2px dashed var(--brand-primary, #2c3143);
outline-offset: 2px;
}
</style>
@@ -12,6 +12,10 @@ import WorkflowInvocationState from "./WorkflowInvocationState.vue";
const localVue = getLocalVue();
localVue.use(PiniaVuePlugin);
vi.mock("vue-router/composables", () => ({
useRoute: vi.fn(() => ({})),
}));
const selectors = {
invocationSummary: ".invocation-overview",
bAlertStub: "balert-stub",
@@ -3,6 +3,7 @@ import { faExclamation, faSpinner, faSquare, faTimes } from "@fortawesome/free-s
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BAlert, BBadge, BNav, BNavItem } from "bootstrap-vue";
import { computed, onUnmounted, ref, watch } from "vue";
import { useRoute } from "vue-router/composables";
import { type InvocationStep, isWorkflowInvocationElementView } from "@/api/invocations";
import { usePersistentToggle } from "@/composables/persistentToggle";
@@ -20,6 +21,7 @@ import {
} from "./util";
import GButton from "../BaseComponents/GButton.vue";
import HistoryPageView from "../PageEditor/HistoryPageView.vue";
import ProgressBar from "../ProgressBar.vue";
import WorkflowInvocationSteps from "../Workflow/Invocation/Graph/WorkflowInvocationSteps.vue";
import InvocationReport from "../Workflow/InvocationReport.vue";
@@ -35,7 +37,7 @@ import WorkflowInvocationSearch from "./WorkflowInvocationSearch.vue";
import WorkflowInvocationShare from "./WorkflowInvocationShare.vue";
import LoadingSpan from "@/components/LoadingSpan.vue";
type InvocationViewTab = "steps" | "inputs" | "outputs" | "report" | "export" | "metrics" | "debug";
type InvocationViewTab = "steps" | "inputs" | "outputs" | "report" | "reports" | "export" | "metrics" | "debug";
interface Props {
invocationId: string;
@@ -54,6 +56,8 @@ const emit = defineEmits<{
(e: "invocation-cancelled"): void;
}>();
const route = useRoute();
const invocationStore = useInvocationStore();
const stepStatesInterval = ref<any>(undefined);
@@ -65,6 +69,11 @@ const isPolling = ref(false);
const { toggled: headerCollapsed, toggle: toggleHeaderCollapse } = usePersistentToggle("invocation-header-collapsed");
/** Returns the ID of the currently viewed Galaxy notebook report, if any */
const currentViewedReportId = computed<string | undefined>(() =>
props.tab === "reports" && typeof route.query.id === "string" ? route.query.id : undefined,
);
const uniqueMessages = computed(() => {
const messages = invocation.value?.messages || [];
const uniqueMessagesSet = new Set(messages.map((message) => JSON.stringify(message)));
@@ -93,7 +102,10 @@ const disabledTabTooltip = computed(() => {
/** We are on the default "Overview" tab if the tab prop is not set or is not one of the expected tab values */
const onOverviewTab = computed(() => {
return !props.tab || !["steps", "inputs", "outputs", "report", "export", "metrics", "debug"].includes(props.tab);
return (
!props.tab ||
!["steps", "inputs", "outputs", "report", "reports", "export", "metrics", "debug"].includes(props.tab)
);
});
const invocation = computed(() => {
@@ -397,7 +409,7 @@ async function onCancel() {
<BNavItem
:title="!tabsDisabled ? 'Report' : disabledTabTooltip"
class="invocation-report-tab"
:active="!tabsDisabled && props.tab === 'report'"
:active="!tabsDisabled && (props.tab === 'report' || props.tab === 'reports')"
:to="`/workflows/invocations/${props.invocationId}/report`"
:disabled="tabsDisabled">
Report
@@ -450,7 +462,7 @@ async function onCancel() {
</div>
</BNav>
<div class="mt-1 d-flex flex-column tab-content-container">
<div class="mt-1 d-flex flex-column overflow-auto tab-content-container">
<div v-if="onOverviewTab">
<WorkflowInvocationOverview
class="invocation-overview"
@@ -476,7 +488,7 @@ async function onCancel() {
:invocation="invocation"
:terminal="invocationAndJobTerminal"
:tab="props.tab" />
<div v-if="props.tab === 'report'">
<div v-if="props.tab === 'report' || props.tab === 'reports'" class="steps-tab-content">
<BAlert v-if="isSubworkflow" variant="info" show>
<span v-localize>Report is not available for subworkflow.</span>
</BAlert>
@@ -484,7 +496,17 @@ async function onCancel() {
v-else-if="tabsDisabled"
:invocation-id="props.invocationId"
:tooltip="disabledTabTooltip" />
<InvocationReport v-else :invocation-id="invocation.id" />
<InvocationReport
v-else-if="props.tab === 'report'"
:invocation-id="invocation.id"
:history-id="invocation.history_id"
from-runtime-report />
<HistoryPageView
v-else
:invocation-id="props.invocationId"
:history-id="invocation.history_id"
:page-id="currentViewedReportId"
display-only />
</div>
<div v-if="props.tab === 'export'">
<TabsDisabledAlert
@@ -517,12 +539,19 @@ async function onCancel() {
<BAlert v-else-if="!invocationLoaded" variant="info" show>
<LoadingSpan message="Loading invocation" />
</BAlert>
<BAlert v-else-if="invocationStore.getInvocationLoadError(props.invocationId)" variant="danger" show>
{{ invocationStore.getInvocationLoadError(props.invocationId) }}
</BAlert>
<BAlert v-else variant="info" show>
<span v-localize>Invocation not found.</span>
</BAlert>
</template>
<style lang="scss">
.alert {
height: unset !important;
}
// To show the tooltip on the disabled report tab badge
.invocation-report-tab,
.invocation-export-tab {
@@ -152,8 +152,9 @@ export function useUploadBatchOperations(options: UploadBatchOperationsOptions =
collectionType: batch.type,
},
{
progress: (percentage) => {
ids.forEach((id) => uploadState.updateProgress(id, percentage));
uploadIds: ids,
perFileProgress: (fileId, percentage) => {
uploadState.updateProgress(fileId, percentage);
},
success: () => {
ids.forEach((id) => uploadState.updateProgress(id, 100));
@@ -10,7 +10,6 @@ import {
markTrackedCompleted,
markTrackedError,
splitTrackedUploadsByType,
updateTrackedProgress,
} from "@/composables/upload/uploadTracking";
import { useUploadBatchOperations } from "@/composables/upload/useUploadBatchOperations";
import { errorMessageAsString } from "@/utils/simple-error";
@@ -105,7 +104,10 @@ export function useUploadSubmission() {
},
progress: (percentage) => {
onProgress?.(percentage);
updateTrackedProgress(uploadState, apiIds, percentage);
},
uploadIds: apiIds,
perFileProgress: (fileId, percentage) => {
uploadState.updateProgress(fileId, percentage);
},
};
@@ -1,6 +1,9 @@
import { faFile, faMagic, faSitemap, faWrench } from "@fortawesome/free-solid-svg-icons";
import { describe, expect, it, vi } from "vitest";
import { type Ref, ref } from "vue";
import { PAGE_LABELS } from "@/components/Page/constants";
import type { ActiveContext } from "./useActiveContext";
// Mock route as a reactive ref so tests can mutate it
@@ -118,6 +121,60 @@ describe("useActiveContext", () => {
jobId: "job-7",
});
});
it("detects notebook context from notebook editor route", () => {
const { activeContext } = withRoute(
"/histories/test-history-id/pages/test-page-id",
{},
{ historyId: "test-history-id", pageId: "test-page-id" },
);
expect(activeContext.value).toEqual({
contextType: "notebook",
pageId: "test-page-id",
historyId: "test-history-id",
});
});
it("returns null for slightly invalid notebook editor route", () => {
// Note how the route has "page" instead of "pages"
const { activeContext } = withRoute(
"/histories/test-history-id/page/test-page-id",
{},
{ historyId: "test-history-id", pageId: "test-page-id" },
);
expect(activeContext.value).toBeNull();
});
it("detects notebook context from invocation reports route", () => {
const { activeContext } = withRoute(
"/workflows/invocations/inv-1/reports",
{ id: "p-1" },
{ invocationId: "inv-1" },
);
expect(activeContext.value).toEqual({
contextType: "notebook",
pageId: "p-1",
invocationId: "inv-1",
});
});
it("returns null for invocation reports route without page id query param", () => {
const { activeContext } = withRoute("/workflows/invocations/inv-1/reports", {}, { invocationId: "inv-1" });
expect(activeContext.value).toBeNull();
});
it("detects notebook context from page editor route", () => {
const { activeContext } = withRoute("/pages/editor", { id: "test-page-id" }, {});
expect(activeContext.value).toEqual({
contextType: "notebook",
pageId: "test-page-id",
});
});
it("returns null for page editor route without id", () => {
const { activeContext } = withRoute("/pages/editor");
expect(activeContext.value).toBeNull();
});
});
describe("contextLabel", () => {
@@ -160,5 +217,69 @@ describe("useActiveContext", () => {
const { contextLabel } = withRoute("/jobs/j-5", {}, { jobId: "j-5" });
expect(contextLabel.value).toBe("Job: j-5");
});
it("labels notebook context", () => {
const { contextLabel } = withRoute(
"/histories/test-history-id/pages/test-page-id",
{},
{ historyId: "test-history-id", pageId: "test-page-id" },
);
expect(contextLabel.value).toBe("Galaxy Notebook: test-page-id");
});
it("labels standalone page editor context without historyId", () => {
const { contextLabel } = withRoute("/pages/editor", { id: "test-page-id" }, {});
expect(contextLabel.value).toBe("Report: test-page-id");
});
it("labels invocation notebook context", () => {
const { contextLabel } = withRoute(
"/workflows/invocations/inv-1/reports",
{ id: "p-1" },
{ invocationId: "inv-1" },
);
expect(contextLabel.value).toBe("Invocation Report: p-1");
});
});
describe("contextIcon", () => {
it("returns faWrench for tool context", () => {
const { contextIcon } = withRoute("/", { tool_id: "bwa" });
expect(contextIcon.value).toBe(faWrench);
});
it("returns faFile for dataset context", () => {
const { contextIcon } = withRoute("/datasets/d-1", {}, { datasetId: "d-1" });
expect(contextIcon.value).toBe(faFile);
});
it("returns faSitemap for workflow editor context", () => {
const { contextIcon } = withRoute("/workflows/edit", { id: "wf-1" });
expect(contextIcon.value).toBe(faSitemap);
});
it("returns faSitemap for workflow run context", () => {
const { contextIcon } = withRoute("/workflows/run", { id: "wf-1" });
expect(contextIcon.value).toBe(faSitemap);
});
it("returns history notebook icon for history notebook context", () => {
const { contextIcon } = withRoute("/histories/h-1/pages/p-1", {}, { historyId: "h-1", pageId: "p-1" });
expect(contextIcon.value).toBe(PAGE_LABELS.history.titleIcon);
});
it("returns invocation notebook icon for invocation notebook context", () => {
const { contextIcon } = withRoute(
"/workflows/invocations/inv-1/reports",
{ id: "p-1" },
{ invocationId: "inv-1" },
);
expect(contextIcon.value).toBe(PAGE_LABELS.invocation.titleIcon);
});
it("returns faMagic for unrecognized routes", () => {
const { contextIcon } = withRoute("/some/unknown");
expect(contextIcon.value).toBe(faMagic);
});
});
});
+68 -1
View File
@@ -1,6 +1,8 @@
import { faFile, faMagic, faSitemap, faWrench } from "@fortawesome/free-solid-svg-icons";
import { computed } from "vue";
import { useRoute } from "vue-router/composables";
import { PAGE_LABELS } from "@/components/Page/constants";
import { useToolStore } from "@/stores/toolStore";
export type ActiveContext =
@@ -8,7 +10,8 @@ export type ActiveContext =
| { contextType: "dataset"; datasetId: string }
| { contextType: "workflow_editor"; workflowId: string }
| { contextType: "workflow_run"; workflowId: string }
| { contextType: "job"; jobId: string; toolId?: string };
| { contextType: "job"; jobId: string; toolId?: string }
| { contextType: "notebook"; pageId: string; historyId?: string; invocationId?: string };
export function useActiveContext() {
const route = useRoute();
@@ -67,6 +70,37 @@ export function useActiveContext() {
};
}
if (path.startsWith("/workflows/invocations/") && params.invocationId && query.id) {
const invocationId = String(params.invocationId);
if (path === `/workflows/invocations/${invocationId}/reports`) {
return {
contextType: "notebook",
pageId: String(query.id),
invocationId,
};
}
}
if (path.startsWith("/histories/") && params.historyId && params.pageId) {
const historyId = String(params.historyId);
const pageId = String(params.pageId);
if (path === `/histories/${historyId}/pages/${pageId}`) {
return {
contextType: "notebook",
pageId,
historyId,
invocationId: query.invocation_id ? String(query.invocation_id) : undefined,
};
}
}
if (path === "/pages/editor" && query.id) {
return {
contextType: "notebook",
pageId: String(query.id),
};
}
return null;
});
@@ -86,13 +120,46 @@ export function useActiveContext() {
return `Running workflow: ${ctx.workflowId}`;
case "job":
return `Job: ${ctx.jobId}`;
case "notebook": {
const notebookLabels = getNotebookLabels(ctx);
return `${notebookLabels.entityName}: ${ctx.pageId}`;
}
default:
return null;
}
});
const contextIcon = computed(() => {
switch (activeContext.value?.contextType) {
case "tool":
return faWrench;
case "dataset":
return faFile;
case "workflow_editor":
case "workflow_run":
return faSitemap;
case "notebook": {
const notebookLabels = getNotebookLabels(activeContext.value);
return notebookLabels.titleIcon;
}
default:
return faMagic;
}
});
function getNotebookLabels(noteBookContext: Extract<ActiveContext, { contextType: "notebook" }>) {
if (noteBookContext.invocationId) {
return PAGE_LABELS.invocation;
} else if (noteBookContext.historyId) {
return PAGE_LABELS.history;
} else {
return PAGE_LABELS.standalone;
}
}
return {
activeContext,
contextIcon,
contextLabel,
};
}

Some files were not shown because too many files have changed in this diff Show More