mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-09-24 16:30:27 +08:00
Merge pull request #22982 from mvdbeek/merge_release_26.1_into_dev
Merge release_26.1 into dev
This commit is contained in:
@@ -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?: {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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)"
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 & 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,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">−</button>
|
||||
<button class="window-manager-window-btn" aria-label="Maximize" @click="onMaximize">
|
||||
<span v-if="window.maximized">◼</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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user