Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[24.2] Guide users to collection builders #18857

Open
wants to merge 24 commits into
base: release_24.2
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
1053e4d
This really stablizes these tests - they sort of runaway without this.
jmchilton Nov 19, 2024
d82e773
fix pesky warning
ElectronicBlueberry Nov 21, 2024
9ea9112
add button for creating a list from run form field
ahmedhamidawan Sep 19, 2024
94ae28a
fully implement `list` collection creator in `FormData`
ahmedhamidawan Sep 26, 2024
87bf68a
add a `maintain-selection-order` prop to `FormSelectMany`
ahmedhamidawan Sep 26, 2024
d825138
fix reactivity of `ClickToEdit` and add some styling
ahmedhamidawan Oct 1, 2024
70eb4a2
`ListCollectionCreator`: add more types
ahmedhamidawan Oct 1, 2024
6798f5b
modernize/refactor `PairedListCollectionCreator` for input forms
ahmedhamidawan Oct 1, 2024
5953ea4
remove the extensions toggle, only include `isSubTypeOfAny` items
ahmedhamidawan Oct 1, 2024
59c5385
improve styling of collection create button in `FormData`
ahmedhamidawan Oct 1, 2024
f8dd555
change create new collection `ButtonSpinner` variant
ahmedhamidawan Oct 3, 2024
1a7a87a
change create new collection `ButtonSpinner` title; fix icon imports
ahmedhamidawan Oct 3, 2024
767c0b9
add history name to modal header
ahmedhamidawan Oct 4, 2024
566fdf8
restrict the `list:paired` creator to required extensions
ahmedhamidawan Oct 7, 2024
454a1e4
make the "pairing" section in `list:paired` builder expand/collapseable
ahmedhamidawan Oct 7, 2024
6eb858b
add `CollectionCreatorModal` that replaces `buildCollectionModal`
ahmedhamidawan Oct 18, 2024
fbea6fc
slight optimization
ahmedhamidawan Oct 18, 2024
1afaf91
(incomplete/WIP) add uploader to collection creator
ahmedhamidawan Oct 21, 2024
880a2de
`paired` collection items are now also reversed like history
ahmedhamidawan Nov 19, 2024
70a57b1
add uploaded files to collection directly
ahmedhamidawan Nov 19, 2024
11a70ef
Fix jest collection modal test to be more flexible, allowing title co…
dannon Nov 19, 2024
732451f
remove legacy JQuery collection creator modal files
ahmedhamidawan Nov 19, 2024
c4d9247
fix `PairedListCollectionCreator` jest
ahmedhamidawan Nov 19, 2024
5090d7b
fix selectors for the changes to the collection builder
ahmedhamidawan Nov 22, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions client/src/api/datatypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { type components } from "@/api";

export type CompositeFileInfo = components["schemas"]["CompositeFileInfo"];
3 changes: 3 additions & 0 deletions client/src/api/histories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { type components } from "@/api";

export type HistoryContentsResult = components["schemas"]["HistoryContentsResult"];
329 changes: 329 additions & 0 deletions client/src/components/Collections/CollectionCreatorModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,329 @@
<script setup lang="ts">
import { faCheckCircle, faUndo } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BAlert, BLink, BModal } from "bootstrap-vue";
import { computed, ref, watch } from "vue";

import type { HDASummary, HistoryItemSummary, HistorySummary } from "@/api";
import { createDatasetCollection } from "@/components/History/model/queries";
import { useCollectionBuilderItemsStore } from "@/stores/collectionBuilderItemsStore";
import { useHistoryStore } from "@/stores/historyStore";
import localize from "@/utils/localization";
import { orList } from "@/utils/strings";

import type { CollectionType, DatasetPair } from "../History/adapters/buildCollectionModal";

import ListCollectionCreator from "./ListCollectionCreator.vue";
import PairCollectionCreator from "./PairCollectionCreator.vue";
import PairedListCollectionCreator from "./PairedListCollectionCreator.vue";
import Heading from "@/components/Common/Heading.vue";
import GenericItem from "@/components/History/Content/GenericItem.vue";
import LoadingSpan from "@/components/LoadingSpan.vue";

interface Props {
historyId: string;
showModal: boolean;
collectionType: CollectionType;
selectedItems?: HistoryItemSummary[];
defaultHideSourceItems?: boolean;
extensions?: string[];
fromRulesInput?: boolean;
hideModalOnCreate?: boolean;
filterText?: string;
}
const props = defineProps<Props>();

