Merge pull request #22231 from itisAliRH/btable-to-gtable-selection-dialogs

Migrate Selection Dialog Components from BTable to GTable
This commit is contained in:
David López
2026-07-23 12:16:40 +02:00
committed by GitHub
13 changed files with 387 additions and 153 deletions
+13 -2
View File
@@ -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) */
+59 -16
View File
@@ -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 {
+5 -5
View File
@@ -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:
+1 -1
View File
@@ -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: