Merge branch 'release_24.0' into dev

This commit is contained in:
mvdbeek
2024-03-11 12:02:17 +01:00
38 changed files with 862 additions and 493 deletions
+6 -8
View File
@@ -9752,7 +9752,7 @@ export interface components {
* Name
* @description The name of the creator.
*/
name: string;
name?: string | null;
/** Telephone */
telephone?: string | null;
/** URL */
@@ -11005,10 +11005,9 @@ export interface components {
};
/**
* Latest workflow UUID
* Format: uuid4
* @description TODO
*/
latest_workflow_uuid: string;
latest_workflow_uuid?: string | null;
/**
* License
* @description SPDX Identifier of the license associated with this workflow.
@@ -12108,18 +12107,17 @@ export interface components {
* Label
* @description Label of the input.
*/
label: string;
label: string | null;
/**
* UUID
* Format: uuid4
* @description Universal unique identifier of the input.
*/
uuid: string;
uuid: string | null;
/**
* Value
* @description TODO
*/
value: string;
value: Record<string, never> | null;
};
/** WorkflowInvocationCollectionView */
WorkflowInvocationCollectionView: {
@@ -12475,7 +12473,7 @@ export interface components {
* Name
* @description The name of the creator.
*/
name: string;
name?: string | null;
/** Telephone */
telephone?: string | null;
/** URL */
@@ -112,7 +112,7 @@ const gridConfig: GridConfig = {
filtering: new Filtering(validFilters, undefined, false, false),
getData: getData,
plural: "Histories",
sortBy: "name",
sortBy: "update_time",
sortDesc: true,
sortKeys: ["create_time", "name", "update_time", "username"],
title: "Shared Histories",
@@ -5,6 +5,8 @@ import { PiniaVuePlugin } from "pinia";
import { getLocalVue } from "tests/jest/helpers";
import VueRouter from "vue-router";
import { mockFetcher } from "@/api/schema/__mocks__";
import ContentItem from "./ContentItem";
jest.mock("components/History/model/queries");
@@ -14,23 +16,31 @@ localVue.use(VueRouter);
localVue.use(PiniaVuePlugin);
const router = new VueRouter();
jest.mock("vue-router/composables", () => ({
useRoute: jest.fn(() => ({})),
useRouter: jest.fn(() => ({})),
}));
// mock queries
updateContentFields.mockImplementation(async () => {});
const item = {
id: "item_id",
some_data: "some_data",
tags: ["tag1", "tag2", "tag3"],
deleted: false,
visible: true,
};
describe("ContentItem", () => {
let wrapper;
beforeEach(() => {
mockFetcher.path("/api/datasets/{dataset_id}").method("get").mock({ data: item });
wrapper = mount(ContentItem, {
propsData: {
expandDataset: true,
item: {
id: "item_id",
some_data: "some_data",
tags: ["tag1", "tag2", "tag3"],
deleted: false,
visible: true,
},
item,
id: 1,
isDataset: true,
isHistoryItem: true,
@@ -1,3 +1,300 @@
<script setup lang="ts">
import { library } from "@fortawesome/fontawesome-svg-core";
import { faCheckSquare, faSquare } from "@fortawesome/free-regular-svg-icons";
import { faArrowCircleDown, faArrowCircleUp, faCheckCircle, faSpinner } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BBadge, BButton, BCollapse } from "bootstrap-vue";
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router/composables";
import type { ItemUrls } from "@/components/History/Content/Dataset/index";
import { updateContentFields } from "@/components/History/model/queries";
import { useEntryPointStore } from "@/stores/entryPointStore";
import { useEventStore } from "@/stores/eventStore";
import { clearDrag } from "@/utils/setDrag";
import { JobStateSummary } from "./Collection/JobStateSummary";
import { HIERARCHICAL_COLLECTION_JOB_STATES, type StateMap, STATES } from "./model/states";
import CollectionDescription from "./Collection/CollectionDescription.vue";
import ContentOptions from "./ContentOptions.vue";
import DatasetDetails from "./Dataset/DatasetDetails.vue";
import StatelessTags from "@/components/TagsMultiselect/StatelessTags.vue";
library.add(faArrowCircleUp, faArrowCircleDown, faCheckCircle, faSpinner);
const router = useRouter();
const route = useRoute();
interface Props {
id: number;
item: any;
name: string;
expandDataset?: boolean;
writable?: boolean;
addHighlightBtn?: boolean;
highlight?: string;
isDataset?: boolean;
isHistoryItem?: boolean;
selected?: boolean;
selectable?: boolean;
filterable?: boolean;
isPlaceholder?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
expandDataset: false,
writable: true,
addHighlightBtn: false,
highlight: undefined,
isDataset: true,
isHistoryItem: false,
selected: false,
selectable: false,
filterable: false,
isPlaceholder: false,
});
const emit = defineEmits<{
(e: "update:selected", selected: boolean): void;
(e: "update:expand-dataset", expand: boolean): void;
(e: "shift-select", direction: string): void;
(e: "init-key-selection"): void;
(e: "arrow-navigate", direction: string): void;
(e: "hide-selection"): void;
(e: "select-all"): void;
(e: "selected-to", reset: boolean): void;
(e: "delete", item: any, recursive: boolean): void;
(e: "undelete"): void;
(e: "unhide"): void;
(e: "view-collection", item: any, name: string): void;
(e: "drag-start", evt: DragEvent): void;
(e: "tag-change", item: any, newTags: Array<string>): void;
(e: "tag-click", tag: string): void;
(e: "toggleHighlights", item: any): void;
}>();
const entryPointStore = useEntryPointStore();
const eventStore = useEventStore();
const jobState = computed(() => {
return new JobStateSummary(props.item);
});
const contentId = computed(() => {
return `dataset-${props.item.id}`;
});
const contentCls = computed(() => {
const status = contentState.value && contentState.value.status;
if (props.selected) {
return "alert-info";
} else if (!status) {
return `alert-success`;
} else {
return `alert-${status}`;
}
});
const contentState = computed(() => {
return STATES[state.value] && STATES[state.value];
});
const hasTags = computed(() => {
return tags.value && tags.value.length > 0;
});
const hasStateIcon = computed(() => {
return contentState.value && contentState.value.icon;
});
const state = computed<keyof StateMap>(() => {
if (props.isPlaceholder) {
return "placeholder";
}
if (props.item.populated_state === "failed") {
return "failed_populated_state";
}
if (props.item.populated_state === "new") {
return "new_populated_state";
}
if (props.item.job_state_summary) {
for (const jobState of HIERARCHICAL_COLLECTION_JOB_STATES) {
if (props.item.job_state_summary[jobState] > 0) {
return jobState;
}
}
} else if (props.item.state) {
return props.item.state;
}
return "ok";
});
const dataState = computed(() => {
return state.value === "new_populated_state" ? "new" : state.value;
});
const tags = computed(() => {
return props.item.tags;
});
const tagsDisabled = computed(() => {
return !props.writable || !props.expandDataset || !props.isHistoryItem;
});
const isCollection = computed(() => {
return "collection_type" in props.item;
});
const itemUrls = computed<ItemUrls>(() => {
const id = props.item.id;
if (isCollection.value) {
return {
edit: `/collection/${id}/edit`,
showDetails:
props.item.job_source_id && props.item.job_source_type === "Job"
? `/jobs/${props.item.job_source_id}/view`
: null,
};
}
return {
display: `/datasets/${id}/preview`,
edit: `/datasets/${id}/edit`,
showDetails: `/datasets/${id}/details`,
reportError: `/datasets/${id}/error`,
rerun: `/tool_runner/rerun?id=${id}`,
visualize: `/visualizations?dataset_id=${id}`,
};
});
const isBeingUsed = computed(() => {
return Object.values(itemUrls.value).includes(route.path) ? "being-used" : "";
});
/** Based on the user's keyboard platform, checks if it is the
* typical key for selection (ctrl for windows/linux, cmd for mac)
*/
function isSelectKey(event: KeyboardEvent) {
return eventStore.isMac ? event.metaKey : event.ctrlKey;
}
function onKeyDown(event: KeyboardEvent) {
if (!(event.target as HTMLElement)?.classList?.contains("content-item")) {
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onClick(event);
} else if ((event.key === "ArrowUp" || event.key === "ArrowDown") && event.shiftKey) {
event.preventDefault();
emit("shift-select", event.key);
} else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
emit("init-key-selection");
emit("arrow-navigate", event.key);
} else if (event.key === "Tab") {
emit("init-key-selection");
} else if (event.key === "Delete" && !props.selected && !props.item.deleted) {
event.preventDefault();
onDelete(event.shiftKey);
} else if (event.key === "Escape") {
event.preventDefault();
emit("hide-selection");
} else if (event.key === "a" && isSelectKey(event)) {
event.preventDefault();
emit("select-all");
}
}
function onClick(e: Event) {
const event = e as KeyboardEvent;
if (event && event.shiftKey && isSelectKey(event)) {
emit("selected-to", false);
} else if (event && isSelectKey(event)) {
emit("init-key-selection");
emit("update:selected", !props.selected);
} else if (event && event.shiftKey) {
emit("selected-to", true);
} else if (props.isPlaceholder) {
emit("init-key-selection");
} else if (props.isDataset) {
emit("init-key-selection");
emit("update:expand-dataset", !props.expandDataset);
} else {
emit("view-collection", props.item, props.name);
}
}
function onDisplay() {
const entryPointsForHda = entryPointStore.entryPointsForHda(props.item.id);
if (entryPointsForHda && entryPointsForHda.length > 0) {
// there can be more than one entry point, choose the first
const url = entryPointsForHda[0]?.target;
window.open(url, "_blank");
} else {
// vue-router 4 supports a native force push with clean URLs,
// but we're using a __vkey__ bit as a workaround
// Only conditionally force to keep urls clean most of the time.
if (route.path === itemUrls.value.display) {
// @ts-ignore
router.push(itemUrls.value.display, { title: props.name, force: true });
} else if (itemUrls.value.display) {
router.push({ path: itemUrls.value.display, params: { title: props.name } });
}
}
}
function onDelete(recursive = false) {
emit("delete", props.item, recursive);
emit("update:selected", false);
emit("init-key-selection");
}
function onUndelete() {
emit("undelete");
emit("update:selected", false);
emit("init-key-selection");
}
function onDragStart(evt: DragEvent) {
emit("drag-start", evt);
}
function onDragEnd() {
clearDrag();
}
function onEdit() {
router.push(itemUrls.value.edit!);
}
function onShowCollectionInfo() {
router.push(itemUrls.value.showDetails!);
}
function onTags(newTags: string[]) {
emit("tag-change", props.item, newTags);
updateContentFields(props.item, { tags: newTags });
}
function onTagClick(tag: string) {
if (props.filterable) {
emit("tag-click", tag);
}
}
function toggleHighlights() {
emit("toggleHighlights", props.item);
}
function unexpandedClick(event: Event) {
if (!props.expandDataset) {
onClick(event);
}
}
</script>
<template>
<div
:id="contentId"
@@ -7,23 +304,24 @@
tabindex="0"
role="button"
@keydown="onKeyDown">
<!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events, vuejs-accessibility/no-static-element-interactions -->
<div class="p-1 cursor-pointer" draggable @dragstart="onDragStart" @dragend="onDragEnd" @click.stop="onClick">
<div class="d-flex justify-content-between">
<span class="p-1" data-description="content item header info">
<b-button v-if="selectable" class="selector p-0" @click.stop="$emit('update:selected', !selected)">
<icon v-if="selected" fixed-width size="lg" :icon="['far', 'check-square']" />
<icon v-else fixed-width size="lg" :icon="['far', 'square']" />
</b-button>
<b-button
<BButton v-if="selectable" class="selector p-0" @click.stop="emit('update:selected', !selected)">
<FontAwesomeIcon v-if="selected" fixed-width size="lg" :icon="faCheckSquare" />
<FontAwesomeIcon v-else fixed-width size="lg" :icon="faSquare" />
</BButton>
<BButton
v-if="highlight == 'input'"
v-b-tooltip.hover
variant="link"
class="p-0"
title="Input"
@click.stop="toggleHighlights">
<FontAwesomeIcon class="text-info" icon="arrow-circle-up" />
</b-button>
<b-button
<FontAwesomeIcon class="text-info" :icon="faArrowCircleUp" />
</BButton>
<BButton
v-else-if="highlight == 'active'"
v-b-tooltip.hover
variant="link"
@@ -31,17 +329,17 @@
title="Inputs/Outputs highlighted for this item"
@click.stop="toggleHighlights"
@keypress="toggleHighlights">
<FontAwesomeIcon icon="check-circle" />
</b-button>
<b-button
<FontAwesomeIcon :icon="faCheckCircle" />
</BButton>
<BButton
v-else-if="highlight == 'output'"
v-b-tooltip.hover
variant="link"
class="p-0"
title="Output"
@click.stop="toggleHighlights">
<FontAwesomeIcon class="text-info" icon="arrow-circle-down" />
</b-button>
<FontAwesomeIcon class="text-info" :icon="faArrowCircleDown" />
</BButton>
<span v-if="hasStateIcon" class="state-icon">
<icon
fixed-width
@@ -53,9 +351,9 @@
<span class="content-title name font-weight-bold">{{ name }}</span>
</span>
<span v-if="item.purged" class="align-self-start btn-group p-1">
<b-badge variant="secondary" title="This dataset has been permanently deleted">
<BBadge variant="secondary" title="This dataset has been permanently deleted">
<icon icon="burn" /> Purged
</b-badge>
</BBadge>
</span>
<ContentOptions
v-if="!isPlaceholder && !item.purged"
@@ -66,33 +364,36 @@
:is-visible="item.visible"
:state="state"
:item-urls="itemUrls"
:keyboard-selectable="isCollection || expandDataset"
@delete="onDelete"
@display="onDisplay"
@showCollectionInfo="onShowCollectionInfo"
@edit="onEdit"
@undelete="$emit('undelete')"
@unhide="$emit('unhide')" />
@undelete="onUndelete"
@unhide="emit('unhide')" />
</div>
</div>
<CollectionDescription
v-if="!isDataset"
class="px-2 pb-2"
:job-state-summary="jobState"
:collection-type="item.collection_type"
:element-count="item.element_count"
:elements-datatypes="item.elements_datatypes" />
<StatelessTags
v-if="!tagsDisabled || hasTags"
class="px-2 pb-2"
:value="tags"
:disabled="tagsDisabled"
:clickable="filterable"
:use-toggle-link="false"
@input="onTags"
@tag-click="onTagClick" />
<!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events, vuejs-accessibility/no-static-element-interactions -->
<span @click.stop="unexpandedClick">
<CollectionDescription
v-if="!isDataset"
class="px-2 pb-2 cursor-pointer"
:job-state-summary="jobState"
:collection-type="item.collection_type"
:element-count="item.element_count"
:elements-datatypes="item.elements_datatypes" />
<StatelessTags
v-if="!tagsDisabled || hasTags"
class="px-2 pb-2"
:class="{ 'cursor-pointer': !expandDataset }"
:value="tags"
:disabled="tagsDisabled"
:clickable="filterable"
:use-toggle-link="false"
@input="onTags"
@tag-click="onTagClick" />
</span>
<!-- collections are not expandable, so we only need the DatasetDetails component here -->
<b-collapse :visible="expandDataset" class="px-2 pb-2">
<BCollapse :visible="expandDataset" class="px-2 pb-2">
<DatasetDetails
v-if="expandDataset && item.id"
:id="item.id"
@@ -101,219 +402,10 @@
:item-urls="itemUrls"
@edit="onEdit"
@toggleHighlights="toggleHighlights" />
</b-collapse>
</BCollapse>
</div>
</template>
<script>
import { library } from "@fortawesome/fontawesome-svg-core";
import { faArrowCircleDown, faArrowCircleUp, faCheckCircle, faSpinner } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { updateContentFields } from "components/History/model/queries";
import StatelessTags from "components/TagsMultiselect/StatelessTags";
import { useEntryPointStore } from "stores/entryPointStore";
import { clearDrag } from "@/utils/setDrag.ts";
import CollectionDescription from "./Collection/CollectionDescription";
import { JobStateSummary } from "./Collection/JobStateSummary";
import ContentOptions from "./ContentOptions";
import DatasetDetails from "./Dataset/DatasetDetails";
import { HIERARCHICAL_COLLECTION_JOB_STATES, STATES } from "./model/states";
library.add(faArrowCircleUp, faArrowCircleDown, faCheckCircle, faSpinner);
export default {
components: {
CollectionDescription,
ContentOptions,
DatasetDetails,
StatelessTags,
FontAwesomeIcon,
},
props: {
id: { type: Number, required: true },
item: { type: Object, required: true },
name: { type: String, required: true },
expandDataset: { type: Boolean, default: false },
writable: { type: Boolean, default: true },
addHighlightBtn: { type: Boolean, default: false },
highlight: { type: String, default: null },
isDataset: { type: Boolean, default: true },
isHistoryItem: { type: Boolean, default: false },
selected: { type: Boolean, default: false },
selectable: { type: Boolean, default: false },
filterable: { type: Boolean, default: false },
isPlaceholder: { type: Boolean, default: false },
},
computed: {
jobState() {
return new JobStateSummary(this.item);
},
contentId() {
return `dataset-${this.item.id}`;
},
contentCls() {
const status = this.contentState && this.contentState.status;
if (this.selected) {
return "alert-info";
} else if (!status) {
return `alert-success`;
} else {
return `alert-${status}`;
}
},
contentState() {
return STATES[this.state] && STATES[this.state];
},
hasTags() {
return this.tags && this.tags.length > 0;
},
hasStateIcon() {
return this.contentState && this.contentState.icon;
},
state() {
if (this.isPlaceholder) {
return "placeholder";
}
if (this.item.populated_state === "failed") {
return "failed_populated_state";
}
if (this.item.populated_state === "new") {
return "new_populated_state";
}
if (this.item.job_state_summary) {
for (const state of HIERARCHICAL_COLLECTION_JOB_STATES) {
if (this.item.job_state_summary[state] > 0) {
return state;
}
}
} else if (this.item.state) {
return this.item.state;
}
return "ok";
},
dataState() {
return this.state === "new_populated_state" ? "new" : this.state;
},
tags() {
return this.item.tags;
},
tagsDisabled() {
return !this.writable || !this.expandDataset || !this.isHistoryItem;
},
isCollection() {
return "collection_type" in this.item;
},
/** Relative URLs for history item actions */
itemUrls() {
const id = this.item.id;
if (this.isCollection) {
return {
edit: `/collection/${id}/edit`,
showDetails:
this.item.job_source_id && this.item.job_source_type === "Job"
? `/jobs/${this.item.job_source_id}/view`
: null,
};
}
return {
display: `/datasets/${id}/preview`,
edit: `/datasets/${id}/edit`,
showDetails: `/datasets/${id}/details`,
reportError: `/datasets/${id}/error`,
rerun: `/tool_runner/rerun?id=${id}`,
visualize: `/visualizations?dataset_id=${id}`,
};
},
isBeingUsed() {
return Object.values(this.itemUrls).includes(this.$route.path) ? "being-used" : "";
},
},
methods: {
onKeyDown(event) {
if (!event.target.classList.contains("content-item")) {
return;
}
if (event.key === "Enter" || event.key === " ") {
this.onClick();
} else if ((event.key === "ArrowUp" || event.key === "ArrowDown") && event.shiftKey) {
event.preventDefault();
this.$emit("shift-select", event.key);
} else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
this.$emit("arrow-navigate", event.key);
} else if (event.key === "Delete" && !this.selected && !this.item.deleted) {
event.preventDefault();
this.onDelete(event.shiftKey);
} else if (event.key === "Escape") {
event.preventDefault();
this.$emit("hide-selection");
} else if (event.key === "a" && event.ctrlKey) {
event.preventDefault();
this.$emit("select-all");
}
},
onClick(event) {
if (event && event.ctrlKey) {
this.$emit("update:selected", !this.selected);
} else if (this.isPlaceholder) {
return;
} else if (this.isDataset) {
this.$emit("update:expand-dataset", !this.expandDataset);
} else {
this.$emit("view-collection", this.item, this.name);
}
},
onDisplay() {
const entryPointStore = useEntryPointStore();
const entryPointsForHda = entryPointStore.entryPointsForHda(this.item.id);
if (entryPointsForHda && entryPointsForHda.length > 0) {
// there can be more than one entry point, choose the first
const url = entryPointsForHda[0].target;
window.open(url, "_blank");
} else {
// vue-router 4 supports a native force push with clean URLs,
// but we're using a workaround with a __vkey__ bit as a workaround
// Only conditionally force to keep urls clean most of the time.
if (this.$router.currentRoute.path === this.itemUrls.display) {
this.$router.push(this.itemUrls.display, { title: this.name, force: true });
} else {
this.$router.push(this.itemUrls.display, { title: this.name });
}
}
},
onDelete(recursive = false) {
this.$emit("delete", this.item, recursive);
},
onDragStart(evt) {
this.$emit("drag-start", evt);
},
onDragEnd() {
clearDrag();
},
onEdit() {
this.$router.push(this.itemUrls.edit);
},
onShowCollectionInfo() {
this.$router.push(this.itemUrls.showDetails);
},
onTags(newTags) {
this.$emit("tag-change", this.item, newTags);
updateContentFields(this.item, { tags: newTags });
},
onTagClick(tag) {
if (this.filterable) {
this.$emit("tag-click", tag);
}
},
toggleHighlights() {
this.$emit("toggleHighlights", this.item);
},
},
};
</script>
<style lang="scss" scoped>
@import "~bootstrap/scss/_functions.scss";
@import "theme/blue.scss";
@@ -12,7 +12,6 @@ const props = defineProps({
isVisible: { type: Boolean, default: true },
state: { type: String, default: "" },
itemUrls: { type: Object, required: true },
keyboardSelectable: { type: Boolean, default: true },
});
const emit = defineEmits<{
@@ -46,8 +45,6 @@ const canShowCollectionDetails = computed(() => props.itemUrls.showDetails);
const showCollectionDetailsUrl = computed(() => prependPath(props.itemUrls.showDetails));
const tabindex = computed(() => (props.keyboardSelectable ? "0" : "-1"));
function onDelete($event: MouseEvent) {
if (isCollection.value) {
deleteCollectionMenu.value?.show();
@@ -94,7 +91,7 @@ function onDisplay($event: MouseEvent) {
v-if="isDataset"
:disabled="displayDisabled"
:title="displayButtonTitle"
:tabindex="tabindex"
tabindex="0"
class="display-btn px-1"
size="sm"
variant="link"
@@ -106,7 +103,7 @@ function onDisplay($event: MouseEvent) {
v-if="writable && isHistoryItem"
:disabled="editDisabled"
:title="editButtonTitle"
:tabindex="tabindex"
tabindex="0"
class="edit-btn px-1"
size="sm"
variant="link"
@@ -116,7 +113,7 @@ function onDisplay($event: MouseEvent) {
</b-button>
<b-button
v-if="writable && isHistoryItem && !isDeleted"
:tabindex="tabindex"
:tabindex="isDataset ? '0' : '-1'"
class="delete-btn px-1"
title="Delete"
size="sm"
@@ -139,7 +136,7 @@ function onDisplay($event: MouseEvent) {
</b-button>
<b-button
v-if="writable && isHistoryItem && isDeleted"
:tabindex="tabindex"
tabindex="0"
class="undelete-btn px-1"
title="Undelete"
size="sm"
@@ -149,7 +146,7 @@ function onDisplay($event: MouseEvent) {
</b-button>
<b-button
v-if="writable && isHistoryItem && !isVisible"
:tabindex="tabindex"
tabindex="0"
class="unhide-btn px-1"
title="Unhide"
size="sm"
@@ -58,16 +58,16 @@ const showVisualizations = computed(() => {
return !props.item.purged && ["ok", "failed_metadata", "error"].includes(props.item.state);
});
const reportErrorUrl = computed(() => {
return prependPath(props.itemUrls.reportError);
return prependPath(props.itemUrls.reportError!);
});
const showDetailsUrl = computed(() => {
return prependPath(props.itemUrls.showDetails);
return prependPath(props.itemUrls.showDetails!);
});
const rerunUrl = computed(() => {
return prependPath(props.itemUrls.rerun);
return prependPath(props.itemUrls.rerun!);
});
const visualizeUrl = computed(() => {
return prependPath(props.itemUrls.visualize);
return prependPath(props.itemUrls.visualize!);
});
const downloadUrl = computed(() => {
return prependPath(`api/datasets/${props.item.id}/display?to_ext=${props.item.extension}`);
@@ -83,11 +83,11 @@ function onDownload(resource: string) {
}
function onError() {
router.push(props.itemUrls.reportError);
router.push(props.itemUrls.reportError!);
}
function onInfo() {
router.push(props.itemUrls.showDetails);
router.push(props.itemUrls.showDetails!);
}
function onRerun() {
@@ -95,7 +95,7 @@ function onRerun() {
}
function onVisualize() {
router.push(props.itemUrls.visualize);
router.push(props.itemUrls.visualize!);
}
function onHighlight() {
@@ -1,6 +1,8 @@
export type ItemUrls = {
rerun: string;
visualize: string;
reportError: string;
showDetails: string;
display?: string;
edit: string;
rerun?: string;
visualize?: string;
reportError?: string;
showDetails: string | null;
};
@@ -1,3 +1,4 @@
import { createTestingPinia } from "@pinia/testing";
import { mount } from "@vue/test-utils";
import { getLocalVue } from "tests/jest/helpers";
import VueRouter from "vue-router";
@@ -60,6 +61,7 @@ describe("GenericElement", () => {
},
localVue,
router,
pinia: createTestingPinia(),
});
});
@@ -17,7 +17,7 @@ export default {
items: new Map(),
showSelection: false,
allSelected: false,
initSelectedKey: null,
initSelectedItem: null,
initDirection: null,
};
},
@@ -31,12 +31,20 @@ export default {
currentFilters() {
return HistoryFilters.getFiltersForText(this.filterText);
},
initSelectedKey() {
return this.initSelectedItem ? this.getItemKey(this.initSelectedItem) : null;
},
},
methods: {
setShowSelection(val) {
this.showSelection = val;
},
selectAllInCurrentQuery(loadedItems = []) {
selectAllInCurrentQuery(loadedItems = [], force = true) {
// if we are not forcing selectAll, and all items are already selected; deselect them
if (!force && this.allSelected) {
this.setShowSelection(false);
return;
}
this.selectItems(loadedItems);
this.allSelected = true;
},
@@ -54,10 +62,10 @@ export default {
this.items = newSelected;
this.breakQuerySelection();
},
shiftSelect({ item, nextItem, eventKey }) {
shiftSelect(item, nextItem, eventKey) {
const currentItemKey = this.getItemKey(item);
if (!this.initDirection) {
this.initSelectedKey = currentItemKey;
if (!this.initSelectedKey) {
this.initSelectedItem = item;
this.initDirection = eventKey;
this.setSelected(item, true);
}
@@ -77,8 +85,39 @@ export default {
this.setSelected(nextItem, true);
}
},
selectTo(item, prevItem, allItems, reset = true) {
if (prevItem && item) {
// we are staring a new shift+click selectTo from `prevItem`
if (!this.initSelectedKey) {
this.initSelectedItem = prevItem;
}
// `reset = false` in the case user is holding shift+ctrl key
if (reset) {
// clear this.items of any other selections
this.items = new Map();
}
this.setSelected(this.initSelectedItem, true);
const initItemIndex = allItems.indexOf(this.initSelectedItem);
const currentItemIndex = allItems.indexOf(item);
let selections = [];
// from allItems, get the items between the init item and the current item
if (initItemIndex < currentItemIndex) {
this.initDirection = "ArrowDown";
selections = allItems.slice(initItemIndex + 1, currentItemIndex + 1);
} else if (initItemIndex > currentItemIndex) {
this.initDirection = "ArrowUp";
selections = allItems.slice(currentItemIndex, initItemIndex);
}
this.selectItems(selections);
} else {
this.setSelected(item, true);
}
},
initKeySelection() {
this.initSelectedKey = null;
this.initSelectedItem = null;
this.initDirection = null;
},
selectItems(items = []) {
@@ -137,6 +176,7 @@ export default {
setShowSelection: this.setShowSelection,
selectAllInCurrentQuery: this.selectAllInCurrentQuery,
selectItems: this.selectItems,
selectTo: this.selectTo,
isSelected: this.isSelected,
setSelected: this.setSelected,
resetSelection: this.reset,
@@ -12,7 +12,7 @@ interface StateRepresentation {
nonDb?: boolean;
}
type StateMap = {
export type StateMap = {
[__ in State]: StateRepresentation;
};
@@ -7,11 +7,11 @@ import {
faExchangeAlt,
faFileArchive,
faFileExport,
faList,
faLock,
faPlay,
faPlus,
faShareAlt,
faSitemap,
faStream,
faTrash,
faUserLock,
@@ -50,7 +50,7 @@ library.add(
faPlay,
faPlus,
faShareAlt,
faSitemap,
faList,
faStream,
faTrash,
faUserLock
@@ -204,7 +204,7 @@ function userTitle(title: string) {
:disabled="isAnonymous"
:title="userTitle('Display Workflow Invocations')"
@click="$router.push(`/histories/${history.id}/invocations`)">
<FontAwesomeIcon fixed-width :icon="faSitemap" class="fa-rotate-270 mr-1" />
<FontAwesomeIcon fixed-width :icon="faList" class="mr-1" />
<span v-localize>Show Invocations</span>
</BDropdownItem>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { BAlert } from "bootstrap-vue";
import { storeToRefs } from "pinia";
import { computed, onMounted, ref, set as VueSet, unref, watch } from "vue";
import { computed, onMounted, type Ref, ref, set as VueSet, unref, watch } from "vue";
import type { HistorySummary } from "@/api";
import { copyDataset } from "@/api/datasets";
@@ -10,6 +10,7 @@ import SelectedItems from "@/components/History/Content/SelectedItems";
import { HistoryFilters } from "@/components/History/HistoryFilters";
import { deleteContent, updateContentFields } from "@/components/History/model/queries";
import { Toast } from "@/composables/toast";
import { useActiveElement } from "@/composables/useActiveElement";
import { startWatchingHistory } from "@/store/historyStore/model/watchHistory";
import { useEventStore } from "@/stores/eventStore";
import { type HistoryItem, useHistoryItemsStore } from "@/stores/historyItemsStore";
@@ -54,6 +55,8 @@ interface Props {
isMultiViewItem?: boolean;
}
type ContentItemRef = Record<string, Ref<InstanceType<typeof ContentItem> | null>>;
const props = withDefaults(defineProps<Props>(), {
listOffset: 0,
filter: "",
@@ -76,12 +79,13 @@ const operationError = ref(null);
const querySelectionBreak = ref(false);
const dragTarget = ref<EventTarget | null>(null);
const contentItemRefs = computed(() => {
return historyItems.value.reduce((acc: any, item) => {
// TODO: type `any` properly
acc[`item-${item.hid}`] = ref(null);
return historyItems.value.reduce((acc: ContentItemRef, item) => {
acc[`item-${item.id}`] = ref(null);
return acc;
}, {});
});
const currItemFocused = useActiveElement();
const lastItemId = ref<string | null>(null);
const { currentFilterText, currentHistoryId } = storeToRefs(useHistoryStore());
const { lastCheckedTime, totalMatchesCount, isWatching } = storeToRefs(useHistoryItemsStore());
@@ -141,6 +145,10 @@ const storeFilterText = computed(() => {
return currentFilterText.value || "";
});
const lastItemFocused = computed(() => {
return lastItemId.value ? historyItems.value.find((item) => lastItemId.value === `dataset-${item.id}`) : null;
});
watch(offsetQueryParam, () => loadHistoryItems());
watch(
@@ -149,6 +157,7 @@ watch(
invisibleHistoryItems.value = {};
offsetQueryParam.value = 0;
loadHistoryItems();
lastItemId.value = null;
}
);
@@ -176,6 +185,7 @@ watch(
(newValue, currentValue) => {
if (newValue !== currentValue) {
operationRunning.value = null;
lastItemId.value = null;
}
}
);
@@ -193,6 +203,17 @@ watch(historyItems, (newHistoryItems) => {
}
});
watch(
() => currItemFocused.value,
(newItem, oldItem) => {
// if user clicked elsewhere, set lastItemId to the last focused item
// (if it was indeed a history .content-item)
if (newItem && oldItem?.classList?.contains("content-item") && oldItem?.getAttribute("data-hid")) {
lastItemId.value = oldItem?.id || null;
}
}
);
function getHighlight(item: HistoryItem) {
if (unref(isLoading)) {
return undefined;
@@ -335,7 +356,10 @@ async function onDrop(evt: any) {
// this was not a valid object for this dropzone, ignore
}
if (!data || historyId === props.history.id) {
if (!data) {
return;
} else if (historyId === props.history.id) {
Toast.error("Cannot copy to the same history");
return;
}
@@ -361,10 +385,10 @@ async function onDrop(evt: any) {
}
if (multiple && datasetCount > 0) {
Toast.info(`${datasetCount} datasets copied to history`);
Toast.info(`${datasetCount} dataset${datasetCount > 1 ? "s" : ""} copied to new history`);
}
if (multiple && collectionCount > 0) {
Toast.info(`${collectionCount} collections copied to history`);
Toast.info(`${collectionCount} collection${collectionCount > 1 ? "s" : ""} copied to new history`);
}
historyStore.loadHistoryById(props.history.id);
} catch (error) {
@@ -387,17 +411,14 @@ onMounted(async () => {
filterText.value = storeFilterText.value;
}
await loadHistoryItems();
// if there is a listOffset, we are coming from a collection view, so focus on item at that offset
if (props.listOffset) {
const itemId = historyItems.value[props.listOffset]?.id;
const itemElement = contentItemRefs.value[`item-${itemId}`]?.value?.$el as HTMLElement;
itemElement?.focus();
}
});
function nextSelections(item: HistoryItem, eventKey: string) {
const nextItem = arrowNavigate(item, eventKey);
return {
item,
nextItem,
eventKey,
};
}
function arrowNavigate(item: HistoryItem, eventKey: string) {
let nextItem = null;
if (eventKey === "ArrowDown") {
@@ -406,7 +427,8 @@ function arrowNavigate(item: HistoryItem, eventKey: string) {
nextItem = historyItems.value[historyItems.value.indexOf(item) - 1];
}
if (nextItem) {
contentItemRefs.value[`item-${nextItem.hid}`].value.$el.focus();
const itemElement = contentItemRefs.value[`item-${nextItem.id}`]?.value?.$el as HTMLElement;
itemElement?.focus();
}
return nextItem;
}
@@ -444,6 +466,7 @@ function setItemDragstart(
setShowSelection,
selectAllInCurrentQuery,
isSelected,
selectTo,
setSelected,
shiftSelect,
initKeySelection,
@@ -563,7 +586,7 @@ function setItemDragstart(
<template v-slot:item="{ item, currentOffset }">
<ContentItem
:id="item.hid"
:ref="contentItemRefs[`item-${item.hid}`]"
:ref="contentItemRefs[`item-${item.id}`]"
is-history-item
:item="item"
:name="item.name"
@@ -574,10 +597,7 @@ function setItemDragstart(
:selected="isSelected(item)"
:selectable="showSelection"
:filterable="filterable"
@arrow-navigate="
arrowNavigate(item, $event);
initKeySelection();
"
@arrow-navigate="arrowNavigate(item, $event)"
@drag-start="
setItemDragstart(
item,
@@ -588,8 +608,12 @@ function setItemDragstart(
)
"
@hide-selection="setShowSelection(false)"
@shift-select="(eventKey) => shiftSelect(nextSelections(item, eventKey))"
@select-all="selectAllInCurrentQuery(historyItems)"
@init-key-selection="initKeySelection"
@shift-select="
(eventKey) => shiftSelect(item, arrowNavigate(item, eventKey), eventKey)
"
@select-all="selectAllInCurrentQuery(historyItems, false)"
@selected-to="(reset) => selectTo(item, lastItemFocused, historyItems, reset)"
@tag-click="updateFilterValue('tag', $event)"
@tag-change="onTagChange"
@toggleHighlights="updateFilterValue('related', item.hid)"
@@ -0,0 +1,100 @@
import { createTestingPinia } from "@pinia/testing";
import { getLocalVue } from "@tests/jest/helpers";
import { shallowMount } from "@vue/test-utils";
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import flushPromises from "flush-promises";
import { type HistorySummary } from "@/api";
import SwitchToHistoryLink from "./SwitchToHistoryLink.vue";
const localVue = getLocalVue(true);
const selectors = {
historyLink: ".history-link",
} as const;
function mountSwitchToHistoryLinkForHistory(history: HistorySummary) {
const pinia = createTestingPinia();
const axiosMock = new MockAdapter(axios);
axiosMock.onGet(`/api/histories/${history.id}`).reply(200, history);
const wrapper = shallowMount(SwitchToHistoryLink as object, {
propsData: {
historyId: history.id,
},
localVue,
pinia,
stubs: {
FontAwesomeIcon: true,
},
});
return wrapper;
}
async function expectOptionForHistory(option: string, history: HistorySummary) {
const wrapper = mountSwitchToHistoryLinkForHistory(history);
// Wait for the history to be loaded
await flushPromises();
expect(wrapper.html()).toContain(option);
expect(wrapper.text()).toContain(history.name);
}
describe("SwitchToHistoryLink", () => {
it("loads the history information from the store", async () => {
const history = {
id: "history-id-to-load",
name: "History Name",
deleted: false,
archived: false,
purged: false,
} as HistorySummary;
const wrapper = mountSwitchToHistoryLinkForHistory(history);
expect(wrapper.find(selectors.historyLink).exists()).toBe(false);
expect(wrapper.html()).toContain("Loading");
// Wait for the history to be loaded
await flushPromises();
expect(wrapper.find(selectors.historyLink).exists()).toBe(true);
expect(wrapper.text()).toContain(history.name);
});
it("should display the Switch option when the history can be switched to", async () => {
const history = {
id: "active-history-id",
name: "History Active",
deleted: false,
purged: false,
archived: false,
} as HistorySummary;
await expectOptionForHistory("Switch", history);
});
it("should display the View option when the history is purged", async () => {
const history = {
id: "purged-history-id",
name: "History Purged",
deleted: false,
purged: true,
archived: false,
} as HistorySummary;
await expectOptionForHistory("View", history);
});
it("should display the View option when the history is archived", async () => {
const history = {
id: "archived-history-id",
name: "History Archived",
deleted: false,
purged: false,
archived: true,
} as HistorySummary;
await expectOptionForHistory("View", history);
});
});
@@ -0,0 +1,66 @@
<script setup lang="ts">
import { library } from "@fortawesome/fontawesome-svg-core";
import { faArchive, faBurn } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BLink } from "bootstrap-vue";
import { computed } from "vue";
import { useRouter } from "vue-router/composables";
import { HistorySummary } from "@/api";
import { useHistoryStore } from "@/stores/historyStore";
import LoadingSpan from "@/components/LoadingSpan.vue";
library.add(faArchive, faBurn);
const router = useRouter();
const historyStore = useHistoryStore();
interface Props {
historyId: string;
}
const props = defineProps<Props>();
const history = computed(() => historyStore.getHistoryById(props.historyId));
const canSwitch = computed(() => !!history.value && !history.value.archived && !history.value.purged);
const actionText = computed(() => (canSwitch.value ? "Switch to" : "View in new tab"));
function onClick(history: HistorySummary) {
if (canSwitch.value) {
historyStore.setCurrentHistory(history.id);
return;
}
viewHistoryInNewTab(history);
}
function viewHistoryInNewTab(history: HistorySummary) {
const routeData = router.resolve(`/histories/view?id=${history.id}`);
window.open(routeData.href, "_blank");
}
</script>
<template>
<div>
<LoadingSpan v-if="!history" />
<div v-else v-b-tooltip.hover.top.html :title="`<b>${actionText}</b><br>${history.name}`" class="history-link">
<BLink class="truncate" href="#" @click.stop="onClick(history)">
{{ history.name }}
</BLink>
<FontAwesomeIcon v-if="history.purged" :icon="faBurn" fixed-width />
<FontAwesomeIcon v-else-if="history.archived" :icon="faArchive" fixed-width />
</div>
</div>
</template>
<style scoped>
.history-link {
display: flex;
gap: 1px;
align-items: center;
justify-content: space-between;
}
</style>
@@ -1,5 +1,61 @@
<script setup lang="ts">
import { library } from "@fortawesome/fontawesome-svg-core";
import { faStar } from "@fortawesome/free-regular-svg-icons";
import { faStar as faRegStar } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BButton } from "bootstrap-vue";
import { storeToRefs } from "pinia";
import { computed, ref, watch } from "vue";
import { useUserStore } from "@/stores/userStore";
library.add(faStar, faRegStar);
interface Props {
query: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(e: "onFavorites", filter: string): void;
}>();
const { isAnonymous } = storeToRefs(useUserStore());
const FAVORITES = ["#favorites", "#favs", "#favourites"];
const toggle = ref(false);
const tooltipText = computed(() => {
if (isAnonymous.value) {
return "Log in to Favorite Tools";
} else {
if (toggle.value) {
return "Clear";
} else {
return "Show favorites";
}
}
});
watch(
() => props.query,
() => {
toggle.value = FAVORITES.includes(props.query);
}
);
function onFavorites() {
toggle.value = !toggle.value;
if (toggle.value) {
emit("onFavorites", "#favorites");
} else {
emit("onFavorites", "");
}
}
</script>
<template>
<b-button
<BButton
v-b-tooltip.hover.top.noninteractive
class="panel-header-button-toolbox"
size="sm"
@@ -8,58 +64,6 @@
:disabled="isAnonymous"
:title="tooltipText"
@click="onFavorites">
<icon v-if="toggle" :icon="['fas', 'star']" />
<icon v-else :icon="['far', 'star']" />
</b-button>
<FontAwesomeIcon :icon="toggle ? faRegStar : faStar" />
</BButton>
</template>
<script>
import { mapState } from "pinia";
import { useUserStore } from "@/stores/userStore";
export default {
name: "FavoritesButton",
props: {
query: {
type: String,
required: true,
},
},
data() {
return {
searchKey: "#favorites",
toggle: false,
};
},
computed: {
...mapState(useUserStore, ["isAnonymous"]),
tooltipText() {
if (this.isAnonymous) {
return this.l("Log in to Favorite Tools");
} else {
if (this.toggle) {
return this.l("Clear");
} else {
return this.l("Show favorites");
}
}
},
},
watch: {
query() {
this.toggle = this.query == this.searchKey;
},
},
methods: {
onFavorites() {
this.toggle = !this.toggle;
if (this.toggle) {
this.$emit("onFavorites", this.searchKey);
} else {
this.$emit("onFavorites", "");
}
},
},
};
</script>
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { computed, type ComputedRef, onMounted, onUnmounted, type PropType, type Ref, ref } from "vue";
import { storeToRefs } from "pinia";
import { computed, type ComputedRef, onMounted, onUnmounted, type PropType, type Ref, ref, watch } from "vue";
import { useRouter } from "vue-router/composables";
import { getGalaxyInstance } from "@/app";
import { type Tool, type ToolSection, useToolStore } from "@/stores/toolStore";
import { useUserStore } from "@/stores/userStore";
import Filtering, { contains, type ValidFilter } from "@/utils/filtering";
import _l from "@/utils/localization";
@@ -109,6 +110,7 @@ const validFilters: ComputedRef<Record<string, ValidFilter<string>>> = computed(
});
const ToolFilters: ComputedRef<Filtering<string>> = computed(() => new Filtering(validFilters.value));
const { currentFavorites } = storeToRefs(useUserStore());
const toolStore = useToolStore();
const sectionNames = toolStore.sectionDatalist("default").map((option: { value: string; text: string }) => option.text);
@@ -118,8 +120,6 @@ const ontologyList = toolStore
onMounted(() => {
searchWorker.value = new Worker(new URL("../toolSearch.worker.js", import.meta.url));
const Galaxy = getGalaxyInstance();
const favoritesResults = Galaxy?.user.getFavorites().tools;
searchWorker.value.onmessage = ({ data }) => {
const { type, payload, sectioned, query, closestTerm } = data;
@@ -128,7 +128,7 @@ onMounted(() => {
} else if (type === "clearFilterResult") {
emit("onResults", null, null, null);
} else if (type === "favoriteToolsResult") {
emit("onResults", favoritesResults, null, null);
emit("onResults", currentFavorites.value.tools, null, null);
}
};
});
@@ -137,6 +137,15 @@ onUnmounted(() => {
searchWorker.value?.terminate();
});
watch(
() => currentFavorites.value.tools,
() => {
if (FAVORITES.includes(props.query)) {
post({ type: "favoriteTools" });
}
}
);
function checkQuery(q: string) {
emit("onQuery", q);
if (q && q.length >= MIN_QUERY_LENGTH) {
+1
View File
@@ -252,6 +252,7 @@ function setButtonText() {
@onQuery="(q) => (query = q)"
@onResults="onResults" />
<section v-if="!propShowAdvanced">
<UploadButton v-if="!props.workflow" />
<div v-if="hasResults && resultPanel" class="pb-2">
<b-button size="sm" class="w-100" @click="onToggle">
<FontAwesomeIcon :icon="buttonIcon" />
+2 -2
View File
@@ -34,7 +34,7 @@ const emit = defineEmits<{
const arePanelsFetched = ref(false);
const toolStore = useToolStore();
const { currentPanelView, defaultPanelView, isPanelPopulated, loading, panelViews } = storeToRefs(toolStore);
const { currentPanelView, defaultPanelView, isPanelPopulated, loading, panel, panelViews } = storeToRefs(toolStore);
const loadingView = ref<string | undefined>(undefined);
const query = ref("");
@@ -62,7 +62,7 @@ watch(
watch(
() => currentPanelView.value,
async (newVal) => {
if (!newVal && arePanelsFetched.value) {
if ((!newVal || !panel.value[newVal]) && arePanelsFetched.value) {
await initializeTools();
}
}
@@ -60,15 +60,14 @@ const filterSettings = computed(() => {
return newFilterSettings;
});
const itemsLoaded: Ref<Tool[]> = ref([]);
onMounted(async () => {
await toolStore.fetchTools(filterSettings.value);
itemsLoaded.value = Object.values(toolStore.getToolsById(filterSettings.value));
});
const filterCount = computed(() => Object.keys(filterSettings.value).length);
const itemsLoaded = computed<Tool[]>(() => Object.values(toolStore.getToolsById(filterSettings.value)));
function scrollToTop() {
scrollContainer.value?.scrollTo({ top: 0, behavior: "smooth" });
}
@@ -97,7 +97,7 @@ export default {
}
},
async fetchHelp(tool) {
await fetchData(`api/tools/${tool.id}/build`).then((response) => {
await fetchData(`api/tools/${encodeURIComponent(tool.id)}/build`).then((response) => {
const help = response.help;
Vue.set(tool, "_showDetails", false); // maybe not needed
if (help && help != "\n") {
@@ -169,7 +169,7 @@ describe("InvocationsList.vue", () => {
const row = rows[0];
const columns = row.findAll("td");
expect(columns.at(1).text()).toBe("workflow name");
expect(columns.at(2).text()).toBe("history name");
expect(columns.at(2).text()).toBe("Loading..."); // Checking the actual name will be tested in its own component
expect(columns.at(3).text()).toBe(
formatDistanceToNow(parseISO(`${mockInvocationData.create_time}Z`), { addSuffix: true })
);
@@ -188,13 +188,6 @@ describe("InvocationsList.vue", () => {
expect(rows.length).toBe(1);
});
it("calls switchHistory", async () => {
const mockMethod = jest.fn();
wrapper.vm.switchHistory = mockMethod;
await wrapper.find("#switch-to-history").trigger("click");
expect(mockMethod).toHaveBeenCalled();
});
it("check run button", async () => {
const runButton = await wrapper.find('[data-workflow-run="workflowId"');
@@ -56,14 +56,7 @@
</div>
</template>
<template v-slot:cell(history_id)="data">
<div
v-b-tooltip.hover.top.html
:title="`<b>Switch to</b><br>${getHistoryNameById(data.item.history_id)}`"
class="truncate">
<b-link id="switch-to-history" href="#" @click.stop="switchHistory(data.item.history_id)">
{{ getHistoryNameById(data.item.history_id) }}
</b-link>
</div>
<SwitchToHistoryLink :history-id="data.value" />
</template>
<template v-slot:cell(create_time)="data">
<UtcDate :date="data.value" mode="elapsed" />
@@ -90,7 +83,6 @@
</template>
<script>
import { getGalaxyInstance } from "app";
import HelpText from "components/Help/HelpText";
import { invocationsProvider } from "components/providers/InvocationsProvider";
import UtcDate from "components/UtcDate";
@@ -103,13 +95,15 @@ import { useWorkflowStore } from "@/stores/workflowStore";
import paginationMixin from "./paginationMixin";
import WorkflowRunButton from "./WorkflowRunButton.vue";
import SwitchToHistoryLink from "@/components/History/SwitchToHistoryLink.vue";
export default {
components: {
HelpText,
UtcDate,
WorkflowInvocationState,
WorkflowRunButton,
HelpText,
SwitchToHistoryLink,
},
mixins: [paginationMixin],
props: {
@@ -226,10 +220,6 @@ export default {
invocationLink(item) {
return `/workflows/invocations/${item.id}`;
},
switchHistory(historyId) {
const Galaxy = getGalaxyInstance();
Galaxy.currHistoryPanel.switchToHistory(historyId);
},
},
};
</script>
@@ -1,13 +1,13 @@
<script setup lang="ts">
import { library } from "@fortawesome/fontawesome-svg-core";
import { faClock, faSitemap } from "@fortawesome/free-solid-svg-icons";
import { faList } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { onMounted, ref } from "vue";
import { invocationCountsFetcher } from "@/api/workflows";
import localize from "@/utils/localization";
library.add(faClock, faSitemap);
library.add(faList);
interface Props {
workflow: any;
@@ -33,22 +33,23 @@ onMounted(initCounts);
<template>
<div class="workflow-invocations-count d-flex align-items-center flex-gapx-1">
<BBadge v-if="count != undefined && count === 0" pill class="list-view">
<span>never run</span>
</BBadge>
<BBadge
v-if="count != undefined"
v-else-if="count != undefined && count > 0"
v-b-tooltip.hover.noninteractive
pill
:title="localize('View workflow invocations')"
class="outline-badge cursor-pointer list-view"
:to="`/workflows/${props.workflow.id}/invocations`">
<FontAwesomeIcon :icon="faSitemap" fixed-width />
<FontAwesomeIcon :icon="faList" fixed-width />
<span v-if="count > 0">
<span>
workflow runs:
{{ count }}
</span>
<span v-else> workflow never run </span>
</BBadge>
<BButton
v-else
v-b-tooltip.hover.noninteractive
@@ -57,7 +58,7 @@ onMounted(initCounts);
variant="link"
size="sm"
:to="`/workflows/${props.workflow.id}/invocations`">
<FontAwesomeIcon :icon="faSitemap" fixed-width />
<FontAwesomeIcon :icon="faList" fixed-width />
</BButton>
</div>
</template>
+3 -1
View File
@@ -4,13 +4,14 @@
*/
import { defineStore } from "pinia";
import { type Ref, ref } from "vue";
import { computed, type Ref, ref } from "vue";
export type EventData = { [key: string]: unknown };
export const useEventStore = defineStore("eventStore", () => {
const dragData: Ref<EventData | null> = ref(null);
const multipleDragData: Ref<boolean> = ref(false);
const isMac = computed(() => navigator.userAgent.toUpperCase().indexOf("MAC") >= 0);
function clearDragData() {
dragData.value = null;
@@ -27,6 +28,7 @@ export const useEventStore = defineStore("eventStore", () => {
}
return {
isMac,
multipleDragData,
clearDragData,
getDragData,
+5 -1
View File
@@ -64,7 +64,11 @@ class StoreExportTracker:
StoreExportAssociation,
)
.where(
and_(StoreExportAssociation.object_type == object_type, StoreExportAssociation.object_id == object_id)
and_(
StoreExportAssociation.object_type == object_type,
StoreExportAssociation.object_id == object_id,
StoreExportAssociation.task_uuid.is_not(None),
)
)
.order_by(StoreExportAssociation.create_time.desc())
)
+14 -28
View File
@@ -1633,6 +1633,10 @@ class Job(Base, JobLike, UsesCreateAndUpdateTime, Dictifiable, Serializable):
def add_output_dataset(self, name, dataset):
joda = JobToOutputDatasetAssociation(name, dataset)
if dataset.dataset.job is None:
# Only set job if dataset doesn't already have associated job.
# database operation tools that make copies should not modify the job here.
dataset.dataset.job = self
add_object_to_object_session(self, joda)
self.output_datasets.append(joda)
@@ -1996,24 +2000,8 @@ class Job(Base, JobLike, UsesCreateAndUpdateTime, Dictifiable, Serializable):
SET
state = :state,
update_time = :update_time
WHERE id IN (
SELECT hda.dataset_id FROM history_dataset_association hda
INNER JOIN job_to_output_dataset jtod
ON jtod.dataset_id = hda.id AND jtod.job_id = :job_id
);
"""
),
text(
"""
UPDATE dataset
SET
state = :state,
update_time = :update_time
WHERE id IN (
SELECT ldda.dataset_id FROM library_dataset_dataset_association ldda
INNER JOIN job_to_output_library_dataset jtold
ON jtold.ldda_id = ldda.id AND jtold.job_id = :job_id
);
WHERE
dataset.job_id = :job_id
"""
),
text(
@@ -2022,11 +2010,10 @@ class Job(Base, JobLike, UsesCreateAndUpdateTime, Dictifiable, Serializable):
SET
info = :info,
update_time = :update_time
WHERE id IN (
SELECT jtod.dataset_id
FROM job_to_output_dataset jtod
WHERE jtod.job_id = :job_id
);
FROM dataset
WHERE
history_dataset_association.dataset_id = dataset.id
AND dataset.job_id = :job_id;
"""
),
text(
@@ -2035,11 +2022,10 @@ class Job(Base, JobLike, UsesCreateAndUpdateTime, Dictifiable, Serializable):
SET
info = :info,
update_time = :update_time
WHERE id IN (
SELECT jtold.ldda_id
FROM job_to_output_library_dataset jtold
WHERE jtold.job_id = :job_id
);
FROM dataset
WHERE
library_dataset_dataset_association.dataset_id = dataset.id
AND dataset.job_id = :job_id;
"""
),
]
+27 -17
View File
@@ -34,6 +34,10 @@ from typing import (
from bdbag import bdbag_api as bdb
from boltons.iterutils import remap
from pydantic import (
BaseModel,
ConfigDict,
)
from rocrate.model.computationalworkflow import (
ComputationalWorkflow,
WorkflowDescription,
@@ -96,7 +100,10 @@ from galaxy.schema.bco.util import (
get_contributors,
write_to_file,
)
from galaxy.schema.schema import ModelStoreFormat
from galaxy.schema.schema import (
DatasetStateField,
ModelStoreFormat,
)
from galaxy.security.idencoding import IdEncodingHelper
from galaxy.util import (
FILENAME_VALID_CHARS,
@@ -177,6 +184,20 @@ class ImportDiscardedDataType(Enum):
FORCE = "force"
class DatasetAttributeImportModel(BaseModel):
state: Optional[DatasetStateField] = None
deleted: Optional[bool] = None
purged: Optional[bool] = None
external_filename: Optional[str] = None
_extra_files_path: Optional[str] = None
file_size: Optional[int] = None
object_store_id: Optional[str] = None
total_size: Optional[int] = None
created_from_basename: Optional[str] = None
uuid: Optional[str] = None
model_config = ConfigDict(extra="ignore")
DEFAULT_DISCARDED_DATA_TYPE = ImportDiscardedDataType.FORBID
@@ -447,22 +468,11 @@ class ModelImportStore(metaclass=abc.ABCMeta):
def handle_dataset_object_edit(dataset_instance, dataset_attrs):
if "dataset" in dataset_attrs:
assert self.import_options.allow_dataset_object_edit
dataset_attributes = [
"state",
"deleted",
"purged",
"external_filename",
"_extra_files_path",
"file_size",
"object_store_id",
"total_size",
"created_from_basename",
"uuid",
]
for attribute in dataset_attributes:
if attribute in dataset_attrs["dataset"]:
setattr(dataset_instance.dataset, attribute, dataset_attrs["dataset"][attribute])
dataset_attributes = DatasetAttributeImportModel(**dataset_attrs["dataset"]).model_dump(
exclude_unset=True,
)
for attribute, value in dataset_attributes.items():
setattr(dataset_instance.dataset, attribute, value)
self._attach_dataset_hashes(dataset_attrs["dataset"], dataset_instance)
self._attach_dataset_sources(dataset_attrs["dataset"], dataset_instance)
if "id" in dataset_attrs["dataset"] and self.import_options.allow_edit:
+17 -16
View File
@@ -20,6 +20,7 @@ from pydantic import (
AnyHttpUrl,
AnyUrl,
BaseModel,
BeforeValidator,
ConfigDict,
Field,
Json,
@@ -151,11 +152,11 @@ HistoryDatasetAssociationId = Annotated[EncodedDatabaseIdField, Field(..., title
JobId = Annotated[EncodedDatabaseIdField, Field(..., title="Job ID")]
DatasetStateField: DatasetState = Field(
...,
title="State",
description="The current state of this dataset.",
)
DatasetStateField = Annotated[
DatasetState,
BeforeValidator(lambda v: "discarded" if v == "deleted" else v),
Field(..., title="State", description="The current state of this dataset."),
]
CreateTimeField = Field(
title="Create Time",
@@ -661,7 +662,7 @@ class HDASummary(HDACommon):
title="Dataset ID",
description="The encoded ID of the dataset associated with this item.",
)
state: DatasetState = DatasetStateField
state: DatasetStateField
extension: Optional[str] = Field(
...,
title="Extension",
@@ -679,7 +680,7 @@ class HDAInaccessible(HDACommon):
"""History Dataset Association information when the user can not access it."""
accessible: bool = AccessibleField
state: DatasetState = DatasetStateField
state: DatasetStateField
HdaLddaField = Field(
@@ -872,7 +873,7 @@ class HDAObject(Model, WithModelClass):
# If so at least merge models
id: HistoryDatasetAssociationId
model_class: HDA_MODEL_CLASS = ModelClassField(HDA_MODEL_CLASS)
state: DatasetState = DatasetStateField
state: DatasetStateField
hda_ldda: DatasetSourceType = HdaLddaField
history_id: HistoryID
tags: List[str]
@@ -2136,8 +2137,8 @@ class StoredWorkflowSummary(Model, WithModelClass):
title="Owner",
description="The name of the user who owns this workflow.",
)
latest_workflow_uuid: UUID4 = Field( # Is this really used?
...,
latest_workflow_uuid: Optional[UUID4] = Field(
None,
title="Latest workflow UUID",
description="TODO",
)
@@ -2154,17 +2155,17 @@ class StoredWorkflowSummary(Model, WithModelClass):
class WorkflowInput(Model):
label: str = Field(
label: Optional[str] = Field(
...,
title="Label",
description="Label of the input.",
)
value: str = Field(
value: Optional[Any] = Field(
...,
title="Value",
description="TODO",
)
uuid: UUID4 = Field(
uuid: Optional[UUID4] = Field(
...,
title="UUID",
description="Universal unique identifier of the input.",
@@ -2275,7 +2276,7 @@ class SubworkflowStep(WorkflowStepBase):
class Creator(Model):
class_: str = Field(..., alias="class", title="Class", description="The class representing this creator.")
name: str = Field(..., title="Name", description="The name of the creator.")
name: Optional[str] = Field(None, title="Name", description="The name of the creator.")
address: Optional[str] = Field(
None,
title="Address",
@@ -3080,7 +3081,7 @@ class FileLibraryFolderItem(LibraryFolderItemBase):
date_uploaded: datetime
is_unrestricted: bool
is_private: bool
state: DatasetState = DatasetStateField
state: DatasetStateField
file_size: str
raw_size: int
ldda_id: EncodedDatabaseIdField
@@ -3650,7 +3651,7 @@ class DatasetSummary(Model):
id: EncodedDatabaseIdField
create_time: Optional[datetime] = CreateTimeField
update_time: Optional[datetime] = UpdateTimeField
state: DatasetState = DatasetStateField
state: DatasetStateField
deleted: bool
purged: bool
purgable: bool
+1 -1
View File
@@ -35,7 +35,7 @@ NOT_IMPLEMENTED_MESSAGE = "Galaxy tool format does not yet support this tool fea
class AssertionDict(TypedDict):
tag: str
attributes: Dict[str, Any]
children: Optional[List[Dict[str, Any]]]
children: "AssertionList"
AssertionList = Optional[List[AssertionDict]]
+5 -4
View File
@@ -287,10 +287,11 @@ def to_test_assert_list(assertions) -> AssertionList:
new_assertion["that"] = assertion_key
new_assertion.update(assertion_value)
assertion = new_assertion
children = []
if "children" in assertion:
children = assertion["children"]
del assertion["children"]
children = assertion.pop("asserts", assertion.pop("children", []))
# if there are no nested assertions then children should be []
# but to_test_assert_list would return None
if children:
children = to_test_assert_list(children)
assert_dict: AssertionDict = dict(
tag=assertion["that"],
attributes=assertion,
-5
View File
@@ -175,11 +175,6 @@ def execute(
history = execution_slice.history or history
jobs_executed += 1
if job_datasets:
for job, datasets in job_datasets.items():
for dataset_instance in datasets:
dataset_instance.dataset.job = job
if execution_slice:
history.add_pending_items()
# Make sure collections, implicit jobs etc are flushed even if there are no precreated output datasets
+1 -1
View File
@@ -366,7 +366,7 @@ class FastAPIUsers:
return payload
@router.delete(
"/api/users/{user_id}/favorites/{object_type}/{object_id}",
"/api/users/{user_id}/favorites/{object_type}/{object_id:path}",
name="remove_favorite",
summary="Remove the object from user's favorites",
)
@@ -1296,6 +1296,10 @@ class FastAPIInvocations:
include_nested_invocations: bool = True,
trans: ProvidesUserContext = DependsOnTrans,
) -> List[WorkflowInvocationResponse]:
if not trans.user:
# Anon users don't have accessible invocations (currently, though published invocations should be a thing)
response.headers["total_matches"] = "0"
return []
invocation_payload = InvocationIndexPayload(
workflow_id=workflow_id,
history_id=history_id,
@@ -912,7 +912,7 @@ class DatasetsService(ServiceBase, UsesVisualizationMixin):
# If stats were requested, return them.
if "stats" in kwargs:
if stats["data"]["max"] == 0:
return DataResult(dataset_type=indexer.dataset_type, data=None)
return DataResult(dataset_type=indexer.dataset_type, data=[])
else:
return stats
+5 -2
View File
@@ -239,8 +239,11 @@ class UsersService(ServiceBase):
and not trans.app.config.expose_user_name
and not trans.app.config.expose_user_email
):
item = trans.user.to_dict()
return [item]
if trans.user:
item = trans.user.to_dict()
return [item]
else:
return []
stmt = stmt.filter(User.deleted == false())
for user in trans.sa_session.scalars(stmt).all():
item = user.to_dict()
@@ -25,10 +25,10 @@ def job_connections_manager(sa_session) -> JobConnectionsManager:
# =============================================================================
def setup_connected_dataset(sa_session: galaxy_scoped_session):
center_hda = HistoryDatasetAssociation(sa_session=sa_session)
input_hda = HistoryDatasetAssociation(sa_session=sa_session)
center_hda = HistoryDatasetAssociation(sa_session=sa_session, create_dataset=True)
input_hda = HistoryDatasetAssociation(sa_session=sa_session, create_dataset=True)
input_hdca = HistoryDatasetCollectionAssociation()
output_hda = HistoryDatasetAssociation(sa_session=sa_session)
output_hda = HistoryDatasetAssociation(sa_session=sa_session, create_dataset=True)
output_hdca = HistoryDatasetCollectionAssociation()
input_job = Job()
output_job = Job()
@@ -56,10 +56,10 @@ def setup_connected_dataset(sa_session: galaxy_scoped_session):
def setup_connected_dataset_collection(sa_session: galaxy_scoped_session):
center_hdca = HistoryDatasetCollectionAssociation()
input_hda1 = HistoryDatasetAssociation(sa_session=sa_session)
input_hda2 = HistoryDatasetAssociation(sa_session=sa_session)
input_hda1 = HistoryDatasetAssociation(sa_session=sa_session, create_dataset=True)
input_hda2 = HistoryDatasetAssociation(sa_session=sa_session, create_dataset=True)
input_hdca = HistoryDatasetCollectionAssociation()
output_hda = HistoryDatasetAssociation(sa_session=sa_session)
output_hda = HistoryDatasetAssociation(sa_session=sa_session, create_dataset=True)
output_hdca = HistoryDatasetCollectionAssociation()
input_job = Job()
output_job = Job()
+12
View File
@@ -1,5 +1,8 @@
from uuid import uuid4
from pydantic import BaseModel
from galaxy.schema.schema import DatasetStateField
from galaxy.schema.tasks import (
GenerateInvocationDownload,
RequestUser,
@@ -22,3 +25,12 @@ def test_task_schema():
assert rehydrated_download.invocation_id == TEST_INVOCATION_ID
assert rehydrated_download.user.user_id == TEST_USER_ID
assert rehydrated_download.galaxy_url == TEST_GALAXY_URL
class StateModel(BaseModel):
state: DatasetStateField
def test_dataset_state_coercion():
assert StateModel(state="ok").state == "ok"
assert StateModel(state="deleted").state == "discarded"
+23
View File
@@ -38,3 +38,26 @@ def test_simple_assert_to_test_assert_list():
def test_assert_legacy_same_as_new_list_style():
assert to_test_assert_list(ASSERT_THAT_LIST) == to_test_assert_list(ASSERT_THAT_LIST)
NESTED_ASSERT_LIST = yaml.safe_load(
"""
- has_archive_member:
path: ".*"
asserts:
- has_text:
text: "a text"
- has_text:
text: "another text"
"""
)
def test_nested_asserts():
asserts = to_test_assert_list(NESTED_ASSERT_LIST)
assert asserts and len(asserts) == 1
assert asserts and asserts[0]["children"] and len(asserts[0]["children"]) == 2
assert asserts and asserts[0]["children"] and asserts[0]["children"][0]["tag"] == "has_text"
assert asserts and asserts[0]["children"] and asserts[0]["children"][0]["attributes"]["text"] == "a text"
assert asserts and asserts[0]["children"] and asserts[0]["children"][1]["tag"] == "has_text"
assert asserts and asserts[0]["children"] and asserts[0]["children"][1]["attributes"]["text"] == "another text"