mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-09-01 15:37:32 +08:00
Merge pull request #22231 from itisAliRH/btable-to-gtable-selection-dialogs
Migrate Selection Dialog Components from BTable to GTable
This commit is contained in:
@@ -9,6 +9,17 @@ export type SortOrder = "asc" | "desc";
|
||||
/** Table field alignment options */
|
||||
export type FieldAlignment = "left" | "center" | "right";
|
||||
|
||||
/** Shared class value shape used by row/cell class bindings */
|
||||
export type TableClassValue = string | readonly string[] | Record<string, boolean>;
|
||||
|
||||
/** Optional per-item class metadata a row can carry to style itself/its cells */
|
||||
export interface TableItemClassMeta {
|
||||
/** CSS classes applied to the row */
|
||||
class?: TableClassValue;
|
||||
/** CSS classes applied to individual cells, keyed by field key */
|
||||
cellClass?: Record<string, TableClassValue>;
|
||||
}
|
||||
|
||||
/** Table field definition */
|
||||
export interface TableField {
|
||||
/** Unique key for the field (matches data property name) */
|
||||
@@ -18,11 +29,11 @@ export interface TableField {
|
||||
/** Whether the column is sortable */
|
||||
sortable?: boolean;
|
||||
/** Custom CSS classes for the column */
|
||||
class?: string;
|
||||
class?: TableClassValue;
|
||||
/** Custom CSS classes for the header cell */
|
||||
headerClass?: string;
|
||||
/** Custom CSS classes for data cells */
|
||||
cellClass?: string;
|
||||
cellClass?: TableClassValue;
|
||||
/** Column alignment */
|
||||
align?: FieldAlignment;
|
||||
/** Width of the column (CSS value) */
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
TableAction,
|
||||
TableEmptyState,
|
||||
TableField,
|
||||
TableItemClassMeta,
|
||||
} from "./GTable.types";
|
||||
|
||||
import GOverlay from "@/components/BaseComponents/GOverlay.vue";
|
||||
@@ -186,6 +187,14 @@ interface Props {
|
||||
*/
|
||||
perPage?: number;
|
||||
|
||||
/**
|
||||
* Item field to expose as each row's `data-pk` attribute, enabling row
|
||||
* selection by key (e.g. in selenium selectors). When unset, no `data-pk`
|
||||
* is rendered.
|
||||
* @default ""
|
||||
*/
|
||||
primaryKey?: string;
|
||||
|
||||
/**
|
||||
* Whether to show striped rows
|
||||
* @default true
|
||||
@@ -210,6 +219,13 @@ interface Props {
|
||||
*/
|
||||
selectedItems?: number[];
|
||||
|
||||
/**
|
||||
* Array of item indices whose selection is partial/mixed. Their row checkbox
|
||||
* renders as indeterminate (e.g. a folder with only some children selected).
|
||||
* @default []
|
||||
*/
|
||||
indeterminateItems?: number[];
|
||||
|
||||
/**
|
||||
* Whether to show the empty state message when no items are available
|
||||
* @default false
|
||||
@@ -288,9 +304,11 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
noSelectOnClick: false,
|
||||
overlayLoading: false,
|
||||
perPage: undefined,
|
||||
primaryKey: "",
|
||||
selectable: false,
|
||||
selectCheckboxTitle: "Select for bulk actions",
|
||||
selectedItems: () => [],
|
||||
indeterminateItems: () => [],
|
||||
showEmpty: false,
|
||||
showSelectAll: false,
|
||||
sortBy: "",
|
||||
@@ -322,7 +340,7 @@ const emit = defineEmits<{
|
||||
* Emitted when select all checkbox is toggled
|
||||
* @event select-all
|
||||
*/
|
||||
(e: "select-all"): void;
|
||||
(e: "select-all", selected: boolean): void;
|
||||
|
||||
/**
|
||||
* Emitted when a row is selected/deselected
|
||||
@@ -527,7 +545,7 @@ function onRowClick(item: T, index: number, event: MouseEvent | KeyboardEvent) {
|
||||
}
|
||||
|
||||
function onSelectAll(selected: boolean) {
|
||||
emit("select-all");
|
||||
emit("select-all", selected);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -592,15 +610,17 @@ function getAlignmentClass(align?: FieldAlignment) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cell variant class for Bootstrap color variants (e.g., "success", "danger", "info")
|
||||
* Supports the _cellVariants convention from b-table for backward compatibility
|
||||
* Get row class from item metadata
|
||||
*/
|
||||
function getCellVariantClass(item: T, field: TableField) {
|
||||
const cellVariants = item._cellVariants as Record<string, string> | undefined;
|
||||
if (!cellVariants || !cellVariants[field.key]) {
|
||||
return undefined;
|
||||
}
|
||||
return `table-${cellVariants[field.key]}`;
|
||||
function getRowClass(item: T) {
|
||||
return (item as TableItemClassMeta).class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cell class from item metadata
|
||||
*/
|
||||
function getItemCellClass(item: T, fieldKey: string) {
|
||||
return (item as TableItemClassMeta).cellClass?.[fieldKey];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -610,6 +630,10 @@ function isRowSelected(index: number) {
|
||||
return props.selectedItems.includes(index);
|
||||
}
|
||||
|
||||
function isRowIndeterminate(index: number) {
|
||||
return props.indeterminateItems.includes(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status icon for a row
|
||||
*/
|
||||
@@ -659,7 +683,11 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :id="`g-table-container-${props.id}`" class="g-table-container" :class="containerClass">
|
||||
<div
|
||||
:id="`g-table-container-${props.id}`"
|
||||
class="g-table-container"
|
||||
:class="containerClass"
|
||||
:aria-busy="props.loading ? 'true' : 'false'">
|
||||
<!-- Table wrapper -->
|
||||
<GOverlay :show="overlayLoading" class="position-relative w-100">
|
||||
<div
|
||||
@@ -751,10 +779,15 @@ defineExpose({
|
||||
<tr
|
||||
:id="getRowId(props.id, getGlobalIndex(paginatedIndex))"
|
||||
:key="`tr` + getGlobalIndex(paginatedIndex)"
|
||||
:class="{
|
||||
'g-table-row-clickable': clickableRows || (selectable && !noSelectOnClick),
|
||||
'g-table-row-selected': isRowSelected(getGlobalIndex(paginatedIndex)),
|
||||
}"
|
||||
:aria-rowindex="getGlobalIndex(paginatedIndex) + 1"
|
||||
:data-pk="props.primaryKey ? item[props.primaryKey] : undefined"
|
||||
:class="[
|
||||
{
|
||||
'g-table-row-clickable': clickableRows || (selectable && !noSelectOnClick),
|
||||
'g-table-row-selected': isRowSelected(getGlobalIndex(paginatedIndex)),
|
||||
},
|
||||
getRowClass(item),
|
||||
]"
|
||||
@click="onRowClick(item, getGlobalIndex(paginatedIndex), $event)">
|
||||
<!-- Selection checkbox column -->
|
||||
<td v-if="selectable" class="g-table-select-column">
|
||||
@@ -762,6 +795,7 @@ defineExpose({
|
||||
:id="`${getRowId(props.id, getGlobalIndex(paginatedIndex))}-select`"
|
||||
v-g-tooltip.hover
|
||||
:checked="isRowSelected(getGlobalIndex(paginatedIndex))"
|
||||
:indeterminate="isRowIndeterminate(getGlobalIndex(paginatedIndex))"
|
||||
:title="props.selectCheckboxTitle"
|
||||
@click.stop
|
||||
@change="onRowSelect(item, getGlobalIndex(paginatedIndex))" />
|
||||
@@ -777,7 +811,7 @@ defineExpose({
|
||||
field.cellClass,
|
||||
field.class,
|
||||
getAlignmentClass(field.align),
|
||||
getCellVariantClass(item, field),
|
||||
getItemCellClass(item, field.key),
|
||||
{ 'hide-on-small': field.hideOnSmall },
|
||||
]">
|
||||
<template
|
||||
@@ -1037,6 +1071,15 @@ defineExpose({
|
||||
|
||||
.custom-checkbox {
|
||||
cursor: pointer;
|
||||
|
||||
// The visible checkbox is the (empty) label's ::before box, while
|
||||
// the real input is visually hidden; both default to the arrow
|
||||
// cursor, making the checkbox look unclickable. Force a pointer
|
||||
// across the whole control.
|
||||
:deep(.custom-control-input:not(:disabled)),
|
||||
:deep(.custom-control-input:not(:disabled) ~ .custom-control-label) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { BBadge } from "bootstrap-vue";
|
||||
import { onMounted, type Ref, ref, watch } from "vue";
|
||||
import Vue from "vue";
|
||||
|
||||
import type { TableField } from "@/components/Common/GTable.types";
|
||||
import type { DataOption } from "@/components/Form/Elements/FormData/types";
|
||||
import type { SelectionItem } from "@/components/SelectionDialog/selectionTypes";
|
||||
import { useGlobalUploadModal } from "@/composables/globalUploadModal";
|
||||
@@ -69,18 +70,22 @@ const model = new Model({ multiple: props.multiple, format: props.format });
|
||||
const urlTracker = useUrlTracker<string>({ root: getHistoryUrl() });
|
||||
|
||||
/** Specifies data columns to be shown in the dialog's table */
|
||||
const fields = [
|
||||
const fields: TableField[] = [
|
||||
{
|
||||
key: "label",
|
||||
label: "Name",
|
||||
},
|
||||
{
|
||||
key: "extension",
|
||||
label: "Extension",
|
||||
},
|
||||
{
|
||||
key: "tags",
|
||||
label: "Tags",
|
||||
},
|
||||
{
|
||||
key: "update_time",
|
||||
label: "Update Time",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ vi.mock("@/composables/config", () => ({
|
||||
const { server, http } = useServerMock();
|
||||
|
||||
interface RowElement extends SelectionItem, Element {
|
||||
_rowVariant: SelectionState;
|
||||
selectionState: SelectionState;
|
||||
}
|
||||
|
||||
function paramsToKey(query: {
|
||||
@@ -164,7 +164,7 @@ describe("FilesDialog, file mode", () => {
|
||||
utils.expectNumberOfSelectedItemsToBe(filesInResponse.length);
|
||||
|
||||
await utils.applyToEachFile((item) => {
|
||||
expect(item._rowVariant).toBe(SELECTION_STATES.SELECTED);
|
||||
expect(item.selectionState).toBe(SELECTION_STATES.SELECTED);
|
||||
});
|
||||
|
||||
utils.expectOkButtonEnabled();
|
||||
@@ -185,7 +185,8 @@ describe("FilesDialog, file mode", () => {
|
||||
// go inside directory1
|
||||
await utils.openDirectoryById(targetDirectoryId);
|
||||
|
||||
utils.expectSelectAllIconStatusToBe(SELECTION_STATES.SELECTED);
|
||||
utils.expectSelectAllChecked();
|
||||
utils.expectSelectAllNotIndeterminate();
|
||||
|
||||
//every item should be selected
|
||||
utils.expectAllRenderedItemsSelected();
|
||||
@@ -198,7 +199,7 @@ describe("FilesDialog, file mode", () => {
|
||||
|
||||
// ensure that it has "mixed" status icon
|
||||
const directory = utils.findRenderedDirectory(targetDirectoryId);
|
||||
expect(directory._rowVariant).toBe(SELECTION_STATES.MIXED);
|
||||
expect(directory.selectionState).toBe(SELECTION_STATES.MIXED);
|
||||
});
|
||||
|
||||
it("should be able to unselect a sub-directory keeping the rest selected", async () => {
|
||||
@@ -211,12 +212,13 @@ describe("FilesDialog, file mode", () => {
|
||||
// unselect subfolder
|
||||
await utils.clickOn(utils.findRenderedDirectory(subSubDirectoryId));
|
||||
// directory should be unselected
|
||||
expect(utils.findRenderedDirectory(subSubDirectoryId)._rowVariant).toBe(SELECTION_STATES.UNSELECTED);
|
||||
// selectAllIcon should be unselected
|
||||
utils.expectSelectAllIconStatusToBe(SELECTION_STATES.UNSELECTED);
|
||||
expect(utils.findRenderedDirectory(subSubDirectoryId).selectionState).toBe(SELECTION_STATES.UNSELECTED);
|
||||
// selectAll checkbox should be unchecked
|
||||
utils.expectSelectAllUnchecked();
|
||||
utils.expectSelectAllNotIndeterminate();
|
||||
await utils.navigateBack();
|
||||
await utils.navigateBack();
|
||||
expect(utils.findRenderedDirectory(directoryId)._rowVariant).toBe(SELECTION_STATES.MIXED);
|
||||
expect(utils.findRenderedDirectory(directoryId).selectionState).toBe(SELECTION_STATES.MIXED);
|
||||
});
|
||||
|
||||
it("should select all on 'toggleSelectAll' event", async () => {
|
||||
@@ -230,7 +232,7 @@ describe("FilesDialog, file mode", () => {
|
||||
utils.expectAllRenderedItemsSelected();
|
||||
await utils.navigateBack();
|
||||
const rootNode = utils.findRenderedDirectory(rootId);
|
||||
expect(rootNode._rowVariant).toBe(SELECTION_STATES.SELECTED);
|
||||
expect(rootNode.selectionState).toBe(SELECTION_STATES.SELECTED);
|
||||
});
|
||||
|
||||
it("should show ftp helper only in ftp directory", async () => {
|
||||
@@ -442,12 +444,14 @@ class Utils {
|
||||
|
||||
expectAllRenderedItemsSelected() {
|
||||
this.getRenderedRows().forEach((item) => {
|
||||
expect(item._rowVariant).toBe(SELECTION_STATES.SELECTED);
|
||||
expect(item.selectionState).toBe(SELECTION_STATES.SELECTED);
|
||||
});
|
||||
}
|
||||
|
||||
expectNumberOfSelectedItemsToBe(number: number) {
|
||||
const selectedItems = this.getRenderedRows().filter((item) => item._rowVariant === SELECTION_STATES.SELECTED);
|
||||
const selectedItems = this.getRenderedRows().filter(
|
||||
(item) => item.selectionState === SELECTION_STATES.SELECTED,
|
||||
);
|
||||
expect(selectedItems.length).toBe(number);
|
||||
}
|
||||
|
||||
@@ -459,8 +463,25 @@ class Utils {
|
||||
expect(this.getOkButton().attributes("disabled")).toBeFalsy();
|
||||
}
|
||||
|
||||
expectSelectAllIconStatusToBe(status: string) {
|
||||
expect(this.getSelectionDialog().props("selectAllVariant")).toBe(status);
|
||||
getSelectAllCheckbox(): Wrapper<any> {
|
||||
const checkbox = this.wrapper.find("input[id^='g-table-select-all-']");
|
||||
expect(checkbox.exists()).toBe(true);
|
||||
return checkbox;
|
||||
}
|
||||
|
||||
expectSelectAllChecked() {
|
||||
const checkbox = this.getSelectAllCheckbox();
|
||||
expect((checkbox.element as HTMLInputElement).checked).toBe(true);
|
||||
}
|
||||
|
||||
expectSelectAllUnchecked() {
|
||||
const checkbox = this.getSelectAllCheckbox();
|
||||
expect((checkbox.element as HTMLInputElement).checked).toBe(false);
|
||||
}
|
||||
|
||||
expectSelectAllNotIndeterminate() {
|
||||
const checkbox = this.getSelectAllCheckbox();
|
||||
expect((checkbox.element as HTMLInputElement).indeterminate).toBe(false);
|
||||
}
|
||||
|
||||
expectNoErrorMessage() {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type FilterFileSourcesOptions,
|
||||
type RemoteEntry,
|
||||
} from "@/api/remoteFiles";
|
||||
import type { TableField } from "@/components/Common/GTable.types";
|
||||
import { fileSourcePluginToItem, isSubPath } from "@/components/FilesDialog/utilities";
|
||||
import {
|
||||
type ItemsProvider,
|
||||
@@ -69,7 +70,6 @@ const selectionModel = ref<Model>(new Model({ multiple: props.multiple }));
|
||||
|
||||
const query = ref<string>();
|
||||
const selectionDialog = ref();
|
||||
const allSelected = ref(false);
|
||||
const selectedDirectories = ref<SelectionItem[]>([]);
|
||||
const errorMessage = ref<string>();
|
||||
const filter = ref();
|
||||
@@ -83,18 +83,17 @@ const showTime = ref(true);
|
||||
const showDetails = ref(true);
|
||||
const isBusy = ref(false);
|
||||
const showFTPHelper = ref(false);
|
||||
const selectAllIcon = ref<SelectionState>(SELECTION_STATES.UNSELECTED);
|
||||
const urlTracker = useUrlTracker<SelectionItem & { parentPage?: number }>({ root: undefined });
|
||||
const totalItems = ref(0);
|
||||
|
||||
const fields = computed(() => {
|
||||
const fields = computed<TableField[]>(() => {
|
||||
const fields = [];
|
||||
fields.push({ key: "label" });
|
||||
fields.push({ key: "label", label: "Name" });
|
||||
if (showDetails.value) {
|
||||
fields.push({ key: "details" });
|
||||
fields.push({ key: "details", label: "Details" });
|
||||
}
|
||||
if (showTime.value) {
|
||||
fields.push({ key: "time" });
|
||||
fields.push({ key: "time", label: "Time" });
|
||||
}
|
||||
return fields;
|
||||
});
|
||||
@@ -212,20 +211,21 @@ function formatRows() {
|
||||
|
||||
hasValue.value = selectionModel.value.count() > 0 || selectedDirectories.value.length > 0;
|
||||
for (const item of items.value) {
|
||||
let _rowVariant = "active";
|
||||
let selectionState: SelectionState = SELECTION_STATES.UNSELECTED;
|
||||
if (item.isLeaf || !fileMode.value) {
|
||||
_rowVariant = selectionModel.value.exists(item.id) ? "success" : "default";
|
||||
selectionState = selectionModel.value.exists(item.id)
|
||||
? SELECTION_STATES.SELECTED
|
||||
: SELECTION_STATES.UNSELECTED;
|
||||
}
|
||||
// if directory
|
||||
else if (!item.isLeaf) {
|
||||
_rowVariant = getIcon(isDirectorySelected(item.id), item.url);
|
||||
selectionState = getIcon(isDirectorySelected(item.id), item.url);
|
||||
}
|
||||
Vue.set(item, "_rowVariant", _rowVariant);
|
||||
}
|
||||
allSelected.value = checkIfAllSelected();
|
||||
if (urlTracker.current.value?.url) {
|
||||
selectAllIcon.value = getIcon(allSelected.value, urlTracker.current.value.url);
|
||||
Vue.set(item, "selectionState", selectionState);
|
||||
}
|
||||
// Called for its side effect: auto-selects the current folder when all
|
||||
// of its children are selected.
|
||||
checkIfAllSelected();
|
||||
}
|
||||
|
||||
function isDirectorySelected(directoryId: string): boolean {
|
||||
@@ -480,8 +480,7 @@ onMounted(() => {
|
||||
:modal-show="modalShow"
|
||||
:multiple="multiple"
|
||||
:options-show="optionsShow"
|
||||
:select-all-variant="selectAllIcon"
|
||||
:show-select-icon="undoShow && multiple"
|
||||
:selectable="undoShow && multiple"
|
||||
:undo-show="undoShow"
|
||||
:watch-on-page-changes="false"
|
||||
@onCancel="() => (modalShow = false)"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, type Ref, ref } from "vue";
|
||||
|
||||
import type { TableField } from "@/components/Common/GTable.types";
|
||||
import type { SelectionItem } from "@/components/SelectionDialog/selectionTypes";
|
||||
import { errorMessageAsString } from "@/utils/simple-error";
|
||||
|
||||
@@ -40,13 +41,13 @@ const modalShow = ref(true);
|
||||
const optionsShow = ref(false);
|
||||
const showTime = ref(false);
|
||||
|
||||
const fields = computed(() => {
|
||||
const fields = [{ key: "label" }];
|
||||
const fields = computed<TableField[]>(() => {
|
||||
const fields = [{ key: "label", label: "Name" }];
|
||||
if (props.detailsKey) {
|
||||
fields.push({ key: "details" });
|
||||
fields.push({ key: "details", label: "Details" });
|
||||
}
|
||||
if (showTime.value) {
|
||||
fields.push({ key: "time" });
|
||||
fields.push({ key: "time", label: "Time" });
|
||||
}
|
||||
return fields;
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createLocalVue, mount } from "@vue/test-utils";
|
||||
import { BAlert, BTable } from "bootstrap-vue";
|
||||
import { BAlert } from "bootstrap-vue";
|
||||
import flushPromises from "flush-promises";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -7,6 +7,7 @@ import { HttpResponse, useServerMock } from "@/api/client/__mocks__";
|
||||
|
||||
import DatasetCollectionDialog from "./DatasetCollectionDialog.vue";
|
||||
import SelectionDialog from "./SelectionDialog.vue";
|
||||
import GTable from "@/components/Common/GTable.vue";
|
||||
|
||||
vi.mock("app");
|
||||
|
||||
@@ -44,12 +45,12 @@ describe("DatasetCollectionDialog.vue", () => {
|
||||
});
|
||||
|
||||
expect(wrapper.findComponent(SelectionDialog).exists()).toBe(true);
|
||||
expect(wrapper.findComponent(BTable).exists()).toBe(false);
|
||||
expect(wrapper.findComponent(GTable).exists()).toBe(false);
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.findComponent(BAlert).exists()).toBe(false);
|
||||
expect(wrapper.findComponent(BTable).exists()).toBe(true);
|
||||
expect(wrapper.findComponent(GTable).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("error message set on dataset collection fetch problems", async () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { faHdd } from "@fortawesome/free-solid-svg-icons";
|
||||
import { computed, ref, set } from "vue";
|
||||
|
||||
import { GalaxyApi, type HDASummary, type HistorySortByLiteral, type HistorySummary } from "@/api";
|
||||
import type { TableField } from "@/components/Common/GTable.types";
|
||||
import { HistoriesFilters } from "@/components/History/HistoriesFilters";
|
||||
import {
|
||||
type ItemsProvider,
|
||||
@@ -47,7 +48,6 @@ const hasValue = ref(false);
|
||||
const modalShow = ref(true);
|
||||
const errorMessage = ref("");
|
||||
const submitting = ref(false);
|
||||
const allSelected = ref(false);
|
||||
const datasetsVisible = ref(false);
|
||||
|
||||
const items = ref<SelectionItem[]>([]);
|
||||
@@ -69,7 +69,8 @@ const okButtonText = computed(() => {
|
||||
return `${props.actionButtonText} ${selected.value.length} dataset${selected.value.length > 1 ? "s" : ""}`;
|
||||
}
|
||||
});
|
||||
const fields = computed(() => {
|
||||
|
||||
const fields = computed<TableField[]>(() => {
|
||||
if (datasetsVisible.value) {
|
||||
return [
|
||||
{ key: "label", label: "Name", sortable: true },
|
||||
@@ -83,15 +84,6 @@ const fields = computed(() => {
|
||||
];
|
||||
}
|
||||
});
|
||||
const selectAllIcon = computed(() => {
|
||||
if (allSelected.value) {
|
||||
return SELECTION_STATES.SELECTED;
|
||||
} else if (selected.value.length > 0) {
|
||||
return SELECTION_STATES.MIXED;
|
||||
} else {
|
||||
return SELECTION_STATES.UNSELECTED;
|
||||
}
|
||||
});
|
||||
|
||||
function historyEntryToRecord(entry: HistorySummary): HistoryRecord {
|
||||
const result: HistoryRecord = {
|
||||
@@ -127,25 +119,14 @@ function formatRows() {
|
||||
|
||||
for (const item of items.value) {
|
||||
if (item.isLeaf) {
|
||||
const _rowVariant =
|
||||
const selectionState =
|
||||
selected.value.findIndex((i) => i.id === item.id) !== -1
|
||||
? SELECTION_STATES.SELECTED
|
||||
: SELECTION_STATES.UNSELECTED;
|
||||
|
||||
set(item, "_rowVariant", _rowVariant);
|
||||
set(item, "selectionState", selectionState);
|
||||
}
|
||||
}
|
||||
|
||||
allSelected.value = checkIfAllSelected();
|
||||
}
|
||||
|
||||
function checkIfAllSelected(): boolean {
|
||||
return Boolean(
|
||||
items.value.length &&
|
||||
items.value.every((item) => {
|
||||
return selected.value.findIndex((i) => i.id === item.id) !== -1;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function historiesProvider(ctx: ItemsProviderContext, url?: string): Promise<SelectionItem[]> {
|
||||
@@ -267,8 +248,8 @@ async function onDatasetClick(item: SelectionItem) {
|
||||
}
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
if (allSelected.value) {
|
||||
function selectAll(checked: boolean) {
|
||||
if (!checked) {
|
||||
selected.value = [];
|
||||
} else {
|
||||
for (const item of items.value) {
|
||||
@@ -311,12 +292,11 @@ function onCancel() {
|
||||
:modal-show="modalShow"
|
||||
:file-mode="false"
|
||||
:multiple="true"
|
||||
:select-all-variant="selectAllIcon"
|
||||
:selectable="datasetsVisible"
|
||||
:items="items"
|
||||
:undo-show="datasetsVisible"
|
||||
:total-items="totalItems"
|
||||
:items-provider="itemsProvider"
|
||||
:show-select-icon="datasetsVisible"
|
||||
:folder-icon="faHdd"
|
||||
:is-busy="loading"
|
||||
:search-title="searchTitle"
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { createLocalVue, mount } from "@vue/test-utils";
|
||||
import { BTable } from "bootstrap-vue";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { SELECTION_STATES } from "./selectionTypes";
|
||||
|
||||
import DataDialogSearch from "./DataDialogSearch.vue";
|
||||
import SelectionDialog from "./SelectionDialog.vue";
|
||||
import GTable from "@/components/Common/GTable.vue";
|
||||
|
||||
const mockOptions = {
|
||||
callback: () => {},
|
||||
@@ -24,10 +26,10 @@ describe("SelectionDialog.vue", () => {
|
||||
|
||||
it("loads correctly in loading state, shows options when optionsShow becomes true", async () => {
|
||||
expect(wrapper.find("[data-description='selection dialog spinner']").exists()).toBeTruthy();
|
||||
expect(wrapper.findComponent(BTable).exists()).toBeFalsy();
|
||||
expect(wrapper.findComponent(GTable).exists()).toBeFalsy();
|
||||
await wrapper.setProps({ optionsShow: true });
|
||||
expect(wrapper.find("[data-description='selection dialog spinner']").exists()).toBeFalsy();
|
||||
expect(wrapper.findComponent(BTable).exists()).toBeTruthy();
|
||||
expect(wrapper.findComponent(GTable).exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
it("loads header correctly", async () => {
|
||||
@@ -40,4 +42,81 @@ describe("SelectionDialog.vue", () => {
|
||||
wrapper.find("[data-description='selection dialog cancel']").trigger("click");
|
||||
expect(wrapper.emitted().onCancel).toBeTruthy();
|
||||
});
|
||||
|
||||
it("syncs row selection state from incoming items", async () => {
|
||||
await wrapper.setProps({
|
||||
optionsShow: true,
|
||||
selectable: true,
|
||||
items: [
|
||||
{ id: "1", label: "file1", isLeaf: true, selectionState: SELECTION_STATES.SELECTED },
|
||||
{ id: "2", label: "file2", isLeaf: true, selectionState: SELECTION_STATES.UNSELECTED },
|
||||
],
|
||||
});
|
||||
|
||||
const selectAllCheckbox = wrapper.find("input[id^='g-table-select-all-']").element;
|
||||
expect(selectAllCheckbox.checked).toBe(false);
|
||||
expect(selectAllCheckbox.indeterminate).toBe(true);
|
||||
});
|
||||
|
||||
it("shows select-all as checked when all incoming items are selected", async () => {
|
||||
await wrapper.setProps({
|
||||
optionsShow: true,
|
||||
selectable: true,
|
||||
items: [
|
||||
{ id: "1", label: "file1", isLeaf: true, selectionState: SELECTION_STATES.SELECTED },
|
||||
{ id: "2", label: "file2", isLeaf: true, selectionState: SELECTION_STATES.SELECTED },
|
||||
],
|
||||
});
|
||||
|
||||
const selectAllCheckbox = wrapper.find("input[id^='g-table-select-all-']").element;
|
||||
expect(selectAllCheckbox.checked).toBe(true);
|
||||
expect(selectAllCheckbox.indeterminate).toBe(false);
|
||||
});
|
||||
|
||||
it("renders a MIXED row as an indeterminate checkbox", async () => {
|
||||
await wrapper.setProps({
|
||||
optionsShow: true,
|
||||
selectable: true,
|
||||
items: [
|
||||
{ id: "1", label: "folder1", isLeaf: false, selectionState: SELECTION_STATES.MIXED },
|
||||
{ id: "2", label: "file2", isLeaf: true, selectionState: SELECTION_STATES.UNSELECTED },
|
||||
],
|
||||
});
|
||||
|
||||
const rowCheckbox = wrapper.find("tbody tr[aria-rowindex='1'] .g-table-select-column input").element;
|
||||
expect(rowCheckbox.checked).toBe(false);
|
||||
expect(rowCheckbox.indeterminate).toBe(true);
|
||||
});
|
||||
|
||||
it("emits onClick for the row when its checkbox is toggled", async () => {
|
||||
await wrapper.setProps({
|
||||
optionsShow: true,
|
||||
selectable: true,
|
||||
items: [
|
||||
{ id: "1", label: "folder1", isLeaf: false, selectionState: SELECTION_STATES.MIXED },
|
||||
{ id: "2", label: "file2", isLeaf: true, selectionState: SELECTION_STATES.UNSELECTED },
|
||||
],
|
||||
});
|
||||
|
||||
const rowCheckbox = wrapper.find("tbody tr[aria-rowindex='1'] .g-table-select-column input");
|
||||
await rowCheckbox.trigger("change");
|
||||
|
||||
expect(wrapper.emitted().onClick).toBeTruthy();
|
||||
expect(wrapper.emitted().onClick[0][0].id).toBe("1");
|
||||
});
|
||||
|
||||
it("emits onClick exactly once when a selectable row is clicked", async () => {
|
||||
await wrapper.setProps({
|
||||
optionsShow: true,
|
||||
selectable: true,
|
||||
items: [{ id: "1", label: "file1", isLeaf: true, selectionState: SELECTION_STATES.UNSELECTED }],
|
||||
});
|
||||
|
||||
// GTable emits both "row-select" and "row-click" for a selectable row;
|
||||
// SelectionDialog must not toggle selection twice.
|
||||
await wrapper.find("tbody tr[aria-rowindex='1']").trigger("click");
|
||||
|
||||
expect(wrapper.emitted().onClick).toBeTruthy();
|
||||
expect(wrapper.emitted().onClick.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import { faCheckSquare, faMinusSquare, faSquare } from "@fortawesome/free-regular-svg-icons";
|
||||
import { faCaretLeft, faCheck, faFolder, faSpinner, faTimes } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { BAlert, BButton, BLink, BPagination, BTable } from "bootstrap-vue";
|
||||
import { BAlert, BButton, BLink, BPagination } from "bootstrap-vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
import { type ItemsProvider, SELECTION_STATES, type SelectionState } from "@/components/SelectionDialog/selectionTypes";
|
||||
import type { RowClickEvent, RowSelectEvent, TableField } from "@/components/Common/GTable.types";
|
||||
import { type ItemsProvider, SELECTION_STATES } from "@/components/SelectionDialog/selectionTypes";
|
||||
import type Filtering from "@/utils/filtering";
|
||||
|
||||
import type { FieldEntry, SelectionItem } from "./selectionTypes";
|
||||
import type { SelectionItem } from "./selectionTypes";
|
||||
|
||||
import GModal from "../BaseComponents/GModal.vue";
|
||||
import Heading from "../Common/Heading.vue";
|
||||
import FilterMenu from "@/components/Common/FilterMenu.vue";
|
||||
import GTable from "@/components/Common/GTable.vue";
|
||||
import LoadingSpan from "@/components/LoadingSpan.vue";
|
||||
import DataDialogSearch from "@/components/SelectionDialog/DataDialogSearch.vue";
|
||||
import StatelessTags from "@/components/TagsMultiselect/StatelessTags.vue";
|
||||
|
||||
const LABEL_FIELD: FieldEntry = { key: "label", sortable: true };
|
||||
const SELECT_ICON_FIELD: FieldEntry = { key: "__select_icon__", label: "", sortable: false };
|
||||
const LABEL_FIELD: TableField = { key: "label", label: "Name", sortable: true };
|
||||
|
||||
interface Props {
|
||||
disableOk?: boolean;
|
||||
errorMessage?: string;
|
||||
fileMode?: boolean;
|
||||
fields?: FieldEntry[];
|
||||
fields?: TableField[];
|
||||
isBusy?: boolean;
|
||||
isEncoded?: boolean;
|
||||
items?: SelectionItem[];
|
||||
@@ -38,8 +38,7 @@ interface Props {
|
||||
multiple?: boolean;
|
||||
optionsShow?: boolean;
|
||||
undoShow?: boolean;
|
||||
selectAllVariant?: SelectionState;
|
||||
showSelectIcon?: boolean;
|
||||
selectable?: boolean;
|
||||
title?: string;
|
||||
searchTitle?: string;
|
||||
okButtonText?: string;
|
||||
@@ -64,8 +63,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
multiple: false,
|
||||
optionsShow: false,
|
||||
undoShow: false,
|
||||
selectAllVariant: SELECTION_STATES.UNSELECTED,
|
||||
showSelectIcon: false,
|
||||
selectable: false,
|
||||
title: "",
|
||||
searchTitle: undefined,
|
||||
okButtonText: "Select",
|
||||
@@ -78,7 +76,7 @@ const emit = defineEmits<{
|
||||
(e: "onClick", record: SelectionItem): void;
|
||||
(e: "onOk"): void;
|
||||
(e: "onOpen", record: SelectionItem): void;
|
||||
(e: "onSelectAll"): void;
|
||||
(e: "onSelectAll", selected: boolean): void;
|
||||
(e: "onUndo"): void;
|
||||
}>();
|
||||
|
||||
@@ -86,43 +84,104 @@ const filter = ref("");
|
||||
const currentPage = ref(1);
|
||||
const perPage = ref(25);
|
||||
const showAdvancedSearch = ref(false);
|
||||
const selectedItems = ref<number[]>([]);
|
||||
const indeterminateItems = ref<number[]>([]);
|
||||
|
||||
const providerRequestId = ref(0);
|
||||
const providerItems = ref<SelectionItem[]>([]);
|
||||
const sortBy = ref<string | undefined>(undefined);
|
||||
const sortDesc = ref<boolean | undefined>(undefined);
|
||||
|
||||
const usingProvider = computed(() => Boolean(props.itemsProvider));
|
||||
|
||||
const okButtonText = computed(() => {
|
||||
return props.okButtonText ? props.okButtonText : props.fileMode ? "Select" : "Select this folder";
|
||||
});
|
||||
|
||||
const fieldDetails = computed(() => {
|
||||
const fields = props.fields.slice().map((x) => {
|
||||
x.sortable = x.sortable === undefined ? true : x.sortable;
|
||||
return x;
|
||||
});
|
||||
const fieldDetails = computed<TableField[]>(() => {
|
||||
const fields: TableField[] = props.fields.slice().map((field) => ({
|
||||
...field,
|
||||
sortable: field.sortable ?? true,
|
||||
}));
|
||||
if (fields.length === 0) {
|
||||
fields.unshift(LABEL_FIELD);
|
||||
}
|
||||
if (props.showSelectIcon) {
|
||||
fields.unshift(SELECT_ICON_FIELD);
|
||||
}
|
||||
return fields;
|
||||
});
|
||||
|
||||
function selectionIcon(variant: string) {
|
||||
switch (variant) {
|
||||
case SELECTION_STATES.SELECTED:
|
||||
return faCheckSquare;
|
||||
case SELECTION_STATES.MIXED:
|
||||
return faMinusSquare;
|
||||
default:
|
||||
return faSquare;
|
||||
/**
|
||||
* Derive the GTable checkbox state from each item's selectionState: fully
|
||||
* selected rows are checked, MIXED rows (e.g. partially-selected folders)
|
||||
* render as indeterminate. Runs whenever the items change.
|
||||
*/
|
||||
function syncSelectedItems() {
|
||||
const selected: number[] = [];
|
||||
const indeterminate: number[] = [];
|
||||
|
||||
tableItems.value.forEach((item, index) => {
|
||||
if (item.selectionState === SELECTION_STATES.SELECTED) {
|
||||
selected.push(index);
|
||||
} else if (item.selectionState === SELECTION_STATES.MIXED) {
|
||||
indeterminate.push(index);
|
||||
}
|
||||
});
|
||||
|
||||
selectedItems.value = selected;
|
||||
indeterminateItems.value = indeterminate;
|
||||
}
|
||||
|
||||
const tableItems = computed(() => {
|
||||
return usingProvider.value ? providerItems.value : props.items;
|
||||
});
|
||||
|
||||
async function loadProviderItems() {
|
||||
if (!props.itemsProvider || !props.optionsShow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = ++providerRequestId.value;
|
||||
const result = await props.itemsProvider({
|
||||
apiUrl: props.providerUrl,
|
||||
currentPage: currentPage.value,
|
||||
perPage: perPage.value,
|
||||
filter: filter.value || undefined,
|
||||
sortBy: sortBy.value,
|
||||
sortDesc: sortDesc.value,
|
||||
});
|
||||
|
||||
if (requestId === providerRequestId.value) {
|
||||
providerItems.value = result ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Resets pagination when a filter/search word is entered **/
|
||||
function filtered(items: SelectionItem[]) {
|
||||
if (props.itemsProvider === undefined) {
|
||||
resetPagination();
|
||||
function onSortChanged(newSortBy: string, newSortDesc: boolean) {
|
||||
sortBy.value = newSortBy || undefined;
|
||||
sortDesc.value = newSortDesc;
|
||||
}
|
||||
|
||||
function onRowClick(event: RowClickEvent<SelectionItem>) {
|
||||
// For a selectable table GTable also emits "row-select" on a row click
|
||||
// (handled by onRowSelect), so emitting here too would toggle selection
|
||||
// twice. Only emit for non-selectable dialogs.
|
||||
if (!props.selectable) {
|
||||
emit("onClick", event.item);
|
||||
}
|
||||
}
|
||||
|
||||
// Selection for a selectable table: a row click and a checkbox toggle both
|
||||
// arrive here as a single "row-select", so the checkbox behaves like the row.
|
||||
function onRowSelect(event: RowSelectEvent<SelectionItem>) {
|
||||
emit("onClick", event.item);
|
||||
}
|
||||
|
||||
function onOpen(item: SelectionItem) {
|
||||
emit("onOpen", item);
|
||||
}
|
||||
|
||||
function onSelectAll(selected: boolean) {
|
||||
emit("onSelectAll", selected);
|
||||
}
|
||||
|
||||
/** Format time stamp */
|
||||
function formatTime(value: string) {
|
||||
if (value) {
|
||||
@@ -159,6 +218,30 @@ if (props.watchOnPageChanges) {
|
||||
}
|
||||
|
||||
const dialog = ref<InstanceType<typeof GModal> | null>(null);
|
||||
|
||||
watch(
|
||||
[
|
||||
() => props.itemsProvider,
|
||||
currentPage,
|
||||
perPage,
|
||||
filter,
|
||||
sortBy,
|
||||
sortDesc,
|
||||
() => props.providerUrl,
|
||||
() => props.optionsShow,
|
||||
],
|
||||
() => {
|
||||
if (props.itemsProvider && props.optionsShow) {
|
||||
void loadProviderItems();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(filter, () => {
|
||||
resetPagination();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => dialog.value,
|
||||
(newValue) => {
|
||||
@@ -169,6 +252,14 @@ watch(
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
tableItems,
|
||||
() => {
|
||||
syncSelectedItems();
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
|
||||
defineExpose({
|
||||
resetFilter,
|
||||
resetPagination,
|
||||
@@ -208,29 +299,28 @@ defineExpose({
|
||||
</BAlert>
|
||||
<div v-else>
|
||||
<div v-if="optionsShow" data-description="selection dialog options">
|
||||
<BTable
|
||||
small
|
||||
hover
|
||||
<GTable
|
||||
class="selection-dialog-table"
|
||||
clickable-rows
|
||||
compact
|
||||
hover
|
||||
primary-key="id"
|
||||
:busy="isBusy"
|
||||
:current-page="currentPage"
|
||||
:items="itemsProvider ?? items"
|
||||
:fields="fieldDetails"
|
||||
:filter="filter"
|
||||
:items="tableItems"
|
||||
:loading="isBusy"
|
||||
:local-filtering="!usingProvider"
|
||||
:local-sorting="!usingProvider"
|
||||
:indeterminate-items="indeterminateItems"
|
||||
:per-page="perPage"
|
||||
@filtered="filtered"
|
||||
@row-clicked="emit('onClick', $event)">
|
||||
<template v-slot:head(__select_icon__)="">
|
||||
<FontAwesomeIcon
|
||||
class="select-checkbox cursor-pointer"
|
||||
title="Check to select all datasets"
|
||||
:icon="selectionIcon(selectAllVariant)"
|
||||
@click="$emit('onSelectAll')" />
|
||||
</template>
|
||||
<template v-slot:cell(__select_icon__)="data">
|
||||
<FontAwesomeIcon :icon="selectionIcon(data.item._rowVariant)" />
|
||||
</template>
|
||||
:selectable="props.selectable"
|
||||
:selected-items="selectedItems"
|
||||
:show-select-all="props.selectable"
|
||||
@row-click="onRowClick"
|
||||
@row-select="onRowSelect"
|
||||
@select-all="onSelectAll"
|
||||
@sort-changed="onSortChanged">
|
||||
<template v-slot:cell(label)="data">
|
||||
<div style="cursor: pointer">
|
||||
<pre
|
||||
@@ -241,34 +331,44 @@ defineExpose({
|
||||
<i :class="leafIcon" />
|
||||
<span :title="`label-${data.item.url}`">{{ data.value ? data.value : "-" }}</span>
|
||||
</div>
|
||||
<div v-else @click.stop="emit('onOpen', data.item)">
|
||||
<div
|
||||
v-else
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click.stop="onOpen(data.item)"
|
||||
@keydown.enter.stop="onOpen(data.item)"
|
||||
@keydown.space.stop.prevent="onOpen(data.item)">
|
||||
<FontAwesomeIcon :icon="props.folderIcon" />
|
||||
<BLink :title="`label-${data.item.url}`">{{ data.value ? data.value : "-" }}</BLink>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:cell(details)="data">
|
||||
<span :title="`details-${data.item.url}`">{{ data.value ? data.value : "-" }}</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:cell(tags)="data">
|
||||
<StatelessTags v-if="data.value?.length > 0" :value="data.value" :disabled="true" />
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:cell(time)="data">
|
||||
{{ formatTime(data.value) }}
|
||||
</template>
|
||||
|
||||
<template v-slot:cell(update_time)="data">
|
||||
{{ formatTime(data.value) }}
|
||||
</template>
|
||||
</BTable>
|
||||
<div v-if="isBusy" class="text-center">
|
||||
</GTable>
|
||||
|
||||
<div v-if="isBusy" class="text-center" data-description="selection dialog busy spinners">
|
||||
<LoadingSpan />
|
||||
</div>
|
||||
<div v-else-if="totalItems === 0">
|
||||
<div v-if="filter">
|
||||
No search results found for: <b>{{ filter }}</b
|
||||
>.
|
||||
No search results found for: <b> {{ filter }} </b>.
|
||||
</div>
|
||||
<div v-else>No entries.</div>
|
||||
</div>
|
||||
|
||||
@@ -6,12 +6,6 @@ export const SELECTION_STATES = {
|
||||
|
||||
export type SelectionState = (typeof SELECTION_STATES)[keyof typeof SELECTION_STATES];
|
||||
|
||||
export interface FieldEntry {
|
||||
key: string;
|
||||
label?: string;
|
||||
sortable?: boolean;
|
||||
}
|
||||
|
||||
export interface SelectionItem {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -19,7 +13,7 @@ export interface SelectionItem {
|
||||
isLeaf: boolean;
|
||||
url: string;
|
||||
entry: Record<string, unknown>;
|
||||
_rowVariant?: SelectionState;
|
||||
selectionState?: SelectionState;
|
||||
}
|
||||
|
||||
export interface ItemsProviderContext {
|
||||
|
||||
@@ -618,8 +618,8 @@ pages:
|
||||
dataset_selector:
|
||||
type: xpath
|
||||
selector: '//span[text() = "1: 1.fasta"]'
|
||||
workflow_selection: .selection-dialog-modal [role="row"][data-pk="${id}"]
|
||||
history_selection: .selection-dialog-modal [role="row"][data-pk="${id}"]
|
||||
workflow_selection: .selection-dialog-modal tr[data-pk="${id}"]
|
||||
history_selection: .selection-dialog-modal tr[data-pk="${id}"]
|
||||
embed_dialog_add_button: '.pages-embed .buttons #button-0'
|
||||
markdown_editor: '.markdown-textarea'
|
||||
|
||||
@@ -944,7 +944,7 @@ workflow_run:
|
||||
type: xpath
|
||||
selector: '//div[contains(@class, "ag-popup-child")]//div[contains(@class, "ag-list-item")]//span[contains(text(), "${item}")]'
|
||||
select_collection: '[data-description="selection collection card"]'
|
||||
collection_selection: .selection-dialog-modal [role="row"][data-pk="${id}"]
|
||||
collection_selection: .selection-dialog-modal tr[data-pk="${id}"]
|
||||
|
||||
form_element:
|
||||
selectors:
|
||||
@@ -1388,9 +1388,9 @@ libraries:
|
||||
import_datasets_from_history_modal: '.selection-dialog-modal'
|
||||
import_datasets_from_history_modal_history_search: '.selection-dialog-modal .search-query'
|
||||
import_datasets_from_history_modal_dataset_search: '.selection-dialog-modal input[placeholder="search datasets"]'
|
||||
import_datasets_from_history_modal_list_is_ready: '.selection-dialog-modal .selection-dialog-table[aria-busy="false"]'
|
||||
import_datasets_from_history_modal_list_is_ready: '.selection-dialog-modal [data-description="selection dialog busy spinners"]'
|
||||
import_datasets_from_history_modal_select_list_items: '.selection-dialog-modal table tbody tr'
|
||||
import_datasets_from_history_modal_select_list_item_by_index: '.selection-dialog-modal table tbody tr[aria-rowindex="${row_index}"] td[aria-colindex="1"]'
|
||||
import_datasets_from_history_modal_select_list_item_by_index: '.selection-dialog-modal table tbody tr[aria-rowindex="${row_index}"] td[id^="g-table-cell-label-"]'
|
||||
import_datasets_from_history_modal_ok: '.selection-dialog-modal [data-description="selection dialog ok"]'
|
||||
import_datasets_from_history_modal_cancel: '.selection-dialog-modal [data-description="selection dialog cancel"]'
|
||||
add_to_history_as_collection:
|
||||
|
||||
@@ -1842,7 +1842,7 @@ class NavigatesGalaxy(HasDriverProxy[WaitType]):
|
||||
)
|
||||
|
||||
def libraries_dataset_import_from_history_select(self, to_select_items):
|
||||
self.wait_for_visible(
|
||||
self.wait_for_absent(
|
||||
self.navigation.libraries.folder.selectors.import_datasets_from_history_modal_list_is_ready
|
||||
)
|
||||
for to_select_item in to_select_items:
|
||||
|
||||
Reference in New Issue
Block a user