Skip to content

Commit

Permalink
Merge pull request #17546 from ElectronicBlueberry/history-remove-vir…
Browse files Browse the repository at this point in the history
…tual-list

Remove virtual scroller from History
  • Loading branch information
ElectronicBlueberry authored Feb 28, 2024
2 parents ddd1803 + 9bdeb06 commit 4c461f3
Show file tree
Hide file tree
Showing 8 changed files with 155 additions and 90 deletions.
3 changes: 2 additions & 1 deletion client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"browserslist": [
"defaults",
"not op_mini all",
"not ios_saf <= 15.0"
"not ios_saf <= 15.0",
"not kaios > 0"
],
"resolutions": {
"chokidar": "3.5.3",
Expand Down
14 changes: 9 additions & 5 deletions client/src/components/History/CurrentHistory/HistoryPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ const historyItems = computed(() => {
return historyItemsStore.getHistoryItems(props.history.id, filterText.value);
});
const visibleHistoryItems = computed(() => {
return historyItems.value.filter((item) => !invisibleHistoryItems.value[item.hid]);
});
const formattedSearchError = computed(() => {
const newError = unref(searchError);
if (!newError) {
Expand Down Expand Up @@ -368,7 +372,7 @@ onMounted(async () => {
@query-selection-break="querySelectionBreak = true">
<!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -->
<section
class="history-layout d-flex flex-column w-100"
class="history-layout d-flex flex-column w-100 h-100"
@drop.prevent="onDrop"
@dragenter.prevent="onDragEnter"
@dragover.prevent
Expand Down Expand Up @@ -443,9 +447,9 @@ onMounted(async () => {
@hide="operationError = null" />
</section>

<section v-if="!showAdvanced" class="position-relative flex-grow-1 scroller">
<section v-show="!showAdvanced" class="position-relative flex-grow-1 scroller overflow-hidden">
<HistoryDropZone v-if="showDropZone" />
<div>
<div class="h-100">
<div v-if="isLoading && historyItems && historyItems.length === 0">
<BAlert class="m-2" variant="info" show>
<LoadingSpan message="Loading History" />
Expand All @@ -468,12 +472,12 @@ onMounted(async () => {
<ListingLayout
v-else
:offset="listOffset"
:items="historyItems"
:items="visibleHistoryItems"
:query-key="queryKey"
data-key="hid"
@scroll="onScroll">
<template v-slot:item="{ item, currentOffset }">
<ContentItem
v-if="!invisibleHistoryItems[item.hid]"
:id="item.hid"
is-history-item
:item="item"
Expand Down
27 changes: 27 additions & 0 deletions client/src/components/History/Layout/IntersectionObservable.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from "vue";
const props = defineProps<{
observer: IntersectionObserver;
}>();
const element = ref<HTMLDivElement>();
onMounted(() => {
if (element.value) {
props.observer.observe(element.value);
}
});
onBeforeUnmount(() => {
if (element.value) {
props.observer.unobserve(element.value);
}
});
</script>

<template>
<div ref="element">
<slot></slot>
</div>
</template>
155 changes: 79 additions & 76 deletions client/src/components/History/Layout/ListingLayout.vue
Original file line number Diff line number Diff line change
@@ -1,88 +1,91 @@
<template>
<div class="listing-layout">
<VirtualList
ref="listing"
class="listing"
role="list"
:data-key="dataKey"
:offset="offset"
:data-sources="items"
:data-component="{}"
:estimate-size="estimatedItemHeight"
:keeps="estimatedItemCount"
@scroll="onScroll">
<template v-slot:item="{ item }">
<slot name="item" :item="item" :current-offset="getOffset()" />
</template>
<template v-slot:footer>
<LoadingSpan v-if="loading" class="m-2" message="Loading" />
</template>
</VirtualList>
</div>
</template>
<script>
import { useElementBounding } from "@vueuse/core";
import LoadingSpan from "components/LoadingSpan";
import { computed, ref } from "vue";
import VirtualList from "vue-virtual-scroll-list";
<script setup lang="ts">
import { nextTick, ref, watch } from "vue";
export default {
components: {
LoadingSpan,
VirtualList,
},
props: {
dataKey: { type: String, default: "id" },
offset: { type: Number, default: 0 },
loading: { type: Boolean, default: false },
items: { type: Array, default: null },
queryKey: { type: String, default: null },
},
setup() {
const listing = ref(null);
const { height } = useElementBounding(listing);
import IntersectionObservable from "./IntersectionObservable.vue";
import LoadingSpan from "@/components/LoadingSpan.vue";
const props = defineProps<{
items: any[];
queryKey?: string;
dataKey?: string;
loading?: boolean;
offset?: number;
}>();
const estimatedItemHeight = 40;
const estimatedItemCount = computed(() => {
const baseCount = Math.ceil(height.value / estimatedItemHeight);
return baseCount + 20;
const emit = defineEmits<{
(e: "scroll", currentOffset: number): void;
}>();
const root = ref<HTMLDivElement>();
const currentOffset = ref(0);
watch(
() => currentOffset.value,
(offset) => emit("scroll", offset)
);
watch(
() => props.queryKey,
() => {
root.value?.scrollTo({
top: 0,
});
}
);
return { listing, estimatedItemHeight, estimatedItemCount };
},
data() {
return {
previousStart: undefined,
};
},
watch: {
queryKey() {
this.listing.scrollToOffset(0);
},
watch(
() => props.offset,
async (offset) => {
await nextTick();
if (offset !== undefined) {
scrollToOffset(offset);
}
},
methods: {
onScroll() {
const rangeStart = this.listing.range.start;
if (this.previousStart !== rangeStart) {
this.previousStart = rangeStart;
this.$emit("scroll", rangeStart);
}
},
getOffset() {
return this.listing?.getOffset() || 0;
},
{ immediate: true }
);
function scrollToOffset(offset: number) {
const element = root.value?.querySelector(`.listing-layout-item[data-index="${offset}"]`);
element?.scrollIntoView();
}
const observer = new IntersectionObserver(
(items) => {
const intersecting = items.filter((item) => item.isIntersecting);
const indices = intersecting.map((item) => parseInt(item.target.getAttribute("data-index") ?? "0"));
currentOffset.value = indices.length > 0 ? Math.min(...indices) : 0;
},
};
{ root: root.value }
);
function getKey(item: unknown, index: number) {
if (props.dataKey) {
return (item as Record<string, unknown>)[props.dataKey];
} else {
return index;
}
}
</script>

<template>
<div ref="root" class="listing-layout">
<IntersectionObservable
v-for="(item, i) in props.items"
:key="getKey(item, i)"
class="listing-layout-item"
:data-index="i"
:observer="observer">
<slot name="item" :item="item" :current-offset="i" />
</IntersectionObservable>
<LoadingSpan v-if="props.loading" class="m-2" message="Loading" />
</div>
</template>

<style scoped lang="scss">
@import "scss/mixins.scss";
.listing-layout {
.listing {
@include absfill();
scroll-behavior: smooth;
overflow-y: scroll;
overflow-x: hidden;
}
position: absolute;
width: 100%;
height: 100%;
overflow-y: scroll;
}
</style>
18 changes: 12 additions & 6 deletions client/src/stores/historyItemsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,20 @@

import { reverse } from "lodash";
import { defineStore } from "pinia";
import Vue, { computed, ref } from "vue";
import { computed, ref, set } from "vue";

import type { DatasetSummary, HDCASummary } from "@/api";
import { HistoryFilters } from "@/components/History/HistoryFilters";
import { mergeArray } from "@/store/historyStore/model/utilities";
import { LastQueue } from "@/utils/lastQueue";
import { ActionSkippedError, LastQueue } from "@/utils/lastQueue";
import { urlData } from "@/utils/url";

export type HistoryItem = DatasetSummary | HDCASummary;

const limit = 100;

type ExpectedReturn = { stats: { total_matches: number }; contents: HistoryItem[] };
const queue = new LastQueue<typeof urlData>();
const queue = new LastQueue<typeof urlData>(1000, true);

export const useHistoryItemsStore = defineStore("historyItemsStore", () => {
const items = ref<Record<string, HistoryItem[]>>({});
Expand Down Expand Up @@ -59,13 +59,19 @@ export const useHistoryItemsStore = defineStore("historyItemsStore", () => {
const params = `v=dev&order=hid&offset=${offset}&limit=${limit}`;
const url = `/api/histories/${historyId}/contents?${params}&${queryString}`;
const headers = { accept: "application/vnd.galaxy.history.contents.stats+json" };
return await queue.enqueue(urlData, { url, headers, errorSimplify: false }, historyId).then((data) => {

try {
const data = await queue.enqueue(urlData, { url, headers, errorSimplify: false }, historyId);
const stats = (data as ExpectedReturn).stats;
totalMatchesCount.value = stats.total_matches;
const payload = (data as ExpectedReturn).contents;
const relatedHid = HistoryFilters.getFilterValue(filterText, "related");
saveHistoryItems(historyId, payload, relatedHid);
});
} catch (e) {
if (!(e instanceof ActionSkippedError)) {
throw e;
}
}
}

function saveHistoryItems(historyId: string, payload: HistoryItem[], relatedHid = null) {
Expand All @@ -76,7 +82,7 @@ export const useHistoryItemsStore = defineStore("historyItemsStore", () => {
payload.forEach((item: HistoryItem) => {
// current `item.hid` is related to item with hid = `relatedHid`
const relationKey = `${historyId}-${relatedHid}-${item.hid}`;
Vue.set(relatedItems.value, relationKey, true);
set(relatedItems.value, relationKey, true);
});
}
}
Expand Down
17 changes: 16 additions & 1 deletion client/src/utils/lastQueue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ type QueuedAction<T extends (...args: any) => R, R = unknown> = {
reject: (e: Error) => void;
};

export class ActionSkippedError extends Error {}

/**
* This queue waits until the current promise is resolved and only executes the last enqueued
* promise. Promises added between the last and the currently executing promise are skipped.
Expand All @@ -13,15 +15,28 @@ type QueuedAction<T extends (...args: any) => R, R = unknown> = {
*/
export class LastQueue<T extends (arg: any) => R, R = unknown> {
throttlePeriod: number;
/** Throw an error if a queued action is skipped. This avoids dangling promises */
rejectSkipped: boolean;
private queuedPromises: Record<string | number, QueuedAction<T, R>> = {};
private pendingPromise = false;

constructor(throttlePeriod = 1000) {
constructor(throttlePeriod = 1000, rejectSkipped = false) {
this.throttlePeriod = throttlePeriod;
this.rejectSkipped = rejectSkipped;
}

private skipPromise(key: string | number) {
if (!this.rejectSkipped) {
return;
}

const promise = this.queuedPromises[key];
promise?.reject(new ActionSkippedError());
}

async enqueue(action: T, arg: Parameters<T>[0], key: string | number = 0) {
return new Promise((resolve, reject) => {
this.skipPromise(key);
this.queuedPromises[key] = { action, arg, resolve, reject };
this.dequeue();
});
Expand Down
2 changes: 1 addition & 1 deletion client/src/utils/navigation/navigation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ history_panel:
elements_warning: '.dataset-collection-panel .controls .elements-warning'
tag_area_button: '.details .stateless-tags .toggle-button'
tag_area_input: '.details .stateless-tags .headless-multiselect input'
list_items: '.dataset-collection-panel .listing .content-item'
list_items: '.dataset-collection-panel .listing-layout .content-item'
back_to_history: svg[data-description="back to history"]

selectors:
Expand Down
9 changes: 9 additions & 0 deletions client/tests/jest/jest-environment.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,21 @@ import "jsdom-worker";

import JSDOMEnvironment from "jest-environment-jsdom";

class MockObserver {
constructor(...args) {}

observe(...args) {}
unobserve(...args) {}
}

export default class CustomJSDOMEnvironment extends JSDOMEnvironment {
constructor(...args) {
super(...args);

this.global.Worker = Worker;

this.global.IntersectionObserver = MockObserver;

// FIXME https://github.com/jsdom/jsdom/issues/3363
this.global.structuredClone = structuredClone;
}
Expand Down

0 comments on commit 4c461f3

Please sign in to comment.