const emit = defineEmits<{
(e: "created-collection", collection: any): void;
(e: "update:show-modal", showModal: boolean): void;
}>();

/** Computed toggle that handles opening and closing the modal */
const localShowToggle = computed({
get: () => props.showModal,
set: (value: boolean) => {
emit("update:show-modal", value);
},
});

// Create Collection refs
const creatingCollection = ref(false);
const createCollectionError = ref<string | null>(null);
const createdCollection = ref<any>(null);

// History items variables
const historyItemsError = ref<string | null>(null);
const collectionItemsStore = useCollectionBuilderItemsStore();
const historyStore = useHistoryStore();
const history = computed(() => historyStore.getHistoryById(props.historyId));
const historyId = computed(() => props.historyId);
const localFilterText = computed(() => props.filterText || "");
const historyUpdateTime = computed(() => history.value?.update_time);
const isFetchingItems = computed(() => collectionItemsStore.isFetching[localFilterText.value]);
const historyDatasets = computed(() => {
if (collectionItemsStore.cachedDatasetsForFilterText) {
return collectionItemsStore.cachedDatasetsForFilterText[localFilterText.value] || [];
} else {
return [];
}
});

/** Flag for the initial fetch of history items */
const initialFetch = ref(false);

/** Whether a list of items was selected to create a collection from */
const fromSelection = computed(() => !!props.selectedItems?.length);

/** Items to create the collection from */
const creatorItems = computed(() => (fromSelection.value ? props.selectedItems : historyDatasets.value));

watch(
() => localShowToggle.value,
async (show) => {
if (show) {
await fetchHistoryDatasets();
if (!initialFetch.value) {
initialFetch.value = true;
}
}
},
{ immediate: true }
);

// Fetch items when history ID or update time changes, only if localShowToggle is true
watch([historyId, historyUpdateTime, localFilterText], async () => {
if (localShowToggle.value) {
await fetchHistoryDatasets();
}
});

// If there is a change in `historyDatasets`, but we have selected items, we should update the selected items
watch(
() => historyDatasets.value,
(newDatasets) => {
if (fromSelection.value) {
// find each selected item in the new datasets, and update it
props.selectedItems?.forEach((selectedItem) => {
const newDataset = newDatasets.find((dataset) => dataset.id === selectedItem.id);
if (newDataset) {
Object.assign(selectedItem, newDataset);
}
});
}
}
);

const modalTitle = computed(() => {
if (props.collectionType === "list") {
return localize(
`Create a collection from a list of ${fromSelection.value ? "selected" : ""} ${
props.extensions?.length ? orList(props.extensions) : ""
} datasets`
);
} else if (props.collectionType === "list:paired") {
return localize(
`Create a collection of ${fromSelection.value ? "selected" : ""} ${
props.extensions?.length ? orList(props.extensions) : ""
} dataset pairs`
);
} else if (props.collectionType === "paired") {
return localize(
`Create a ${props.extensions?.length ? orList(props.extensions) : ""} dataset pair collection ${
fromSelection.value ? "from selected items" : ""
}`
);
} else {
return localize("Create a collection");
}
});

// Methods
function createListCollection(elements: HDASummary[], name: string, hideSourceItems: boolean) {
const returnedElems = elements.map((element) => ({
id: element.id,
name: element.name,
//TODO: this allows for list:list even if the implementation does not - reconcile
src: "src" in element ? element.src : element.history_content_type == "dataset" ? "hda" : "hdca",
}));
return createHDCA(returnedElems, "list", name, hideSourceItems);
}

function createListPairedCollection(elements: DatasetPair[], name: string, hideSourceItems: boolean) {
const returnedElems = elements.map((pair) => ({
collection_type: "paired",
src: "new_collection",
name: pair.name,
element_identifiers: [
{
name: "forward",
id: pair.forward.id,
src: "src" in pair.forward ? pair.forward.src : "hda",
},
{
name: "reverse",
id: pair.reverse.id,
src: "src" in pair.reverse ? pair.reverse.src : "hda",
},
],
}));
return createHDCA(returnedElems, "list:paired", name, hideSourceItems);
}

function createPairedCollection(elements: DatasetPair, name: string, hideSourceItems: boolean) {
const { forward, reverse } = elements;
const returnedElems = [
{ name: "forward", src: "src" in forward ? forward.src : "hda", id: forward.id },
{ name: "reverse", src: "src" in reverse ? reverse.src : "hda", id: reverse.id },
];
return createHDCA(returnedElems, "paired", name, hideSourceItems);
}

