Merge pull request #17318 from Renni771/port-history-panel-to-composition-api-and-typescript

Port HistoryPanel component to composition API and typescript
This commit is contained in:
Ahmed Hamid Awan
2024-01-23 23:19:40 +05:00
committed by GitHub
4 changed files with 395 additions and 331 deletions
@@ -1,8 +1,361 @@
<script setup lang="ts">
import { storeToRefs } from "pinia";
import { GetComponentPropTypes } from "types/utilityTypes";
import { computed, onMounted, PropType, ref, set as VueSet, unref, watch } from "vue";
import { copyDataset } from "@/api/datasets";
import ExpandedItems from "@/components/History/Content/ExpandedItems";
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 { rewatchHistory } from "@/store/historyStore/model/watchHistory";
import { type HistoryItem, useHistoryItemsStore } from "@/stores/historyItemsStore";
import { useHistoryStore } from "@/stores/historyStore";
import { type Alias, getOperatorForAlias } from "@/utils/filtering";
import HistoryCounter from "./HistoryCounter.vue";
import HistoryDetails from "./HistoryDetails.vue";
import HistoryDropZone from "./HistoryDropZone.vue";
import HistoryEmpty from "./HistoryEmpty.vue";
import HistoryMessages from "./HistoryMessages.vue";
import HistoryOperations from "./HistoryOperations/HistoryOperations.vue";
import OperationErrorDialog from "./HistoryOperations/OperationErrorDialog.vue";
import SelectionChangeWarning from "./HistoryOperations/SelectionChangeWarning.vue";
import HistorySelectionOperations from "./HistoryOperations/SelectionOperations.vue";
import HistorySelectionStatus from "./HistoryOperations/SelectionStatus.vue";
import FilterMenu from "@/components/Common/FilterMenu.vue";
import ContentItem from "@/components/History/Content/ContentItem.vue";
import ListingLayout from "@/components/History/Layout/ListingLayout.vue";
import LoadingSpan from "@/components/LoadingSpan.vue";
interface BackendFilterError {
err_msg: string;
err_code: number;
column?: string;
col?: string;
operation?: Alias;
op?: Alias;
value?: string;
val?: string;
ValueError?: string;
}
const props = defineProps({
listOffset: {
type: Number,
default: 0,
},
history: {
type: Object as PropType<Record<string, any> & GetComponentPropTypes<typeof HistoryCounter>["history"]>,
required: true,
},
filter: {
type: String,
default: "",
},
canEditHistory: {
type: Boolean,
default: true,
},
shouldShowControls: {
type: Boolean,
default: true,
},
filterable: {
type: Boolean,
default: false,
},
});
const filterClass = HistoryFilters;
const filterText = ref("");
const invisibleHistoryItems = ref<Record<string, any>>({});
const isLoading = ref(false);
const offsetQueryParam = ref(0);
const searchError = ref<BackendFilterError | undefined>(undefined);
const showAdvanced = ref(false);
const showDropZone = ref(false);
const operationRunning = ref(null);
const operationError = ref(null);
const querySelectionBreak = ref(false);
const dragTarget = ref<EventTarget | null>(null);
const { currentFilterText, currentHistoryId } = storeToRefs(useHistoryStore());
const { lastCheckedTime, totalMatchesCount, isWatching } = storeToRefs(useHistoryItemsStore());
const historyStore = useHistoryStore();
const historyItemsStore = useHistoryItemsStore();
const historyUpdateTime = computed(() => {
return props.history.update_time as Date;
});
const queryKey = computed(() => {
return `${props.history.id}-${filterText.value}`;
});
const queryDefault = computed(() => {
return !filterText.value;
});
const isProcessing = computed(() => {
if (!operationRunning.value) {
return false;
}
return operationRunning?.value >= props.history.update_time;
});
const historyItems = computed(() => {
return historyItemsStore.getHistoryItems(props.history.id, filterText.value);
});
const formattedSearchError = computed(() => {
const newError = unref(searchError);
if (!newError) {
return null;
}
const { column, col, operation, op, value, val, err_msg, ValueError } = newError;
const alias = operation || op;
const operator = alias ? getOperatorForAlias(alias) : ":";
return {
filter: `${column || col}${operator}`,
value: value || val,
msg: err_msg,
typeError: ValueError,
};
});
const storeFilterText = computed(() => {
if (props.history.id !== currentHistoryId.value) {
return "";
}
return currentFilterText.value || "";
});
watch(offsetQueryParam, () => loadHistoryItems());
watch(
() => queryKey.value,
() => {
invisibleHistoryItems.value = {};
offsetQueryParam.value = 0;
loadHistoryItems();
}
);
watch(
() => props.filter,
(newVal) => (filterText.value = newVal)
);
watch(filterText, (newFilterTextValue) => {
if (props.filterable) {
historyStore.setFilterText(props.history.id, newFilterTextValue);
}
});
watch(storeFilterText, (newFilterTextValue) => {
if (props.filterable) {
filterText.value = newFilterTextValue;
}
});
watch(historyUpdateTime, async () => await loadHistoryItems());
watch(
() => props.history.id,
(newValue, currentValue) => {
if (newValue !== currentValue) {
operationRunning.value = null;
}
}
);
watch(historyItems, (newHistoryItems) => {
if (!invisibleHistoryItems.value) {
return;
}
// Re-hide invisible history items when `historyItems` changes
for (const newHistoryItem of newHistoryItems) {
if (invisibleHistoryItems.value[newHistoryItem.hid]) {
VueSet(invisibleHistoryItems.value, newHistoryItem.hid, false);
}
}
});
function getHighlight(item: HistoryItem) {
if (unref(isLoading)) {
return undefined;
}
const highlightsKey = parseInt(filterClass.getFilterValue(unref(filterText), "related"));
if (!highlightsKey) {
return undefined;
}
if (highlightsKey === item.hid) {
return "active";
}
if (item.hid > highlightsKey) {
return "output";
}
return "input";
}
function hasMatches(items: HistoryItem[]) {
return !!items && items.length > 0;
}
function isDataset(item: HistoryItem) {
return item.history_content_type === "dataset";
}
async function loadHistoryItems() {
isLoading.value = true;
try {
await historyItemsStore.fetchHistoryItems(props.history.id, filterText.value, offsetQueryParam.value);
searchError.value = undefined;
} catch (error: any) {
if (error.response && error.response.data && error.response.data.err_msg) {
console.debug("HistoryPanel - Load items error:", error.response.data.err_msg);
searchError.value = error.response.data;
} else {
console.debug("HistoryPanel - Load items error.", error);
}
} finally {
isLoading.value = false;
}
}
async function onDelete(item: HistoryItem, recursive = false) {
isLoading.value = true;
setInvisible(item);
try {
await deleteContent(item, { recursive: recursive });
} finally {
isLoading.value = false;
}
}
function onHideSelection(selectedItems: HistoryItem[]) {
for (const item of selectedItems) {
setInvisible(item);
}
}
function onScroll(newOffset: number) {
offsetQueryParam.value = newOffset;
}
async function onUndelete(item: HistoryItem) {
setInvisible(item);
isLoading.value = true;
try {
await updateContentFields(item, { deleted: false });
} finally {
isLoading.value = false;
}
}
async function onUnhide(item: HistoryItem) {
setInvisible(item);
isLoading.value = true;
try {
await updateContentFields(item, { visible: true });
} finally {
isLoading.value = false;
}
}
function reloadContents() {
rewatchHistory();
}
function setInvisible(item: HistoryItem) {
VueSet(unref(invisibleHistoryItems), item.hid, true);
}
function onTagChange(item: HistoryItem, newTags: string[]) {
item.tags = newTags;
}
function onOperationError(error: any) {
console.debug("HistoryPanel - Operation error.", error);
operationError.value = error;
}
function onDragEnter(e: DragEvent) {
dragTarget.value = e.target;
showDropZone.value = true;
}
function onDragLeave(e: DragEvent) {
if (dragTarget.value === e.target) {
showDropZone.value = false;
}
}
async function onDrop(evt: any) {
showDropZone.value = false;
let data;
try {
data = JSON.parse(evt.dataTransfer.getData("text"))[0];
} catch (error) {
// this was not a valid object for this dropzone, ignore
}
if (!data || data.history_id === props.history.id) {
return;
}
try {
const dataSource = data.history_content_type === "dataset" ? "hda" : "hdca";
await copyDataset(data.id, props.history.id, data.history_content_type, dataSource);
if (data.history_content_type === "dataset") {
Toast.info("Dataset copied to history");
} else {
Toast.info("Collection copied to history");
}
historyStore.loadHistoryById(props.history.id);
} catch (error) {
Toast.error(`${error}`);
}
}
function updateFilterValue(newFilterText: string, newValue: any) {
const currentFilterText = filterText.value;
filterText.value = filterClass.setFilterValue(currentFilterText, newFilterText, newValue);
}
function getItemKey(item: HistoryItem) {
return item.type_id;
}
onMounted(async () => {
// `filterable` here indicates if this is the current history panel
if (props.filterable && !props.filter) {
filterText.value = storeFilterText.value;
}
await loadHistoryItems();
});
</script>
<template>
<ExpandedItems
v-slot="{ expandedCount, isExpanded, setExpanded, collapseAll }"
:scope-key="historyId"
:get-item-key="(item) => item.type_id">
:scope-key="props.history.id"
:get-item-key="getItemKey">
<SelectedItems
v-slot="{
selectedItems,
@@ -16,10 +369,11 @@
resetSelection,
}"
:scope-key="queryKey"
:get-item-key="(item) => item.type_id"
:get-item-key="getItemKey"
:filter-text="filterText"
:total-items-in-query="totalMatchesCount"
@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"
@drop.prevent="onDrop"
@@ -27,6 +381,7 @@
@dragover.prevent
@dragleave.prevent="onDragLeave">
<slot name="navigation" :history="history" />
<FilterMenu
v-if="filterable"
class="content-operations-filters mx-3"
@@ -34,25 +389,32 @@
placeholder="search datasets"
:filter-class="filterClass"
:filter-text.sync="filterText"
:loading="loading"
:loading="isLoading"
:search-error="searchError"
:show-advanced.sync="showAdvanced" />
<section v-if="!showAdvanced">
<HistoryDetails :history="history" :writeable="writable" @update:history="updateHistory($event)" />
<HistoryDetails
:history="history"
:writeable="canEditHistory"
@update:history="historyStore.updateHistory($event)" />
<HistoryMessages :history="history" />
<HistoryCounter
:history="history"
:is-watching="isWatching"
:last-checked="lastCheckedTime"
:show-controls="showControls"
:show-controls="shouldShowControls"
:filter-text.sync="filterText"
@reloadContents="reloadContents" />
<HistoryOperations
v-if="showControls"
v-if="shouldShowControls"
:history="history"
:show-selection="showSelection"
:expanded-count="expandedCount"
:has-matches="hasMatches(itemsLoaded)"
:has-matches="hasMatches(historyItems)"
:operation-running.sync="operationRunning"
@update:show-selection="setShowSelection"
@collapse-all="collapseAll">
@@ -69,23 +431,27 @@
@operation-error="onOperationError"
@hide-selection="onHideSelection"
@reset-selection="resetSelection" />
<HistorySelectionStatus
v-if="showSelection"
:selection-size="selectionSize"
@select-all="selectAllInCurrentQuery(itemsLoaded)"
@select-all="selectAllInCurrentQuery(historyItems)"
@reset-selection="resetSelection" />
</template>
</HistoryOperations>
<SelectionChangeWarning :query-selection-break="querySelectionBreak" />
<OperationErrorDialog
v-if="operationError"
:operation-error="operationError"
@hide="operationError = null" />
</section>
<section v-if="!showAdvanced" class="position-relative flex-grow-1 scroller">
<HistoryDropZone v-if="showDropZone" />
<div>
<div v-if="loading && itemsLoaded && itemsLoaded.length === 0">
<div v-if="isLoading && historyItems && historyItems.length === 0">
<b-alert class="m-2" variant="info" show>
<LoadingSpan message="Loading History" />
</b-alert>
@@ -93,8 +459,9 @@
<b-alert v-else-if="isProcessing" class="m-2" variant="info" show>
<LoadingSpan message="Processing operation" />
</b-alert>
<div v-else-if="itemsLoaded.length === 0">
<HistoryEmpty v-if="queryDefault" :writable="writable" class="m-2" />
<div v-else-if="historyItems.length === 0">
<HistoryEmpty v-if="queryDefault" :writable="canEditHistory" class="m-2" />
<b-alert v-else-if="formattedSearchError" class="m-2" variant="danger" show>
Error in filter:
<a href="javascript:void(0)" @click="showAdvanced = true">
@@ -108,26 +475,26 @@
<ListingLayout
v-else
:offset="listOffset"
:items="itemsLoaded"
:items="historyItems"
:query-key="queryKey"
@scroll="onScroll">
<template v-slot:item="{ item, currentOffset }">
<ContentItem
v-if="!invisible[item.hid]"
v-if="!invisibleHistoryItems[item.hid]"
:id="item.hid"
is-history-item
:item="item"
:name="item.name"
:writable="writable"
:writable="canEditHistory"
:expand-dataset="isExpanded(item)"
:is-dataset="isDataset(item)"
:highlight="getHighlight(item)"
:selected="isSelected(item)"
:selectable="showSelection"
:filterable="filterable"
@tag-click="updateFilterVal('tag', $event)"
@tag-click="updateFilterValue('tag', $event)"
@tag-change="onTagChange"
@toggleHighlights="updateFilterVal('related', item.hid)"
@toggleHighlights="updateFilterValue('related', item.hid)"
@update:expand-dataset="setExpanded(item, $event)"
@update:selected="setSelected(item, $event)"
@view-collection="$emit('view-collection', item, currentOffset)"
@@ -142,307 +509,3 @@
</SelectedItems>
</ExpandedItems>
</template>
<script>
import FilterMenu from "components/Common/FilterMenu";
import ContentItem from "components/History/Content/ContentItem";
import ExpandedItems from "components/History/Content/ExpandedItems";
import SelectedItems from "components/History/Content/SelectedItems";
import { HistoryFilters } from "components/History/HistoryFilters";
import ListingLayout from "components/History/Layout/ListingLayout";
import { deleteContent, updateContentFields } from "components/History/model/queries";
import LoadingSpan from "components/LoadingSpan";
import { Toast } from "composables/toast";
import { mapActions, mapState, storeToRefs } from "pinia";
import { rewatchHistory } from "store/historyStore/model/watchHistory";
import { useHistoryItemsStore } from "stores/historyItemsStore";
import { useHistoryStore } from "stores/historyStore";
import { getOperatorForAlias } from "utils/filtering";
import Vue from "vue";
import { copyDataset } from "@/api/datasets";
import HistoryCounter from "./HistoryCounter";
import HistoryDetails from "./HistoryDetails";
import HistoryDropZone from "./HistoryDropZone";
import HistoryEmpty from "./HistoryEmpty";
import HistoryMessages from "./HistoryMessages";
import HistoryOperations from "./HistoryOperations/HistoryOperations";
import OperationErrorDialog from "./HistoryOperations/OperationErrorDialog";
import SelectionChangeWarning from "./HistoryOperations/SelectionChangeWarning";
import HistorySelectionOperations from "./HistoryOperations/SelectionOperations";
import HistorySelectionStatus from "./HistoryOperations/SelectionStatus";
export default {
components: {
ContentItem,
ExpandedItems,
FilterMenu,
HistoryCounter,
HistoryMessages,
HistoryDetails,
HistoryDropZone,
HistoryEmpty,
HistoryOperations,
HistorySelectionOperations,
HistorySelectionStatus,
LoadingSpan,
ListingLayout,
SelectedItems,
SelectionChangeWarning,
OperationErrorDialog,
},
props: {
listOffset: { type: Number, default: 0 },
history: { type: Object, required: true },
filter: { type: String, default: "" },
writable: { type: Boolean, default: true },
showControls: { type: Boolean, default: true },
filterable: { type: Boolean, default: false },
},
setup() {
const { currentFilterText, currentHistoryId } = storeToRefs(useHistoryStore());
const { lastCheckedTime, totalMatchesCount, isWatching } = storeToRefs(useHistoryItemsStore());
return { currentFilterText, currentHistoryId, lastCheckedTime, totalMatchesCount, isWatching };
},
data() {
return {
filterText: "",
filterClass: HistoryFilters,
invisible: {},
loading: false,
offset: 0,
searchError: null,
showAdvanced: false,
showDropZone: false,
operationRunning: null,
operationError: null,
querySelectionBreak: false,
};
},
computed: {
...mapState(useHistoryItemsStore, ["getHistoryItems"]),
/** @returns {String} */
historyId() {
return this.history.id;
},
/** @returns {Date} */
historyUpdateTime() {
return this.history.update_time;
},
/** @returns {String} */
queryKey() {
return `${this.historyId}-${this.filterText}`;
},
/** @returns {Boolean} */
queryDefault() {
return !this.filterText;
},
/** @returns {Boolean} */
hasFilters() {
return !this.queryDefault;
},
/** @returns {Boolean} */
isProcessing() {
return this.operationRunning >= this.history.update_time;
},
/** @returns {Array} */
itemsLoaded() {
return this.getHistoryItems(this.historyId, this.filterText);
},
/** @returns {Object} */
formattedSearchError() {
if (this.searchError) {
const { column, col, operation, op, value, val, err_msg, ValueError } = this.searchError;
const alias = operation || op;
const operator = alias ? getOperatorForAlias(alias) : ":";
const formatted = {
filter: `${column || col}${operator}`,
value: value || val,
msg: err_msg,
typeError: ValueError,
};
return formatted;
} else {
return null;
}
},
/** @returns {String} */
storeFilterText() {
if (this.historyId === this.currentHistoryId) {
return this.currentFilterText || "";
} else {
return "";
}
},
},
watch: {
queryKey() {
this.invisible = {};
this.offset = 0;
this.loadHistoryItems();
},
historyId(newVal, oldVal) {
if (newVal !== oldVal) {
this.operationRunning = null;
}
},
filter(newVal) {
this.filterText = newVal;
},
filterText(newVal) {
if (this.filterable) {
this.setFilterText(this.historyId, newVal);
}
},
storeFilterText(newVal) {
if (this.filterable) {
this.filterText = newVal;
}
},
offset() {
this.loadHistoryItems();
},
async historyUpdateTime() {
await this.loadHistoryItems();
},
itemsLoaded(newItems) {
if (this.invisible) {
newItems.forEach((item) => {
if (this.invisible[item.hid]) {
Vue.set(this.invisible, item.hid, false);
}
});
}
},
},
async mounted() {
// `filterable` here indicates if this is the current history panel
if (this.filterable && !this.filter) {
this.filterText = this.storeFilterText;
}
await this.loadHistoryItems();
},
methods: {
...mapActions(useHistoryStore, ["loadHistoryById", "setFilterText", "updateHistory"]),
...mapActions(useHistoryItemsStore, ["fetchHistoryItems"]),
getHighlight(item) {
const highlightsKey = this.filterClass.getFilterValue(this.filterText, "related");
if (!this.loading) {
if (highlightsKey == item.hid) {
return "active";
} else if (highlightsKey) {
if (item.hid > highlightsKey) {
return "output";
} else {
return "input";
}
}
} else {
return null;
}
},
hasMatches(items) {
return !!items && items.length > 0;
},
isDataset(item) {
return item.history_content_type == "dataset";
},
async loadHistoryItems() {
this.loading = true;
try {
await this.fetchHistoryItems(this.historyId, this.filterText, this.offset);
this.searchError = null;
} catch (error) {
if (error.response && error.response.data && error.response.data.err_msg) {
console.debug("HistoryPanel - Load items error:", error.response.data.err_msg);
this.searchError = error.response.data;
} else {
console.debug("HistoryPanel - Load items error.", error);
}
} finally {
this.loading = false;
}
},
async onDelete(item, recursive = false) {
this.loading = true;
this.setInvisible(item);
await deleteContent(item, { recursive: recursive });
this.loading = false;
},
onHideSelection(selectedItems) {
selectedItems.forEach((item) => {
this.setInvisible(item);
});
},
onScroll(offset) {
this.offset = offset;
},
async onUndelete(item) {
this.setInvisible(item);
this.loading = true;
await updateContentFields(item, { deleted: false });
this.loading = false;
},
async onUnhide(item) {
this.setInvisible(item);
this.loading = true;
await updateContentFields(item, { visible: true });
this.loading = false;
},
reloadContents() {
rewatchHistory();
},
setInvisible(item) {
Vue.set(this.invisible, item.hid, true);
},
onTagChange(item, newTags) {
item.tags = newTags;
},
onOperationError(error) {
console.debug("HistoryPanel - Operation error.", error);
this.operationError = error;
},
onDragEnter(e) {
this.dragTarget = e.target;
this.showDropZone = true;
},
onDragLeave(e) {
if (this.dragTarget == e.target) {
this.showDropZone = false;
}
},
async onDrop(evt) {
this.showDropZone = false;
let data;
try {
data = JSON.parse(evt.dataTransfer.getData("text"))[0];
} catch (error) {
// this was not a valid object for this dropzone, ignore
}
if (data) {
const dataSource = data.history_content_type === "dataset" ? "hda" : "hdca";
if (data.history_id != this.historyId) {
try {
await copyDataset(data.id, this.historyId, data.history_content_type, dataSource);
if (data.history_content_type === "dataset") {
Toast.info("Dataset copied to history");
} else {
Toast.info("Collection copied to history");
}
this.loadHistoryById(this.historyId);
} catch (error) {
this.onError(error);
}
}
}
},
onError(error) {
Toast.error(`${error}`);
},
updateFilterVal(newFilter, newVal) {
this.filterText = this.filterClass.setFilterValue(this.filterText, newFilter, newVal);
},
},
};
</script>
@@ -8,6 +8,7 @@ import { getHistoryByIdFromServer, setCurrentHistoryOnServer } from "stores/serv
import { useUserStore } from "stores/userStore";
import { getLocalVue } from "tests/jest/helpers";
import ContentItem from "./Content/ContentItem";
import HistoryView from "./HistoryView";
const localVue = getLocalVue();
@@ -65,10 +66,6 @@ async function createWrapper(localVue, currentUserId, history) {
const wrapper = mount(HistoryView, {
propsData: { id: history.id },
localVue,
stubs: {
icon: { template: "<div></div>" },
ContentItem: true,
},
provide: {
store: {
dispatch: jest.fn,
@@ -121,12 +118,12 @@ describe("History center panel View", () => {
expectCorrectLayout(wrapper);
// make sure all history items show up
const historyItems = wrapper.findAll("contentitem-stub");
const historyItems = wrapper.findAllComponents(ContentItem);
expect(historyItems.length).toBe(10);
for (let i = 0; i < historyItems.length; i++) {
const hid = historyItems.length - i;
expect(historyItems.at(i).attributes("id")).toBe(`${hid}`);
expect(historyItems.at(i).attributes("name")).toBe(`Dataset ${hid}`);
expect(historyItems.at(i).props("id")).toBe(hid);
expect(historyItems.at(i).props("name")).toBe(`Dataset ${hid}`);
}
});
@@ -3,6 +3,7 @@
<b-alert v-if="showHistoryStateInfo" variant="info" show data-description="history state info">
{{ historyStateInfoMessage }}
</b-alert>
<div class="flex-row flex-grow-0 pb-3">
<b-button
v-if="userOwnsHistory"
@@ -14,6 +15,7 @@
@click="setCurrentHistory(history.id)">
Switch to this history
</b-button>
<b-button
v-if="canImportHistory"
v-b-modal:copy-history-modal
@@ -24,6 +26,7 @@
Import this history
</b-button>
</div>
<CollectionPanel
v-if="selectedCollections.length && selectedCollections[0].history_id == id"
:history="history"
@@ -33,10 +36,11 @@
<HistoryPanel
v-else
:history="history"
:writable="canEditHistory"
:show-controls="false"
:can-edit-history="canEditHistory"
:should-show-controls="false"
filterable
@view-collection="onViewCollection" />
<CopyModal id="copy-history-modal" :history="history" />
</div>
</template>
+1 -1
View File
@@ -14,7 +14,7 @@ import { mergeArray } from "@/store/historyStore/model/utilities";
import { LastQueue } from "@/utils/lastQueue";
import { urlData } from "@/utils/url";
type HistoryItem = DatasetSummary | HDCASummary;
export type HistoryItem = DatasetSummary | HDCASummary;
const limit = 100;