Merge branch 'release_24.0' into dev

This commit is contained in:
mvdbeek
2024-03-18 11:32:36 +01:00
17 changed files with 166 additions and 69 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ const name = computed(() => {
<template>
<span id="drag-ghost" class="py-2 px-3 rounded">
<FontAwesomeIcon icon="paper-plane" class="mr-1" />
<FontAwesomeIcon :icon="faPaperPlane" class="mr-1" />
<TextShort class="font-weight-bold" :text="name" />
</span>
</template>
@@ -32,6 +32,7 @@ type SortKeyLiteral = "create_time" | "name" | "update_time" | undefined;
*/
async function getData(offset: number, limit: number, search: string, sort_by: string, sort_desc: boolean) {
const { data, headers } = await historiesFetcher({
view: "summary",
limit,
offset,
search,
@@ -22,6 +22,8 @@ type SortKeyLiteral = "create_time" | "name" | "update_time" | undefined;
*/
async function getData(offset: number, limit: number, search: string, sort_by: string, sort_desc: boolean) {
const { data, headers } = await historiesFetcher({
view: "summary",
keys: "username,create_time",
limit,
offset,
search,
@@ -317,9 +317,12 @@ function unexpandedClick(event: Event) {
:data-state="dataState"
tabindex="0"
role="button"
draggable
@dragstart="onDragStart"
@dragend="onDragEnd"
@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="p-1 cursor-pointer" @click.stop="onClick">
<div class="d-flex justify-content-between">
<span class="p-1" data-description="content item header info">
<BButton v-if="selectable" class="selector p-0" @click.stop="emit('update:selected', !selected)">
@@ -212,6 +212,36 @@ watch(
}
);
function dragSameHistory() {
return getDragData().sameHistory;
}
function getDragData() {
const eventStore = useEventStore();
const multiple = eventStore.multipleDragData;
let data: HistoryItem[] | undefined;
let historyId: string | undefined;
try {
if (multiple) {
const dragData = eventStore.getDragData() as Record<string, HistoryItem>;
// set historyId to the first history_id in the multiple drag data
const firstItem = Object.values(dragData)[0];
if (firstItem) {
historyId = firstItem.history_id;
}
data = Object.values(dragData);
} else {
data = [eventStore.getDragData() as HistoryItem];
if (data[0]) {
historyId = data[0].history_id;
}
}
} catch (error) {
// this was not a valid object for this dropzone, ignore
}
return { data, sameHistory: historyId === props.history.id, multiple };
}
function getHighlight(item: HistoryItem) {
if (unref(isLoading)) {
return undefined;
@@ -319,45 +349,33 @@ function onOperationError(error: any) {
}
function onDragEnter(e: DragEvent) {
if (dragSameHistory()) {
return;
}
dragTarget.value = e.target;
showDropZone.value = true;
}
function onDragOver(e: DragEvent) {
if (dragSameHistory()) {
return;
}
e.preventDefault();
}
function onDragLeave(e: DragEvent) {
if (dragSameHistory()) {
return;
}
if (dragTarget.value === e.target) {
showDropZone.value = false;
}
}
async function onDrop(evt: any) {
const eventStore = useEventStore();
async function onDrop() {
showDropZone.value = false;
let data: HistoryItem[] | undefined;
let historyId: string | undefined;
const multiple = eventStore.multipleDragData;
try {
if (multiple) {
const dragData = eventStore.getDragData() as Record<string, HistoryItem>;
// set historyId to the first history_id in the multiple drag data
const firstItem = Object.values(dragData)[0];
if (firstItem) {
historyId = firstItem.history_id;
}
data = Object.values(dragData);
} else {
data = [eventStore.getDragData() as HistoryItem];
if (data[0]) {
historyId = data[0].history_id;
}
}
} catch (error) {
// this was not a valid object for this dropzone, ignore
}
if (!data) {
return;
} else if (historyId === props.history.id) {
Toast.error("Cannot copy to the same history");
const { data, sameHistory, multiple } = getDragData();
if (!data || sameHistory) {
return;
}
@@ -486,7 +504,7 @@ function setItemDragstart(
class="history-layout d-flex flex-column w-100 h-100"
@drop.prevent="onDrop"
@dragenter.prevent="onDragEnter"
@dragover.prevent
@dragover="onDragOver"
@dragleave.prevent="onDragLeave">
<slot name="navigation" :history="history" />
@@ -20,7 +20,7 @@ describe("HeadlessMultiselect", () => {
});
};
const sampleOptions = ["#named", "#named_2", "#named_3", "abc", "def", "ghi"];
const sampleOptions = ["name:named", "name:named_2", "name:named_3", "abc", "def", "ghi"];
const selectors = {
openButton: ".toggle-button",
@@ -197,27 +197,27 @@ describe("HeadlessMultiselect", () => {
const input = await open(wrapper);
await keyPress(input, "Enter");
expect(wrapper.emitted()["input"]?.[0]?.[0]).toEqual(["#named"]);
expect(wrapper.emitted()["input"]?.[0]?.[0]).toEqual(["name:named"]);
await keyPress(input, "ArrowDown");
await keyPress(input, "Enter");
expect(wrapper.emitted()["input"]?.[1]?.[0]).toEqual(["#named_2"]);
expect(wrapper.emitted()["input"]?.[1]?.[0]).toEqual(["name:named_2"]);
});
it("deselects options via keyboard", async () => {
const wrapper = mountWithProps({
options: sampleOptions,
selected: ["#named", "#named_2", "#named_3"],
selected: ["name:named", "name:named_2", "name:named_3"],
});
const input = await open(wrapper);
await keyPress(input, "Enter");
expect(wrapper.emitted()["input"]?.[0]?.[0]).toEqual(["#named_2", "#named_3"]);
expect(wrapper.emitted()["input"]?.[0]?.[0]).toEqual(["name:named_2", "name:named_3"]);
await keyPress(input, "ArrowDown");
await keyPress(input, "Enter");
expect(wrapper.emitted()["input"]?.[1]?.[0]).toEqual(["#named", "#named_3"]);
expect(wrapper.emitted()["input"]?.[1]?.[0]).toEqual(["name:named", "name:named_3"]);
});
it("allows for adding new options", async () => {
@@ -243,26 +243,26 @@ describe("HeadlessMultiselect", () => {
const options = wrapper.findAll(selectors.option);
await options.at(0).trigger("click");
expect(wrapper.emitted()["input"]?.[0]?.[0]).toEqual(["#named"]);
expect(wrapper.emitted()["input"]?.[0]?.[0]).toEqual(["name:named"]);
await options.at(1).trigger("click");
expect(wrapper.emitted()["input"]?.[1]?.[0]).toEqual(["#named_2"]);
expect(wrapper.emitted()["input"]?.[1]?.[0]).toEqual(["name:named_2"]);
});
it("deselects options with mouse", async () => {
const wrapper = mountWithProps({
options: sampleOptions,
selected: ["#named", "#named_2", "#named_3"],
selected: ["name:named", "name:named_2", "name:named_3"],
});
await open(wrapper);
const options = wrapper.findAll(selectors.option);
await options.at(0).trigger("click");
expect(wrapper.emitted()["input"]?.[0]?.[0]).toEqual(["#named_2", "#named_3"]);
expect(wrapper.emitted()["input"]?.[0]?.[0]).toEqual(["name:named_2", "name:named_3"]);
await options.at(1).trigger("click");
expect(wrapper.emitted()["input"]?.[1]?.[0]).toEqual(["#named", "#named_3"]);
expect(wrapper.emitted()["input"]?.[1]?.[0]).toEqual(["name:named", "name:named_3"]);
});
});
});
@@ -15,6 +15,7 @@ import { computed, nextTick, ref, watch } from "vue";
import Vue2Teleport from "vue2-teleport";
import { useUid } from "@/composables/utils/uid";
import { normalizeTag } from "@/stores/userTagsStore";
library.add(faCheck, faChevronUp, faPlus, faTags, faTimes);
@@ -147,6 +148,8 @@ function getOptionWithId(id: number) {
* was not part of the provided options
*/
function onOptionSelected(option: string) {
option = normalizeTag(option);
if (!optionsAsSet.value.has(option)) {
emit("addOption", option);
return;
@@ -318,7 +321,7 @@ whenever(isOpen, async () => {
:data-parent-id="props.id"
class="headless-multiselect__option"
role="option"
:aria-selected="props.selected.includes(option)"
:aria-selected="props.selected.includes(normalizeTag(option))"
:class="{
invalid: i === 0 && !searchValueValid,
highlighted: highlightedOption === i,
@@ -332,7 +335,7 @@ whenever(isOpen, async () => {
{{ option }}
</span>
<span v-if="props.selected.includes(option)" class="headless-multiselect__info">
<span v-if="props.selected.includes(normalizeTag(option))" class="headless-multiselect__info">
<template v-if="highlightedOption === i">
<FontAwesomeIcon
class="headless-multiselect__needs-highlight"
@@ -3,11 +3,11 @@ import { useToast } from "composables/toast";
import { getLocalVue } from "tests/jest/helpers";
import { computed } from "vue";
import { useUserTagsStore } from "@/stores/userTagsStore";
import { normalizeTag, useUserTagsStore } from "@/stores/userTagsStore";
import StatelessTags from "./StatelessTags";
const autocompleteTags = ["#named_user_tag", "abc", "my_tag"];
const autocompleteTags = ["name:named_user_tag", "abc", "my_tag"];
const toggleButton = ".toggle-button";
const localVue = getLocalVue();
@@ -28,6 +28,12 @@ useUserTagsStore.mockReturnValue({
onMultipleNewTagsSeen: jest.fn(),
});
function normalize(tag) {
return tag.replace(/^#/, "name:");
}
normalizeTag.mockImplementation(normalize);
jest.mock("composables/toast");
const warningMock = jest.fn((message, title) => {
return { message, title };
@@ -88,7 +94,7 @@ describe("StatelessTags", () => {
expect(visibleOptions.length).toBe(autocompleteTags.length);
visibleOptions.wrappers.forEach((option, i) => {
expect(option.text()).toContain(autocompleteTags[i]);
expect(normalize(option.text())).toContain(autocompleteTags[i]);
});
});
@@ -111,7 +111,7 @@ function onTagClicked(tag: string) {
<HeadlessMultiselect
:options="userTags"
:selected="tags"
:selected="props.value"
:placeholder="props.placeholder"
:validator="isValid"
@addOption="onAddTag"
+12 -7
View File
@@ -20,13 +20,13 @@ class UserTagStoreDatabase extends Dexie {
constructor() {
super("userTagStoreDatabase");
this.version(1).stores({ tags: "++id, userHash, lastUsed" });
this.version(2).stores({ tags: "++id, userHash, lastUsed, tag" });
}
}
const maxDbEntriesPerUser = 10000;
function normalizeTag(tag: string) {
export function normalizeTag(tag: string) {
return tag.replace(/^#/, "name:");
}
@@ -113,12 +113,17 @@ export const useUserTagsStore = defineStore("userTagsStore", () => {
await until(dbLoaded).toBe(true);
tag = normalizeTag(tag);
const storedTag = tags.value.find((o) => o.tag === tag);
const id = storedTag?.id;
const dbTag = await db.tags.get({ tag });
if (id !== undefined) {
// put instead of update, because `removeOldestEntries` may have deleted this tag on init
await db.tags.put({ ...storedTag, lastUsed: Date.now() } as StoredTag, id);
if (dbTag) {
await db.tags.update(dbTag, { lastUsed: Date.now() });
} else {
const storedTag = tags.value.find((o) => o.tag === tag);
const id = storedTag?.id;
if (id !== undefined) {
await db.tags.add({ ...storedTag, lastUsed: Date.now() } as StoredTag);
}
}
}
+1
View File
@@ -215,6 +215,7 @@ class HistoryManager(sharable.SharableModelManager, deletable.PurgableManagerMix
total_matches = None
if payload.sort_by == "username":
sort_column = model.User.username
stmt = stmt.add_columns(sort_column)
else:
sort_column = getattr(model.History, payload.sort_by)
if payload.sort_desc:
+4 -2
View File
@@ -191,7 +191,7 @@ class FastAPIHistories:
trans, serialization_params, filter_query_params, deleted_only=deleted, all_histories=all
)
else:
payload = HistoryIndexQueryPayload.construct(
payload = HistoryIndexQueryPayload.model_construct(
show_own=show_own,
show_published=show_published,
show_shared=show_shared,
@@ -201,7 +201,9 @@ class FastAPIHistories:
offset=offset,
search=search,
)
entries, total_matches = self.service.index_query(trans, payload, include_total_count=True)
entries, total_matches = self.service.index_query(
trans, payload, serialization_params, include_total_count=True
)
response.headers["total_matches"] = str(total_matches)
return entries
+11 -5
View File
@@ -891,12 +891,11 @@ class DatasetsService(ServiceBase, UsesVisualizationMixin):
extra_info = None
mode = kwargs.get("mode", "Auto")
indexer = None
# Coverage mode uses index data.
if mode == "Coverage":
# Get summary using minimal cutoffs.
indexer = self.data_provider_registry.get_data_provider(trans, original_dataset=dataset, source="index")
indexer = self._get_indexer(trans, dataset)
return indexer.get_data(chrom, low, high, **kwargs)
# TODO:
@@ -906,7 +905,7 @@ class DatasetsService(ServiceBase, UsesVisualizationMixin):
# If mode is Auto, need to determine what type of data to return.
if mode == "Auto":
# Get stats from indexer.
indexer = self.data_provider_registry.get_data_provider(trans, original_dataset=dataset, source="index")
indexer = self._get_indexer(trans, dataset)
stats = indexer.get_data(chrom, low, high, stats=True)
# If stats were requested, return them.
@@ -956,8 +955,7 @@ class DatasetsService(ServiceBase, UsesVisualizationMixin):
)
# Get mean depth.
if not indexer:
indexer = self.data_provider_registry.get_data_provider(trans, original_dataset=dataset, source="index")
indexer = self._get_indexer(trans, dataset)
stats = indexer.get_data(chrom, low, high, stats=True)
mean_depth = stats["data"]["mean"]
@@ -1008,3 +1006,11 @@ class DatasetsService(ServiceBase, UsesVisualizationMixin):
data = data_provider.get_data(**kwargs)
return data
def _get_indexer(self, trans, dataset):
indexer = self.data_provider_registry.get_data_provider(trans, original_dataset=dataset, source="index")
if indexer is None:
msg = f"No indexer available for dataset {self.encode_id(dataset.id)}"
log.exception(msg)
raise galaxy_exceptions.ObjectNotFound(msg)
return indexer
@@ -216,6 +216,7 @@ class HistoriesService(ServiceBase, ConsumesModelStores, ServesExportStores):
self,
trans,
payload: HistoryIndexQueryPayload,
serialization_params: SerializationParams,
include_total_count: bool = False,
) -> Tuple[List[AnyHistoryView], int]:
"""Return a list of History accessible by the user
@@ -223,10 +224,9 @@ class HistoriesService(ServiceBase, ConsumesModelStores, ServesExportStores):
:rtype: list
:returns: dictionaries containing History details
"""
serialization_params = SerializationParams(default_view="detailed")
entries, total_matches = self.manager.index_query(trans, payload, include_total_count)
return (
[self._serialize_history(trans, entry, serialization_params) for entry in entries],
[self._serialize_history(trans, entry, serialization_params, default_view="summary") for entry in entries],
total_matches,
)
+3 -4
View File
@@ -122,10 +122,9 @@ class UsersService(ServiceBase):
def _anon_user_api_value(self, trans: ProvidesHistoryContext):
"""Return data for an anonymous user, truncated to only usage and quota_percent"""
if not trans.user and not trans.history:
# Can't return info about this user, may not have a history yet.
# return {}
raise glx_exceptions.MessageException(err_msg="The user has no history, which should always be the case.")
usage = self.quota_agent.get_usage(trans, history=trans.history)
usage = None
else:
usage = self.quota_agent.get_usage(trans, history=trans.history)
percent = self.quota_agent.get_percent(trans=trans, usage=usage)
usage = usage or 0
return {
+50
View File
@@ -140,6 +140,56 @@ class TestHistoriesApi(ApiTestCase, BaseHistories):
assert len(index_response) == 1
assert index_response[0]["name"] == expected_history_name
def test_index_views(self):
# Make sure there is at least one history
self._create_history(f"TestHistoryForViews_{uuid4()}")["id"]
# By default the view is summary
index_response = self._get("histories").json()
for history in index_response:
assert "state" not in history
# Change the view to detailed
index_response = self._get("histories?view=detailed").json()
for history in index_response:
assert "state" in history
# Expect only specific keys
expected_keys = ["name"]
unexpected_keys = ["deleted", "state"]
index_response = self._get(f"histories?keys={','.join(expected_keys)}").json()
for history in index_response:
for key in expected_keys:
assert key in history
for key in unexpected_keys:
assert key not in history
def test_index_search_mode_views(self):
# Make sure there is at least one history
expected_name_contains = "SearchMode"
self._create_history(f"TestHistory{expected_name_contains}_{uuid4()}")["id"]
# By default the view is summary
data = dict(search=expected_name_contains, show_published=False)
index_response = self._get("histories", data=data).json()
for history in index_response:
assert "state" not in history
# Change the view to detailed
data = dict(search=expected_name_contains, show_published=False)
index_response = self._get("histories?view=detailed", data=data).json()
for history in index_response:
assert "state" in history
# Expect only specific keys
expected_keys = ["name"]
unexpected_keys = ["deleted", "state"]
data = dict(search=expected_name_contains, show_published=False, keys=",".join(expected_keys))
index_response = self._get("histories", data=data).json()
for history in index_response:
for key in expected_keys:
assert key in history
for key in unexpected_keys:
assert key not in history
def test_index_case_insensitive_contains_query(self):
# Create the histories with a different user to ensure the test
# is not conflicted with the current user's histories.
+2 -1
View File
@@ -296,7 +296,8 @@ class TestGalaxyOIDCLoginIntegration(AbstractTestCases.BaseKeycloakIntegrationTe
response = session.get(response.json()["redirect_uri"], verify=False)
# make sure we can no longer request the user
response = session.get(self._api_url("users/current"))
self._assert_status_code_is(response, 400)
self._assert_status_code_is(response, 200)
assert "email" not in response.json()
def test_auth_by_access_token_logged_in_once(self):
# login at least once