async function createHDCA(
element_identifiers: any[],
collection_type: CollectionType,
name: string,
hide_source_items: boolean,
options = {}
) {
try {
creatingCollection.value = true;
const collection = await createDatasetCollection(history.value as HistorySummary, {
collection_type,
name,
hide_source_items,
element_identifiers,
options,
});

emit("created-collection", collection);
createdCollection.value = collection;

if (props.hideModalOnCreate) {
hideModal();
}
} catch (error) {
createCollectionError.value = error as string;
} finally {
creatingCollection.value = false;
}
}

async function fetchHistoryDatasets() {
const { error } = await collectionItemsStore.fetchDatasetsForFiltertext(
historyId.value,
historyUpdateTime.value,
localFilterText.value
);
if (error) {
historyItemsError.value = error;
console.error("Error fetching history items:", historyItemsError.value);
} else {
historyItemsError.value = null;
}
}

function hideModal() {
localShowToggle.value = false;
}

function resetModal() {
createCollectionError.value = null;
createdCollection.value = null;
}
</script>

<template>
<BModal
id="collection-creator-modal"
v-model="localShowToggle"
:busy="(fromSelection && isFetchingItems) || creatingCollection"
modal-class="ui-modal collection-creator-modal"
:hide-footer="!createdCollection && !createCollectionError"
:ok-disabled="!!createdCollection || !!createCollectionError"
:cancel-title="localize('Exit')"
footer-class="d-flex justify-content-between"
:ok-title="localize('Create Collection')"
@hidden="resetModal">
<template v-slot:modal-header>
<Heading class="w-100" size="sm">
<div class="d-flex justify-content-between unselectable w-100">
<div>{{ modalTitle }}</div>
<div v-if="!!history">
From history: <b>{{ history.name }}</b>
</div>
</div>
</Heading>
</template>
<BAlert v-if="isFetchingItems && !initialFetch" variant="info" show>
<LoadingSpan :message="localize('Loading items')" />
</BAlert>
<BAlert v-else-if="!fromSelection && historyItemsError" variant="danger" show>
{{ historyItemsError }}
</BAlert>
<BAlert v-else-if="!creatorItems?.length" variant="info" show>
{{ localize("No items available to create a collection.") }}
</BAlert>
<BAlert v-else-if="creatingCollection" variant="info" show>
<LoadingSpan :message="localize('Creating collection')" />
</BAlert>
<BAlert v-else-if="createCollectionError" variant="danger" show>
{{ createCollectionError }}
<BLink class="text-decoration-none" @click.stop.prevent="resetModal">
<FontAwesomeIcon :icon="faUndo" fixed-width />
{{ localize("Try again") }}
</BLink>
</BAlert>
<div v-else-if="createdCollection">
<BAlert variant="success" show>
<FontAwesomeIcon :icon="faCheckCircle" class="text-success" fixed-width />
{{ localize("Collection created successfully.") }}
<BLink class="text-decoration-none" @click.stop.prevent="resetModal">
<FontAwesomeIcon :icon="faUndo" fixed-width />
{{ localize("Create another collection") }}
</BLink>
</BAlert>

<!-- TODO: This is a bit shady, better if we confirm it is a collection type -->
<GenericItem :item-id="createdCollection.id" item-src="hdca" />
</div>
<ListCollectionCreator
v-else-if="props.collectionType === 'list'"
:history-id="props.historyId"
:initial-elements="creatorItems"
:default-hide-source-items="props.defaultHideSourceItems"
:from-selection="fromSelection"
:extensions="props.extensions"
@clicked-create="createListCollection"
@on-cancel="hideModal" />
<PairedListCollectionCreator
v-else-if="props.collectionType === 'list:paired'"
:history-id="props.historyId"
:initial-elements="creatorItems"
:default-hide-source-items="props.defaultHideSourceItems"
:from-selection="fromSelection"
:extensions="props.extensions"
@clicked-create="createListPairedCollection"
@on-cancel="hideModal" />
<PairCollectionCreator
v-else-if="props.collectionType === 'paired'"
:history-id="props.historyId"
:initial-elements="creatorItems"
:default-hide-source-items="props.defaultHideSourceItems"
:from-selection="fromSelection"
:extensions="props.extensions"
@clicked-create="createPairedCollection"
@on-cancel="hideModal" />
</BModal>
</template>

<style lang="scss">
/** NOTE: Not using `<style scoped> here because these classes are
`BModal` `body-class` and `content-class` and don't seem to work
with scoped */
.collection-creator-modal {
.modal-dialog {
width: 85%;
max-width: 100%;
}
}
</style>
Loading
Loading