mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-09-24 16:30:27 +08:00
Merge branch 'release_24.0' into dev
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
"""
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user