Merge branch 'release_24.0' into dev

This commit is contained in:
mvdbeek
2024-03-19 11:43:15 +01:00
40 changed files with 576 additions and 459 deletions
+1 -1
View File
@@ -6938,7 +6938,7 @@ export interface components {
* Type
* @constant
*/
type: "parameter";
type: "parameter_input";
/** When */
when: string | null;
};
+1 -21
View File
@@ -4,7 +4,7 @@ import { faAngleDoubleUp, faQuestion, faRedo, faSearch } from "@fortawesome/free
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BButton, BModal } from "bootstrap-vue";
import { kebabCase } from "lodash";
import { computed, ref, watch } from "vue";
import { computed, ref } from "vue";
import type Filtering from "@/utils/filtering";
import { type Alias, getOperatorForAlias } from "@/utils/filtering";
@@ -68,7 +68,6 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<{
(e: "update:filter-text", filter: string): void;
(e: "on-backend-filter", filter: string): void;
(e: "update:show-advanced", showAdvanced: boolean): void;
(e: "on-search", filters: Record<string, string | boolean>, filterText?: string, backendFilter?: string): void;
}>();
@@ -127,25 +126,6 @@ function onToggle() {
function updateFilterText(newFilterText: string) {
emit("update:filter-text", newFilterText);
}
// as the filterText changes, emit a backend-filter that can be used as a backend query
watch(
() => props.filterText,
(newFilterText: string) => {
const defaultBackendFilter = props.filterClass.getFilterText(props.filterClass.defaultFilters, true);
const currentBackendFilter = props.filterClass.getFilterText(filters.value, true);
const backendFilter =
defaultBackendFilter === currentBackendFilter
? `${
defaultBackendFilter && !newFilterText.includes(defaultBackendFilter)
? defaultBackendFilter + " "
: ""
}` + newFilterText
: props.filterClass.getFilterText(filters.value, true);
emit("on-backend-filter", backendFilter);
}
);
</script>
<template>
+161 -109
View File
@@ -3,7 +3,7 @@ import { library } from "@fortawesome/fontawesome-svg-core";
import { faCaretDown, faCaretUp, faShieldAlt } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { useDebounceFn, useEventBus } from "@vueuse/core";
import { BAlert, BButton, BFormCheckbox, BPagination } from "bootstrap-vue";
import { BAlert, BButton, BFormCheckbox, BOverlay, BPagination } from "bootstrap-vue";
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { useRouter } from "vue-router/composables";
@@ -64,11 +64,12 @@ const selectedIndeterminate = computed(() => ![0, gridData.value.length].include
const currentPage = ref(1);
const totalRows = ref(0);
// loading indicator
const loading = ref(true);
// loading indicators
const initDataLoading = ref(true);
const resultsLoading = ref(false);
// check if loading has completed and data rows are available
const isAvailable = computed(() => !loading.value && totalRows.value > 0);
// check if `initDataLoading` has completed and data rows are available
const isAvailable = computed(() => !initDataLoading.value && totalRows.value > 0);
// sort references
const sortBy = ref(props.gridConfig ? props.gridConfig.sortBy : "");
@@ -76,8 +77,13 @@ const sortDesc = ref(props.gridConfig ? props.gridConfig.sortDesc : false);
// filtering refs and handlers
const filterText = ref("");
const searchTerm = ref("");
const showAdvanced = ref(false);
const filterClass = props.gridConfig.filtering;
const rawFilters = computed(() => Object.fromEntries(filterClass.getFiltersForText(filterText.value, true, false)));
const validFilters = computed(() => filterClass.getValidFilters(rawFilters.value, true).validFilters);
const invalidFilters = computed(() => filterClass.getValidFilters(rawFilters.value, true).invalidFilters);
const isSurroundedByQuotes = computed(() => /^["'].*["']$/.test(filterText.value));
const hasInvalidFilters = computed(() => !isSurroundedByQuotes.value && Object.keys(invalidFilters.value).length > 0);
// hide message helper
const hideMessage = useDebounceFn(() => {
@@ -109,24 +115,32 @@ function displayInitialMessage() {
* Request grid data
*/
async function getGridData() {
resultsLoading.value = true;
selected.value = new Set();
if (props.gridConfig) {
if (hasInvalidFilters.value) {
// there are invalid filters, so we don't want to search
initDataLoading.value = false;
resultsLoading.value = false;
return;
}
try {
const offset = props.limit * (currentPage.value - 1);
const [responseData, responseTotal] = await props.gridConfig.getData(
offset,
props.limit,
searchTerm.value,
validatedFilterText(),
sortBy.value,
sortDesc.value
);
gridData.value = responseData;
totalRows.value = responseTotal;
errorMessage.value = "";
loading.value = false;
} catch (e) {
errorMessage.value = `Failed to obtain grid data: ${e}`;
loading.value = false;
} finally {
initDataLoading.value = false;
resultsLoading.value = false;
}
}
}
@@ -159,13 +173,6 @@ function onRouterPush(route: string) {
router.push(route);
}
/**
* Apply backend formatted filter and execute grid search
*/
function onSearch(query: string) {
searchTerm.value = query;
}
/**
* User changes sorting
*/
@@ -211,14 +218,27 @@ function onSelectAll(current: boolean): void {
}
}
/**
* A valid filter/query for the backend
*/
function validatedFilterText() {
if (isSurroundedByQuotes.value) {
// the filterText is surrounded by quotes, remove them
return filterText.value.slice(1, -1);
} else if (Object.keys(rawFilters.value).length === 0) {
// there are no filters derived from the `filterText`
return filterText.value;
}
// there are valid filters derived from the `filterText`
return filterClass.getFilterText(validFilters.value, false);
}
/**
* Initialize grid data
*/
onMounted(() => {
if (props.usernameSearch) {
const search_query = `user:${props.usernameSearch}`.trim();
filterText.value = search_query;
onSearch(search_query);
filterText.value = filterClass.setFilterValue(filterText.value, "user", `'${props.usernameSearch}'`);
}
getGridData();
eventBus.on(onRouterPush);
@@ -232,7 +252,7 @@ onUnmounted(() => {
/**
* Load current page
*/
watch([currentPage, searchTerm, sortDesc, sortBy], () => getGridData());
watch([currentPage, filterText, sortDesc, sortBy], () => getGridData());
/**
* Operation message timeout handler
@@ -269,98 +289,130 @@ watch(operationMessage, () => {
:class="{ 'py-2': !embedded }"
:name="gridConfig.plural"
:placeholder="`search ${gridConfig.plural.toLowerCase()}`"
:filter-class="gridConfig.filtering"
:filter-class="filterClass"
:filter-text.sync="filterText"
:loading="loading"
:show-advanced.sync="showAdvanced"
@on-backend-filter="onSearch" />
:loading="initDataLoading || resultsLoading"
:show-advanced.sync="showAdvanced" />
<hr v-if="showAdvanced" />
</div>
<LoadingSpan v-if="loading" />
<BAlert v-else-if="!isAvailable" variant="info" show>
<span v-if="searchTerm">
<span v-localize>Nothing found with:</span>
<b>{{ searchTerm }}</b>
</span>
<span v-else v-localize> No entries found. </span>
</BAlert>
<table v-else class="grid-table">
<thead>
<th v-if="!!gridConfig.batch">
<BFormCheckbox
class="m-2"
:checked="selectedAll"
:indeterminate="selectedIndeterminate"
@change="onSelectAll" />
</th>
<th
v-for="(fieldEntry, fieldIndex) in gridConfig.fields"
:key="fieldIndex"
class="text-nowrap px-2"
:data-description="`grid header ${fieldIndex}`">
<span v-if="gridConfig.sortKeys.includes(fieldEntry.key)">
<BButton
variant="link"
class="text-nowrap font-weight-bold"
:data-description="`grid sort key ${fieldEntry.key}`"
@click="onSort(fieldEntry.key)">
<span>{{ fieldEntry.title || fieldEntry.key }}</span>
<span v-if="sortBy === fieldEntry.key">
<FontAwesomeIcon v-if="sortDesc" icon="caret-down" data-description="grid sort desc" />
<FontAwesomeIcon v-else icon="caret-up" data-description="grid sort asc" />
</span>
</BButton>
</span>
<span v-else>{{ fieldEntry.title || fieldEntry.key }}</span>
</th>
</thead>
<tr v-for="(rowData, rowIndex) in gridData" :key="rowIndex" :class="{ 'grid-dark-row': rowIndex % 2 }">
<td v-if="!!gridConfig.batch">
<BFormCheckbox
:checked="selected.has(rowData)"
class="m-2 cursor-pointer"
data-description="grid selected"
@change="onSelect(rowData)" />
</td>
<td
v-for="(fieldEntry, fieldIndex) in gridConfig.fields"
:key="fieldIndex"
class="px-2 py-3"
:style="{ width: `${fieldEntry.width}%` }">
<div
v-if="!fieldEntry.condition || fieldEntry.condition(rowData)"
:data-description="`grid cell ${rowIndex}-${fieldIndex}`">
<GridOperations
v-if="fieldEntry.type == 'operations' && fieldEntry.operations"
:operations="fieldEntry.operations"
:row-data="rowData"
:title="rowData[fieldEntry.key]"
@execute="onOperation($event, rowData)" />
<GridBoolean v-else-if="fieldEntry.type == 'boolean'" :value="rowData[fieldEntry.key]" />
<GridDatasets v-else-if="fieldEntry.type == 'datasets'" :history-id="rowData[fieldEntry.key]" />
<GridText v-else-if="fieldEntry.type == 'text'" :text="rowData[fieldEntry.key]" />
<GridLink
v-else-if="fieldEntry.type == 'link'"
:text="rowData[fieldEntry.key]"
@click="fieldEntry.handler && fieldEntry.handler(rowData)" />
<SharingIndicators
v-else-if="fieldEntry.type == 'sharing'"
:object="rowData"
@filter="onFilter($event)" />
<UtcDate v-else-if="fieldEntry.type == 'date'" :date="rowData[fieldEntry.key]" mode="elapsed" />
<StatelessTags
v-else-if="fieldEntry.type == 'tags'"
clickable
:value="rowData[fieldEntry.key]"
:disabled="fieldEntry.disabled"
@input="onTagInput(rowData, $event, fieldEntry.handler)"
@tag-click="applyFilter('tag', $event, true)" />
<span v-else v-localize> Not available. </span>
</div>
<FontAwesomeIcon v-else icon="fa-shield-alt" />
</td>
</tr>
</table>
<LoadingSpan v-if="initDataLoading" />
<span v-else-if="!isAvailable || hasInvalidFilters" variant="info" show>
<BAlert v-if="!hasInvalidFilters" variant="info" show>
<span v-if="filterText">
<span v-localize>Nothing found with:</span>
<b>{{ filterText }}</b>
</span>
<span v-else v-localize> No entries found. </span>
</BAlert>
<BAlert v-else variant="danger" show>
<Heading h4 inline size="sm" class="flex-grow-1 mb-2">Invalid filters in query:</Heading>
<ul>
<li v-for="[invalidKey, value] in Object.entries(invalidFilters)" :key="invalidKey">
<b>{{ invalidKey }}</b
>: {{ value }}
</li>
</ul>
<a href="javascript:void(0)" class="ui-link" @click="filterText = validatedFilterText()">
Remove invalid filters from query
</a>
or
<a
v-b-tooltip.noninteractive.hover
title="Note that this might produce inaccurate results"
href="javascript:void(0)"
class="ui-link"
@click="filterText = `'${filterText}'`">
Match the exact query provided
</a>
</BAlert>
</span>
<BOverlay v-else :show="resultsLoading" rounded="sm">
<table class="grid-table">
<thead>
<th v-if="!!gridConfig.batch">
<BFormCheckbox
class="m-2"
:checked="selectedAll"
:indeterminate="selectedIndeterminate"
@change="onSelectAll" />
</th>
<th
v-for="(fieldEntry, fieldIndex) in gridConfig.fields"
:key="fieldIndex"
class="text-nowrap px-2"
:data-description="`grid header ${fieldIndex}`">
<span v-if="gridConfig.sortKeys.includes(fieldEntry.key)">
<BButton
variant="link"
class="text-nowrap font-weight-bold"
:data-description="`grid sort key ${fieldEntry.key}`"
@click="onSort(fieldEntry.key)">
<span>{{ fieldEntry.title || fieldEntry.key }}</span>
<span v-if="sortBy === fieldEntry.key">
<FontAwesomeIcon
v-if="sortDesc"
icon="caret-down"
data-description="grid sort desc" />
<FontAwesomeIcon v-else icon="caret-up" data-description="grid sort asc" />
</span>
</BButton>
</span>
<span v-else>{{ fieldEntry.title || fieldEntry.key }}</span>
</th>
</thead>
<tr v-for="(rowData, rowIndex) in gridData" :key="rowIndex" :class="{ 'grid-dark-row': rowIndex % 2 }">
<td v-if="!!gridConfig.batch">
<BFormCheckbox
:checked="selected.has(rowData)"
class="m-2 cursor-pointer"
data-description="grid selected"
@change="onSelect(rowData)" />
</td>
<td
v-for="(fieldEntry, fieldIndex) in gridConfig.fields"
:key="fieldIndex"
class="px-2 py-3"
:style="{ width: `${fieldEntry.width}%` }">
<div
v-if="!fieldEntry.condition || fieldEntry.condition(rowData)"
:data-description="`grid cell ${rowIndex}-${fieldIndex}`">
<GridOperations
v-if="fieldEntry.type == 'operations' && fieldEntry.operations"
:operations="fieldEntry.operations"
:row-data="rowData"
:title="rowData[fieldEntry.key]"
@execute="onOperation($event, rowData)" />
<GridBoolean v-else-if="fieldEntry.type == 'boolean'" :value="rowData[fieldEntry.key]" />
<GridDatasets
v-else-if="fieldEntry.type == 'datasets'"
:history-id="rowData[fieldEntry.key]" />
<GridText v-else-if="fieldEntry.type == 'text'" :text="rowData[fieldEntry.key]" />
<GridLink
v-else-if="fieldEntry.type == 'link'"
:text="rowData[fieldEntry.key]"
@click="fieldEntry.handler && fieldEntry.handler(rowData)" />
<SharingIndicators
v-else-if="fieldEntry.type == 'sharing'"
:object="rowData"
@filter="onFilter($event)" />
<UtcDate
v-else-if="fieldEntry.type == 'date'"
:date="rowData[fieldEntry.key]"
mode="elapsed" />
<StatelessTags
v-else-if="fieldEntry.type == 'tags'"
clickable
:value="rowData[fieldEntry.key]"
:disabled="fieldEntry.disabled"
@input="onTagInput(rowData, $event, fieldEntry.handler)"
@tag-click="applyFilter('tag', $event, true)" />
<span v-else v-localize> Not available. </span>
</div>
<FontAwesomeIcon v-else icon="fa-shield-alt" />
</td>
</tr>
</table>
</BOverlay>
<div class="flex-grow-1 h-100" />
<div class="grid-footer">
<div v-if="isAvailable" class="d-flex justify-content-between pt-3">
@@ -33,6 +33,7 @@ type SortKeyLiteral = "create_time" | "name" | "update_time" | undefined;
async function getData(offset: number, limit: number, search: string, sort_by: string, sort_desc: boolean) {
const { data, headers } = await historiesFetcher({
view: "summary",
keys: "create_time",
limit,
offset,
search,
@@ -21,6 +21,7 @@ type SortKeyLiteral = "name" | "update_time" | undefined;
async function getData(offset: number, limit: number, search: string, sort_by: string, sort_desc: boolean) {
const { data, headers } = await historiesFetcher({
view: "summary",
keys: "username",
limit,
offset,
search,
@@ -206,7 +206,7 @@ onMounted(() => {
<BModal
v-model="showPreferredObjectStoreModal"
title="History Preferred Object Store"
title="History Preferred Storage Location"
modal-class="history-preferred-object-store-modal"
title-tag="h3"
size="sm"
@@ -412,9 +412,9 @@ async function onDrop() {
}
}
function updateFilterValue(newFilterText: string, newValue: any) {
function updateFilterValue(filterKey: string, newValue: any) {
const currentFilterText = filterText.value;
filterText.value = filterClass.setFilterValue(currentFilterText, newFilterText, newValue);
filterText.value = filterClass.setFilterValue(currentFilterText, filterKey, newValue);
}
function getItemKey(item: HistoryItem) {
@@ -23,14 +23,14 @@ const preferredObjectStoreId = computed(() => {
<template>
<BPopover :target="`history-storage-${historyId}`" triggers="hover" placement="bottomleft" boundary="window">
<template v-slot:title>Preferred Target Object Store</template>
<template v-slot:title>Preferred Storage Location</template>
<div class="popover-wide">
<p v-if="historyPreferredObjectStoreId" class="history-preferred-object-store-inherited">
This target object store has been set at the history level.
This storage location has been set at the history level.
</p>
<p v-else class="history-preferred-object-store-not-inherited">
This target object store has been inherited from your user preferences (set in User -> Preferences ->
Preferred Object Store). If that option is updated, this history will target that new default.
This storage location has been inherited from your user preferences (set in User -> Preferences ->
Preferred Storage Location). If that option is updated, this history will target that new default.
</p>
<ShowSelectedObjectStore
@@ -39,7 +39,7 @@ const preferredObjectStoreId = computed(() => {
for-what="Galaxy will default to storing this history's datasets in " />
<div v-localize>
Change this preference object store target by clicking on the storage button in the history panel.
Change preferred storage location by clicking on the storage button in the history panel.
</div>
</div>
</BPopover>
@@ -16,7 +16,7 @@ describe("ConfigurationMarkdown.vue", () => {
expect(wrapper.html()).toContain("<em>content</em>");
});
it("should allow HTML in configuration markdup explicitly set by the admin", () => {
it("should allow HTML in configuration markup explicitly set by the admin", () => {
wrapper = shallowMount(ConfigurationMarkdown, {
propsData: { markdown: "the <b>content</b>", admin: true },
localVue,
@@ -46,15 +46,15 @@ export default {
<div>
<span v-localize>{{ what }}</span>
<span v-if="storageInfo.name" class="display-os-by-name">
a Galaxy <ObjectStoreRestrictionSpan :is-private="isPrivate" /> object store named
a Galaxy <ObjectStoreRestrictionSpan :is-private="isPrivate" /> storage location named
<b>{{ storageInfo.name }}</b>
</span>
<span v-else-if="storageInfo.object_store_id" class="display-os-by-id">
a Galaxy <ObjectStoreRestrictionSpan :is-private="isPrivate" /> object store with id
a Galaxy <ObjectStoreRestrictionSpan :is-private="isPrivate" /> storage location with id
<b>{{ storageInfo.object_store_id }}</b>
</span>
<span v-else class="display-os-default">
the default configured Galaxy <ObjectStoreRestrictionSpan :is-private="isPrivate" /> object store </span
the default configured Galaxy <ObjectStoreRestrictionSpan :is-private="isPrivate" /> storage location </span
>.
</div>
<ObjectStoreBadges :badges="badges"> </ObjectStoreBadges>
@@ -66,7 +66,7 @@ export default {
<b-spinner v-if="isLoadingUsage" />
<QuotaUsageBar v-else-if="quotaUsage" :quota-usage="quotaUsage" :embedded="true" />
</QuotaSourceUsageProvider>
<div v-else>Galaxy has no quota configured for this object store.</div>
<div v-else>Galaxy has no quota configured for this storage location.</div>
<ConfigurationMarkdown v-if="storageInfo.description" :markdown="storageInfo.description" :admin="true" />
</div>
</template>
@@ -14,11 +14,11 @@ const MESSAGES = {
restricted:
"This dataset is stored on storage restricted to a single user. It can not be shared, published, or added to Galaxy data libraries.",
user_defined: "This storage was user defined and is not managed by the Galaxy administrator.",
quota: "A Galaxy quota is enabled for this object store.",
no_quota: "No Galaxy quota is enabled for this object store.",
quota: "A Galaxy quota is enabled for this storage location.",
no_quota: "No Galaxy quota is enabled for this storage location.",
faster: "This storage has been marked as a faster option by the Galaxy administrator.",
slower: "This storage has been marked as a slower option by the Galaxy administrator.",
short_term: "This storage has been marked routinely purged by the Galaxy administrator.",
short_term: "This storage has been marked as routinely purged by the Galaxy administrator.",
backed_up: "This storage has been marked as backed up by the Galaxy administrator.",
not_backed_up: "This storage has been marked as not backed up by the Galaxy administrator.",
more_secure:
@@ -136,6 +136,7 @@ const message = computed(() => {
"
triggers="hover"
placement="bottom"
variant="secondary"
class="object-store-badge-popover">
<p v-localize>{{ stockMessage }}</p>
<ConfigurationMarkdown v-if="message" :markdown="message" :admin="true" />
@@ -13,7 +13,7 @@ describe("ObjectStoreRestrictionSpan", () => {
propsData: { isPrivate: true },
localVue,
});
expect(wrapper.find(".stored-how").text()).toBe("private");
expect(wrapper.find(".stored-how").text()).toMatch("private");
expect(wrapper.find(".stored-how").attributes("title")).toBeTruthy();
});
@@ -22,7 +22,7 @@ describe("ObjectStoreRestrictionSpan", () => {
propsData: { isPrivate: false },
localVue,
});
expect(wrapper.find(".stored-how").text()).toBe("unrestricted");
expect(wrapper.find(".stored-how").text()).toMatch("sharable");
expect(wrapper.find(".stored-how").attributes("title")).toBeTruthy();
});
});
@@ -8,12 +8,12 @@ const props = defineProps({
isPrivate: Boolean,
});
const text = computed(() => (props.isPrivate ? "private" : "unrestricted"));
const text = computed(() => (props.isPrivate ? "private" : "sharable"));
const title = computed(() => {
if (props.isPrivate) {
return "This dataset is stored on storage restricted to a single user. It can not be shared, published, or added to Galaxy data libraries.";
return "This dataset is stored on storage restricted to a single user. It cannot be shared, published, or added to Galaxy data libraries.";
} else {
return "This dataset is stored on unrestricted storage. With sufficient Galaxy permissions, this dataset can be published, shared, or added to Galaxy data libraries.";
return "This dataset is stored on storage that allows standard Galaxy sharing features. If you have sufficient Galaxy permissions to this dataset - the dataset can be published, shared, or added to data libraries within Galaxy.";
}
});
</script>
@@ -25,14 +25,12 @@ const props = withDefaults(defineProps<SelectObjectStoreProps>(), {
const store = useObjectStoreStore();
const { isLoading, loadErrorMessage, selectableObjectStores } = storeToRefs(store);
const loadingObjectStoreInfoMessage = ref("Loading object store information");
const loadingObjectStoreInfoMessage = ref("Loading storage location information");
const whyIsSelectionPreferredText = ref(`
Selecting this will reset Galaxy to default behaviors configured by your Galaxy administrator.
Select a preferred object store for new datasets. This is should be thought of as a preferred
object store because depending the job and workflow configuration execution configuration of
this Galaxy instance - a different object store may be selected. After a dataset is created,
Select a preferred storage location for new datasets. Depending on the job and workflow execution configuration of
this Galaxy a different storage location may be ultimately used. After a dataset is created,
click on the info icon in the history panel to view information about where it is stored. If it
is not stored in the correct place, contact your Galaxy administrator for more information.
is not stored in the place you want, contact Galaxy administrator for more information.
`);
function variant(objectStoreId: string) {
@@ -30,7 +30,7 @@ describe("ShowSelectedObjectStore", () => {
});
let loadingEl = wrapper.findComponent(LoadingSpan);
expect(loadingEl.exists()).toBeTruthy();
expect(loadingEl.find(".loading-message").text()).toContainLocalizationOf("Loading object store details");
expect(loadingEl.find(".loading-message").text()).toContainLocalizationOf("Loading storage location details");
await flushPromises();
loadingEl = wrapper.findComponent(LoadingSpan);
expect(loadingEl.exists()).toBeFalsy();
@@ -38,7 +38,7 @@ watch(
}
);
fetch();
const loadingMessage = "Loading object store details";
const loadingMessage = "Loading storage location details";
</script>
<template>
@@ -12,7 +12,7 @@ export default {
},
computed: {
title() {
return this.l(`Preferred Target Object Store ${this.titleSuffix || ""}`);
return this.l(`Preferred Target Storage Location ${this.titleSuffix || ""}`);
},
},
};
@@ -2,7 +2,7 @@
<b-button
v-if="showAdvanced"
variant="link"
class="w-100"
class="w-100 text-decoration-none"
size="sm"
@click="$emit('update:show-advanced', !showAdvanced)">
<slot name="panel-view-selector"></slot><span class="sr-only">Close advanced tool search menu</span>
+1 -1
View File
@@ -148,7 +148,7 @@ const showHelpForum = computed(() => isConfigLoaded.value && config.value.enable
</ToolTargetPreferredObjectStorePopover>
<b-modal
v-model="showPreferredObjectStoreModal"
title="Tool Execution Preferred Object Store"
title="Tool Execution Preferred Storage Location"
modal-class="tool-preferred-object-store-modal"
title-tag="h3"
size="sm"
@@ -12,7 +12,7 @@ const props = withDefaults(defineProps<ToolSelectProps>(), {
});
const selectedObjectStoreId = ref<String | null>(props.toolPreferredObjectStoreId);
const newDatasetsDescription = "The default object store for the outputs of this tool";
const newDatasetsDescription = "The default storage location for the outputs of this tool";
const defaultOptionTitle = "Use Defaults";
const defaultOptionDescription =
"If the history has a default set, that will be used. If instead, you've set an option in your user preferences - that will be assumed to be your default selection. Finally, the Galaxy configuration will be used.";
@@ -3,8 +3,8 @@
<template v-slot:title>{{ title }}</template>
<div class="popover-wide">
<p v-if="toolPreferredObjectStoreId">
This target object store has been set at the tool level, by default history or user preferences will be
used and if those are not set Galaxy will pick an adminstrator configured default.
Preferred storage location has been set at the tool level, by default history or user preferences will
be used and if those are not set Galaxy will pick an administrator-configured default.
</p>
<ShowSelectedObjectStore
v-if="toolPreferredObjectStoreId"
@@ -15,7 +15,7 @@
No selection has been made for this tool execution. Defaults from history, user, or Galaxy will be used.
</div>
<div v-localize>
Change this preference object store target by clicking on the storage button in the tool header.
Change preferred storage location by clicking on the storage button in the tool header.
</div>
</div>
</b-popover>
@@ -36,7 +36,7 @@ export default {
},
data() {
return {
loadingMessage: "Loading object store information",
loadingMessage: "Loading storage location information",
};
},
};
@@ -40,7 +40,7 @@ describe("UserPreferredObjectStore.vue", () => {
const wrapper = mountComponent();
expect(wrapper.vm.$refs["modal"].isHidden).toBeTruthy();
const el = await wrapper.find(ROOT_COMPONENT.preferences.object_store.selector);
expect(el.text()).toBeLocalizationOf("Preferred Object Store");
expect(el.text()).toBeLocalizationOf("Preferred Storage Location");
await el.trigger("click");
expect(wrapper.vm.$refs["modal"].isHidden).toBeFalsy();
});
@@ -7,17 +7,17 @@
v-b-modal.modal-select-preferred-object-store
class="preferred-storage"
href="javascript:void(0)"
><b v-localize>Preferred Object Store</b></a
><b v-localize>Preferred Storage Location</b></a
>
<div v-localize class="form-text text-muted">
Select a preferred default object store for the outputs of new jobs to be created in.
Select a preferred storage location for the outputs of new jobs.
</div>
<BModal
id="modal-select-preferred-object-store"
ref="modal"
v-model="showModal"
centered
title="Preferred Object Store"
title="Preferred Storage Location"
:title-tag="titleTag"
hide-footer
static
@@ -70,7 +70,12 @@ describe("InvocationsList.vue", () => {
beforeEach(async () => {
axiosMock
.onGet("/api/invocations", {
params: { limit: 50, offset: 0, include_terminal: false, workflow_id: "abcde145678" },
params: {
limit: 50,
offset: 0,
include_terminal: false,
workflow_id: "abcde145678",
},
})
.reply(200, [], { total_matches: "0" });
const propsData = {
@@ -102,7 +107,13 @@ describe("InvocationsList.vue", () => {
beforeEach(async () => {
axiosMock
.onGet("/api/invocations", {
params: { limit: 50, offset: 0, include_terminal: false, history_id: "abcde145678" },
params: {
limit: 50,
offset: 0,
include_terminal: false,
history_id: "abcde145678",
include_nested_invocations: false,
},
})
.reply(200, [], { total_matches: "0" });
const propsData = {
@@ -132,7 +143,9 @@ describe("InvocationsList.vue", () => {
describe("with invocation", () => {
beforeEach(async () => {
axiosMock
.onGet("/api/invocations", { params: { limit: 50, offset: 0, include_terminal: false } })
.onGet("/api/invocations", {
params: { limit: 50, offset: 0, include_terminal: false, include_nested_invocations: false },
})
.reply(200, [mockInvocationData], { total_matches: "1" });
const propsData = {
ownerGrid: false,
@@ -202,7 +215,9 @@ describe("InvocationsList.vue", () => {
describe("paginations", () => {
beforeEach(async () => {
axiosMock
.onGet("/api/invocations", { params: { limit: 1, offset: 0, include_terminal: false } })
.onGet("/api/invocations", {
params: { limit: 1, offset: 0, include_terminal: false, include_nested_invocations: false },
})
.reply(200, [mockInvocationData], { total_matches: "3" });
const propsData = {
ownerGrid: false,
@@ -168,6 +168,8 @@ export default {
const extraParams = this.ownerGrid ? {} : { include_terminal: false };
if (this.storedWorkflowId) {
extraParams["workflow_id"] = this.storedWorkflowId;
} else {
extraParams["include_nested_invocations"] = false;
}
if (this.historyId) {
extraParams["history_id"] = this.historyId;
@@ -195,25 +197,6 @@ export default {
methods: {
...mapActions(useHistoryStore, ["loadHistoryById"]),
...mapActions(useWorkflowStore, ["fetchWorkflowForInstanceIdCached"]),
async provider(ctx) {
ctx.root = this.root;
const extraParams = this.ownerGrid ? {} : { include_terminal: false };
if (this.storedWorkflowId) {
extraParams["workflow_id"] = this.storedWorkflowId;
} else {
extraParams["include_nested_invocations"] = false;
}
if (this.historyId) {
extraParams["history_id"] = this.historyId;
}
if (this.userId) {
extraParams["user_id"] = this.userId;
}
const promise = invocationsProvider(ctx, this.setRows, extraParams).catch(this.onError);
const invocationItems = await promise;
this.invocationItems = invocationItems;
return invocationItems;
},
swapRowDetails(row) {
row.toggleDetails();
},
@@ -33,7 +33,7 @@
<b-form-checkbox
v-if="isConfigLoaded && config.object_store_allows_id_selection"
v-model="splitObjectStore">
Send outputs and intermediate to different object stores?
Send outputs and intermediate to different storage locations?
</b-form-checkbox>
<WorkflowStorageConfiguration
v-if="isConfigLoaded && config.object_store_allows_id_selection"
@@ -16,10 +16,10 @@ const emit = defineEmits<{
}>();
const selectedObjectStoreId = ref<String | null>(props.invocationPreferredObjectStoreId);
const newDatasetsDescription = "The default object store for the outputs of this workflow invocation";
const newDatasetsDescription = "The default storage location for the outputs of this workflow invocation";
const defaultOptionTitle = "Use Defaults";
const defaultOptionDescription =
"If the history has a default set, that will be used. If instead, you've set an option in your user preferences - that will be assumed to be your default selection. Finally, the Galaxy configuration will be used.";
"If the history has a preference set, that will be used. If instead, you've set an option in your user preferences - that will be assumed to be your default selection. Finally, the Galaxy configuration will be used.";
async function handleSubmit(preferredObjectStoreId: string | null) {
selectedObjectStoreId.value = preferredObjectStoreId;
@@ -14,7 +14,7 @@
</WorkflowTargetPreferredObjectStorePopover>
<b-modal
v-model="showPreferredObjectStoreModal"
title="Invocation Preferred Object Store"
title="Invocation Preferred Storage Location"
v-bind="modalProps"
hide-footer>
<WorkflowSelectPreferredObjectStore
@@ -37,7 +37,7 @@
</WorkflowTargetPreferredObjectStorePopover>
<b-modal
v-model="showIntermediatePreferredObjectStoreModal"
title="Invocation Preferred Object Store (Intermediate Datasets)"
title="Invocation Preferred Storage Location (Intermediate Datasets)"
v-bind="modalProps"
hide-footer>
<WorkflowSelectPreferredObjectStore
@@ -2,9 +2,7 @@
<b-popover :target="target" triggers="hover" placement="bottomleft" boundary="window">
<template v-slot:title>{{ title }}</template>
<div class="popover-wide">
<p v-if="invocationPreferredObjectStoreId">
This target object store has been set at the invocation level.
</p>
<p v-if="invocationPreferredObjectStoreId">Storage location has been set at the invocation level.</p>
<ShowSelectedObjectStore
v-if="invocationPreferredObjectStoreId"
:preferred-object-store-id="invocationPreferredObjectStoreId"
@@ -15,7 +13,7 @@
used.
</div>
<div v-localize>
Change this preference object store target by clicking on the storage button in the worklfow run header.
Change preferred storage location by clicking on the storage button in the worklfow run header.
</div>
</div>
</b-popover>
@@ -37,6 +37,7 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<{
(e: "tagClick", tag: string): void;
(e: "refreshList", overlayLoading?: boolean, b?: boolean): void;
(e: "update-filter", key: string, value: any): void;
}>();
const userStore = useUserStore();
@@ -124,7 +125,10 @@ async function onTagClick(tag: string) {
'workflow-shared': workflow.published,
}">
<div class="workflow-card-header">
<WorkflowIndicators :workflow="workflow" :published-view="publishedView" />
<WorkflowIndicators
:workflow="workflow"
:published-view="publishedView"
@update-filter="(k, v) => emit('update-filter', k, v)" />
<div class="workflow-count-actions">
<WorkflowInvocationsCount v-if="!isAnonymous && !shared" class="mx-1" :workflow="workflow" />
+164 -108
View File
@@ -1,115 +1,171 @@
import Filtering, { contains, equals, expandNameTag, toBool } from "utils/filtering";
export const helpHtml = `<div>
<p>This input can be used to filter the workflows displayed.</p>
export function helpHtml(activeList = "my") {
let extra = "";
if (activeList === "my") {
extra = `<dt><code>is:published</code></dt>
<dd>
Shows published workflows.
</dd>
<dt><code>is:importable</code></dt>
<dd>
Shows importable workflows (this also means they are URL generated).
</dd>
<dt><code>is:shared_with_me</code></dt>
<dd>
Shows workflows shared by another user directly with you.
</dd>
<dt><code>is:deleted</code></dt>
<dd>Shows deleted workflows.</dd>`;
} else if (activeList === "shared_with_me") {
extra = `<dt><code>user:____</code></dt>
<dd>
Shows workflows owned by the given user.
</dd>
<dt><code>is:published</code></dt>
<dd>
Shows published workflows.
</dd>`;
} else {
extra = `<dt><code>user:____</code></dt>
<dd>
Shows workflows owned by the given user.
</dd>
<dt><code>is:shared_with_me</code></dt>
<dd>
Shows workflows shared by another user directly with you.
</dd>`;
}
<p>
Text entered here will be searched against workflow names and workflow
tags. Additionally, advanced filtering tags can be used to refine the
search more precisely. Filtering tags are of the form
<code>&lt;tag_name&gt;:&lt;tag_value&gt;</code> or
<code>&lt;tag_name&gt;:'&lt;tag_value&gt;'</code>. For instance to
search just for RNAseq in the workflow name,
<code>name:rnsseq</code> can be used. Notice by default the search is
not case-sensitive. If the quoted version of tag is used, the search is
case sensitive and only full matches will be returned. So
<code>name:'RNAseq'</code> would show only workflows named exactly
<code>RNAseq</code>.
</p>
const conditionalHelpHtml = `<div>
<p>This menu can be used to filter the workflows displayed.</p>
<p>The available filtering tags are:</p>
<dl>
<dt><code>name</code></dt>
<dd>
Shows workflows with the given sequence of characters in their names.
</dd>
<dt><code>tag</code></dt>
<dd>
Shows workflows with the given workflow tag. You may also click
on a tag to filter on that tag directly.
</dd>
<dt><code>is:published</code></dt>
<dd>
Shows published workflows.
</dd>
<dt><code>is:importable</code></dt>
<dd>
Shows importable workflows (this also means they are URL generated).
</dd>
<dt><code>is:shared_with_me</code></dt>
<dd>
Shows workflows shared by another user directly with you.
</dd>
<dt><code>is:deleted</code></dt>
<dd>Shows deleted workflows.</dd>
</dl>
</div>`;
<p>
Text entered here will be searched against workflow names and workflow
tags. Additionally, advanced filtering tags can be used to refine the
search more precisely. Filtering tags are of the form
<code>&lt;tag_name&gt;:&lt;tag_value&gt;</code> or
<code>&lt;tag_name&gt;:'&lt;tag_value&gt;'</code>. For instance to
search just for RNAseq in the workflow name,
<code>name:rnsseq</code> can be used. Notice by default the search is
not case-sensitive. If the quoted version of tag is used, the search is
case sensitive and only full matches will be returned. So
<code>name:'RNAseq'</code> would show only workflows named exactly
<code>RNAseq</code>.
</p>
const validFilters = {
name: { placeholder: "name", type: String, handler: contains("name"), menuItem: true },
user: {
placeholder: "owner",
type: String,
handler: contains("user"),
menuItem: false,
},
tag: {
placeholder: "tag(s)",
type: "MultiTags",
handler: contains("tag", "tag", expandNameTag),
menuItem: true,
},
published: {
placeholder: "Filter on published workflows",
type: Boolean,
boolType: "is",
handler: equals("published", "published", toBool),
menuItem: true,
},
importable: {
placeholder: "Filter on importable workflows",
type: Boolean,
boolType: "is",
handler: equals("importable", "importable", toBool),
menuItem: true,
},
shared_with_me: {
placeholder: "Filter on workflows shared with me",
type: Boolean,
boolType: "is",
handler: equals("shared_with_me", "shared_with_me", toBool),
menuItem: true,
},
deleted: {
placeholder: "Filter on deleted workflows",
type: Boolean,
boolType: "is",
handler: equals("deleted", "deleted", toBool),
menuItem: true,
},
};
<p>The available filtering tags are:</p>
<dl>
<dt><code>name:____</code></dt>
<dd>
Shows workflows with the given sequence of characters in their names.
</dd>
<dt><code>tag:____</code></dt>
<dd>
Shows workflows with the given workflow tag. You may also click
on a tag to filter on that tag directly.
</dd>
${extra}
</dl>
</div>`;
return conditionalHelpHtml;
}
export const WorkflowFilters = new Filtering(validFilters, undefined, false, false);
export function WorkflowFilters(activeList = "my") {
const commonFilters = {
name: { placeholder: "name", type: String, handler: contains("name"), menuItem: true },
n: { handler: contains("n"), menuItem: false },
tag: {
placeholder: "tag(s)",
type: "MultiTags",
handler: contains("tag", "tag", expandNameTag),
menuItem: true,
},
t: { type: "MultiTags", handler: contains("t", "t", expandNameTag), menuItem: false },
};
const validPublishedFilters = {
...validFilters,
user: {
...validFilters.user,
menuItem: true,
},
published: {
...validFilters.published,
default: true,
menuItem: false,
},
shared_with_me: {
...validFilters.shared_with_me,
menuItem: false,
},
importable: {
...validFilters.importable,
menuItem: false,
},
};
export const PublishedWorkflowFilters = new Filtering(validPublishedFilters, undefined, false, false);
if (activeList === "my") {
return new Filtering(
{
...commonFilters,
published: {
placeholder: "Filter on published workflows",
type: Boolean,
boolType: "is",
handler: equals("published", "published", toBool),
menuItem: true,
},
importable: {
placeholder: "Filter on importable workflows",
type: Boolean,
boolType: "is",
handler: equals("importable", "importable", toBool),
menuItem: true,
},
shared_with_me: {
placeholder: "Filter on workflows shared with me",
type: Boolean,
boolType: "is",
handler: equals("shared_with_me", "shared_with_me", toBool),
menuItem: true,
},
deleted: {
placeholder: "Filter on deleted workflows",
type: Boolean,
boolType: "is",
handler: equals("deleted", "deleted", toBool),
menuItem: true,
},
},
undefined,
false,
false
);
} else if (activeList === "shared_with_me") {
return new Filtering(
{
...commonFilters,
user: {
placeholder: "owner",
type: String,
handler: contains("user"),
menuItem: true,
},
u: { handler: contains("u"), menuItem: false },
published: {
placeholder: "Filter on published workflows",
type: Boolean,
boolType: "is",
handler: equals("published", "published", toBool),
menuItem: true,
},
},
undefined,
false,
false
);
} else {
return new Filtering(
{
...commonFilters,
user: {
placeholder: "owner",
type: String,
handler: contains("user"),
menuItem: true,
},
u: { handler: contains("u"), menuItem: false },
shared_with_me: {
placeholder: "Filter on workflows shared with me",
type: Boolean,
boolType: "is",
handler: equals("shared_with_me", "shared_with_me", toBool),
menuItem: true,
},
},
undefined,
false,
false
);
}
}
@@ -21,6 +21,10 @@ interface Props {
const props = defineProps<Props>();
const emit = defineEmits<{
(e: "update-filter", key: string, value: any): void;
}>();
const router = useRouter();
const userStore = useUserStore();
@@ -69,28 +73,37 @@ function onCopyLink() {
function onViewMySharedByUser() {
router.push(`/workflows/list_shared_with_me?owner=${props.workflow.owner}`);
emit("update-filter", "user", `'${props.workflow.owner}'`);
}
function onViewUserPublished() {
router.push(`/workflows/list_published?owner=${props.workflow.owner}`);
emit("update-filter", "user", `'${props.workflow.owner}'`);
}
</script>
<template>
<div>
<BButton
v-if="workflow.published"
v-b-tooltip.noninteractive
v-if="workflow.published && !publishedView"
v-b-tooltip.noninteractive.hover
size="sm"
class="workflow-published-icon inline-icon-button"
to="/workflows/list_published"
title="Published workflow. Click to view all published workflows">
title="Published workflow. Click to filter published workflows"
@click="emit('update-filter', 'published', true)">
<FontAwesomeIcon :icon="faGlobe" fixed-width />
</BButton>
<FontAwesomeIcon
v-else-if="workflow.published"
v-b-tooltip.noninteractive.hover
title="Published workflow"
:icon="faGlobe"
fixed-width
size="sm" />
<BButton
v-if="sourceType.includes('trs')"
v-b-tooltip.noninteractive
v-b-tooltip.noninteractive.hover
size="sm"
class="workflow-trs-icon inline-icon-button"
:title="sourceTitle">
@@ -99,7 +112,7 @@ function onViewUserPublished() {
<BButton
v-if="sourceType == 'url'"
v-b-tooltip.noninteractive
v-b-tooltip.noninteractive.hover
size="sm"
class="workflow-external-link inline-icon-button"
:title="sourceTitle">
@@ -115,7 +128,7 @@ function onViewUserPublished() {
<BBadge
v-if="shared && !publishedView"
v-b-tooltip.noninteractive
v-b-tooltip.noninteractive.hover
class="outline-badge cursor-pointer mx-1"
:title="`'${workflow.owner}' shared this workflow with you. Click to view all workflows shared with you by '${workflow.owner}'`"
@click="onViewMySharedByUser">
@@ -125,7 +138,7 @@ function onViewUserPublished() {
<BBadge
v-if="publishedView"
v-b-tooltip.noninteractive
v-b-tooltip.noninteractive.hover
class="outline-badge cursor-pointer mx-1"
:title="publishedTitle"
@click="onViewUserPublished">
+78 -90
View File
@@ -4,13 +4,14 @@ import { faStar, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BAlert, BButton, BNav, BNavItem, BOverlay, BPagination } from "bootstrap-vue";
import { filter } from "underscore";
import { computed, type ComputedRef, onMounted, ref, watch } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router/composables";
import { loadWorkflows } from "@/components/Workflow/workflows.services";
import { Toast } from "@/composables/toast";
import { useUserStore } from "@/stores/userStore";
import Filtering, { contains, equals, expandNameTag, toBool, type ValidFilter } from "@/utils/filtering";
import { helpHtml, WorkflowFilters } from "./WorkflowFilters";
import FilterMenu from "@/components/Common/FilterMenu.vue";
import Heading from "@/components/Common/Heading.vue";
@@ -22,37 +23,6 @@ import WorkflowListActions from "@/components/Workflow/WorkflowListActions.vue";
library.add(faStar, faTrash);
const helpHtml = `<div>
<p>This input can be used to filter the workflows displayed.</p>
<p>
Text entered here will be searched against workflow names and workflow
tags. Additionally, advanced filtering tags can be used to refine the
search more precisely. Filtering tags are of the form
<code>&lt;tag_name&gt;:&lt;tag_value&gt;</code> or
<code>&lt;tag_name&gt;:'&lt;tag_value&gt;'</code>. For instance to
search just for RNAseq in the workflow name,
<code>name:rnsseq</code> can be used. Notice by default the search is
not case-sensitive. If the quoted version of tag is used, the search is
case sensitive and only full matches will be returned. So
<code>name:'RNAseq'</code> would show only workflows named exactly
<code>RNAseq</code>.
</p>
<p>The available filtering tags are:</p>
<dl>
<dt><code>name</code></dt>
<dd>
Shows workflows with the given sequence of characters in their names.
</dd>
<dt><code>tag</code></dt>
<dd>
Shows workflows with the given workflow tag. You may also click
on a tag to filter on that tag directly.
</dd>
</dl>
</div>`;
type ListView = "grid" | "list";
type WorkflowsList = Record<string, never>[];
@@ -76,29 +46,8 @@ const totalWorkflows = ref(0);
const showAdvanced = ref(false);
const showBookmarked = ref(false);
const listHeader = ref<any>(null);
const advancedFiltering = ref<any>(null);
const workflowsLoaded = ref<WorkflowsList>([]);
const validFilters: ComputedRef<Record<string, ValidFilter<string | boolean | undefined>>> = computed(() => {
return {
name: { placeholder: "name", type: String, handler: contains("name"), menuItem: true },
tag: {
placeholder: "tag",
type: "MultiTags",
handler: contains("tags", "tag", expandNameTag),
menuItem: true,
},
deleted: {
placeholder: "Filter on deleted workflows",
type: Boolean,
handler: equals("deleted", "deleted", toBool),
menuItem: false,
},
};
});
const WorkflowFilters = new Filtering(validFilters.value);
const searchPlaceHolder = computed(() => {
let placeHolder = "Search my workflows";
@@ -108,7 +57,7 @@ const searchPlaceHolder = computed(() => {
placeHolder = "Search workflows shared with me";
}
placeHolder += " by name or use the advanced filtering options";
placeHolder += " by query or use the advanced filtering options";
return placeHolder;
});
@@ -123,24 +72,19 @@ const sortBy = computed(() => (listHeader.value && listHeader.value.sortBy) || "
const noItems = computed(() => !loading.value && workflowsLoaded.value.length === 0 && !filterText.value);
const noResults = computed(() => !loading.value && workflowsLoaded.value.length === 0 && filterText.value);
function updateFilter(newVal: string) {
advancedFiltering.value.updateFilter(newVal.trim());
}
// Filtering computed refs
const workflowFilters = computed(() => WorkflowFilters(props.activeList));
const rawFilters = computed(() =>
Object.fromEntries(workflowFilters.value.getFiltersForText(filterText.value, true, false))
);
const validFilters = computed(() => workflowFilters.value.getValidFilters(rawFilters.value, true).validFilters);
const invalidFilters = computed(() => workflowFilters.value.getValidFilters(rawFilters.value, true).invalidFilters);
const isSurroundedByQuotes = computed(() => /^["'].*["']$/.test(filterText.value));
const hasInvalidFilters = computed(() => !isSurroundedByQuotes.value && Object.keys(invalidFilters.value).length > 0);
function onTagClick(tag: string) {
if (filterText.value.includes(tag)) {
filterText.value = filterText.value.replace(`tag:'${tag}'`, "").trim();
} else {
filterText.value = WorkflowFilters.setFilterValue(filterText.value, "tag", `'${tag}'`);
}
}
function onToggleDeleted() {
if (!showDeleted.value) {
filterText.value = `${filterText.value} is:deleted`.trim();
} else {
filterText.value = filterText.value.replace("is:deleted", "").trim();
}
function updateFilterValue(filterKey: string, newValue: any) {
const currentFilterText = filterText.value;
filterText.value = workflowFilters.value.setFilterValue(currentFilterText, filterKey, newValue);
}
function onToggleBookmarked() {
@@ -155,15 +99,22 @@ async function load(overlayLoading = false, silent = false) {
loading.value = true;
}
}
let search;
if (!hasInvalidFilters.value) {
search = validatedFilterText();
let search = filterText.value;
if (published.value) {
search += " is:published";
}
if (sharedWithMe.value) {
search += " is:shared_with_me";
// append default backend query filters for provided `props.activeList`
if (published.value && !workflowFilters.value.getFilterValue(search, "published")) {
search += " is:published";
}
if (sharedWithMe.value && !workflowFilters.value.getFilterValue(search, "shared_with_me")) {
search += " is:shared_with_me";
}
} else {
// there are invalid filters, so we don't want to search
overlay.value = false;
loading.value = false;
return;
}
try {
@@ -205,6 +156,18 @@ async function onPageChange(page: number) {
await load(true);
}
function validatedFilterText() {
if (isSurroundedByQuotes.value) {
// the `filterText` is surrounded by quotes, remove them
return filterText.value.slice(1, -1);
} else if (Object.keys(rawFilters.value).length === 0) {
// there are no filters derived from the `filterText`
return filterText.value;
}
// there are valid filters derived from the `filterText`
return workflowFilters.value.getFilterText(validFilters.value, true);
}
watch([filterText, sortBy, sortDesc, showBookmarked], async () => {
offset.value = 0;
await load(true);
@@ -212,7 +175,7 @@ watch([filterText, sortBy, sortDesc, showBookmarked], async () => {
onMounted(() => {
if (router.currentRoute.query.owner) {
filterText.value = `${filterText.value} user:${router.currentRoute.query.owner}`.trim();
updateFilterValue("user", `'${router.currentRoute.query.owner}'`);
}
load();
});
@@ -251,15 +214,14 @@ onMounted(() => {
id="workflow-list-filter"
name="workflows"
class="mb-2"
:filter-class="WorkflowFilters"
:filter-class="workflowFilters"
:filter-text.sync="filterText"
:loading="loading || overlay"
has-help
:placeholder="searchPlaceHolder"
:show-advanced.sync="showAdvanced"
@updateFilter="updateFilter">
:show-advanced.sync="showAdvanced">
<template v-slot:menu-help-text>
<div v-html="helpHtml"></div>
<div v-html="helpHtml(activeList)"></div>
</template>
</FilterMenu>
@@ -274,7 +236,7 @@ onMounted(() => {
:title="!showDeleted ? 'Show deleted workflows' : 'Hide deleted workflows'"
:pressed="showDeleted"
variant="outline-primary"
@click="onToggleDeleted">
@click="updateFilterValue('deleted', true)">
<FontAwesomeIcon :icon="faTrash" fixed-width />
Show deleted
</BButton>
@@ -303,9 +265,34 @@ onMounted(() => {
No workflows found. You may create or import new workflows using the buttons above.
</BAlert>
<BAlert v-else-if="!loading && !overlay && noResults" id="no-workflow-found" variant="info" show>
No workflows found matching: <span class="font-weight-bold">{{ filterText }}</span>
</BAlert>
<!-- There are either `noResults` or `invalidFilters` -->
<span v-else-if="!loading && !overlay && (noResults || hasInvalidFilters)">
<BAlert v-if="!hasInvalidFilters" id="no-workflow-found" variant="info" show>
No workflows found matching: <span class="font-weight-bold">{{ filterText }}</span>
</BAlert>
<BAlert v-else id="no-workflow-found-invalid" variant="danger" show>
<Heading h4 inline size="sm" class="flex-grow-1 mb-2">Invalid filters in query:</Heading>
<ul>
<li v-for="[invalidKey, value] in Object.entries(invalidFilters)" :key="invalidKey">
<b>{{ invalidKey }}</b
>: {{ value }}
</li>
</ul>
<a href="javascript:void(0)" class="ui-link" @click="filterText = validatedFilterText()">
Remove invalid filters from query
</a>
or
<a
v-b-tooltip.noninteractive.hover
title="Note that this might produce inaccurate results"
href="javascript:void(0)"
class="ui-link"
@click="filterText = `'${filterText}'`">
Match the exact query provided
</a>
</BAlert>
</span>
<BOverlay
v-else
@@ -322,7 +309,8 @@ onMounted(() => {
:grid-view="view === 'grid'"
:class="view === 'grid' ? 'grid-view' : 'list-view'"
@refreshList="load"
@tagClick="onTagClick" />
@tagClick="(tag) => updateFilterValue('tag', `'${tag}'`)"
@update-filter="updateFilterValue" />
<BPagination
v-if="!loading && totalWorkflows > limit"
+29 -28
View File
@@ -1,7 +1,9 @@
import { HistoryFilters } from "components/History/HistoryFilters";
import { PublishedWorkflowFilters } from "components/Workflow/WorkflowFilters";
import { WorkflowFilters } from "components/Workflow/WorkflowFilters";
describe("test filtering helpers to convert filters to filter text", () => {
const MyWorkflowFilters = WorkflowFilters("my");
const PublishedWorkflowFilters = WorkflowFilters("published");
it("conversion from filters to new filter text", async () => {
const normalized = HistoryFilters.defaultFilters;
expect(Object.keys(normalized).length).toBe(2);
@@ -10,7 +12,7 @@ describe("test filtering helpers to convert filters to filter text", () => {
});
it("verify the existence of defaults", async () => {
let filters = {};
const filters = {};
Object.entries(HistoryFilters.defaultFilters).forEach(([key, value]) => {
filters[key] = value;
});
@@ -22,13 +24,6 @@ describe("test filtering helpers to convert filters to filter text", () => {
expect(HistoryFilters.containsDefaults(filters)).toBe(false);
filters["visible"] = String(HistoryFilters.defaultFilters.visible).toUpperCase();
expect(HistoryFilters.containsDefaults(filters)).toBe(true);
filters = {};
Object.entries(PublishedWorkflowFilters.defaultFilters).forEach(([key, value]) => {
filters[key] = value;
});
expect(PublishedWorkflowFilters.containsDefaults(filters)).toBe(true);
filters["published"] = !PublishedWorkflowFilters.defaultFilters.published;
expect(PublishedWorkflowFilters.containsDefaults(filters)).toBe(false);
});
it("verify correct conversion of filters", async () => {
@@ -37,14 +32,16 @@ describe("test filtering helpers to convert filters to filter text", () => {
visible: HistoryFilters.defaultFilters.visible,
name: "name",
other: "other",
tag: ["tag1", "tag2"],
tag: ["tag1", "'tag2'", "'#tag3'"],
genome_build: "",
published: PublishedWorkflowFilters.defaultFilters.published,
published: true,
};
const validHistFilters = HistoryFilters.getValidFilters(filters);
const validHistFilters = HistoryFilters.getValidFilters(filters).validFilters;
expect(Object.keys(validHistFilters)).toEqual(["deleted", "visible", "name"]);
const validWfFilters = PublishedWorkflowFilters.getValidFilters(filters);
expect(Object.keys(validWfFilters)).toEqual(["deleted", "name", "tag", "published"]);
const validMyWfFilters = MyWorkflowFilters.getValidFilters(filters).validFilters;
expect(Object.keys(validMyWfFilters)).toEqual(["deleted", "name", "tag", "published"]);
const validPubWfFilters = PublishedWorkflowFilters.getValidFilters(filters).validFilters;
expect(Object.keys(validPubWfFilters)).toEqual(["name", "tag"]);
expect(HistoryFilters.getFilterText(filters)).toBe("name:name");
filters["visible"] = !HistoryFilters.defaultFilters.visible;
@@ -52,15 +49,27 @@ describe("test filtering helpers to convert filters to filter text", () => {
filters["visible"] = HistoryFilters.defaultFilters.visible;
expect(HistoryFilters.getFilterText(filters)).toBe("name:name");
expect(PublishedWorkflowFilters.getFilterText(filters, true)).toBe("name:name tag:tag1 tag:tag2 is:published");
// non-backend filter text keeps filters as is
expect(MyWorkflowFilters.getFilterText(filters)).toBe("name:name tag:tag1 tag:'tag2' tag:'#tag3' is:published");
// backend filter text adjusts name tag by replacing `#` with `name:`
expect(MyWorkflowFilters.getFilterText(filters, true)).toBe(
"name:name tag:tag1 tag:'tag2' tag:'name:tag3' is:published"
);
expect(PublishedWorkflowFilters.getFilterText(filters, true)).toBe(
"name:name tag:tag1 tag:'tag2' tag:'name:tag3'"
);
delete filters["published"];
expect(PublishedWorkflowFilters.getFilterText(filters, true)).toBe("name:name tag:tag1 tag:tag2");
expect(MyWorkflowFilters.getFilterText(filters, true)).toBe("name:name tag:tag1 tag:'tag2' tag:'name:tag3'");
});
});
describe("test filtering helpers to convert filter text to filters", () => {
const PublishedWorkflowFilters = WorkflowFilters("published");
function getFilters(filteringClass, filterText) {
return filteringClass.getValidFilters(Object.fromEntries(filteringClass.getFiltersForText(filterText)));
return filteringClass.getValidFilters(Object.fromEntries(filteringClass.getFiltersForText(filterText)))
.validFilters;
}
it("verify the existence of defaults", async () => {
@@ -74,11 +83,6 @@ describe("test filtering helpers to convert filter text to filters", () => {
expect(HistoryFilters.containsDefaults(getFilters(HistoryFilters, filterText))).toBe(false);
filterText = "deleted:false visible:true";
expect(HistoryFilters.containsDefaults(getFilters(HistoryFilters, filterText))).toBe(true);
filterText = "";
expect(PublishedWorkflowFilters.containsDefaults(getFilters(PublishedWorkflowFilters, filterText))).toBe(true);
filterText = "is:published is:deleted";
expect(PublishedWorkflowFilters.containsDefaults(getFilters(PublishedWorkflowFilters, filterText))).toBe(true);
});
it("verify correct conversion of filterText (HistoryFilters)", async () => {
@@ -104,17 +108,14 @@ describe("test filtering helpers to convert filter text to filters", () => {
it("verify correct conversion of filterText (PublishedWorkflowFilters)", async () => {
const filters = {
published: PublishedWorkflowFilters.defaultFilters.published,
name: "name",
};
let filterText = "name:name";
let filterText = "name:name is:published";
expect(getFilters(PublishedWorkflowFilters, filterText)).toEqual(filters);
filterText = "published:false name:name";
// filters["visible"] = !PublishedWorkflowFilters.defaultFilters.visible;
// delete filters["deleted"];
expect(getFilters(PublishedWorkflowFilters, filterText)).toEqual(filters);
filterText = "name:name invalid:invalid is:deleted";
filters["deleted"] = true;
filterText = "name:name invalid:invalid user:testUser";
filters["user"] = "testUser";
expect(getFilters(PublishedWorkflowFilters, filterText)).toEqual(filters);
});
});
+10 -1
View File
@@ -14,6 +14,10 @@ const sampleFilters = [
validFilters: {
deleted: true,
},
invalidFilters: {
invalid: "value",
visible: null,
},
validText: "deleted:true visible:any",
},
{
@@ -28,6 +32,9 @@ const sampleFilters = [
related: 10,
hid_gt: 5,
},
invalidFilters: {
hid_less_than: 20,
},
validText: "deleted:any related:10 hid>5 visible:any",
},
];
@@ -90,7 +97,9 @@ describe("filtering", () => {
});
test("parse get valid filters and settings", () => {
sampleFilters.forEach((sample) => {
expect(HistoryFilters.getValidFilters(sample.filters)).toEqual(sample.validFilters);
const { validFilters, invalidFilters } = HistoryFilters.getValidFilters(sample.filters);
expect(validFilters).toEqual(sample.validFilters);
expect(invalidFilters).toEqual(sample.invalidFilters);
expect(HistoryFilters.getFilterText(sample.filters)).toEqual(sample.validText);
});
});
+30 -15
View File
@@ -104,7 +104,7 @@ export function toLowerNoQuotes<T>(value: T): string {
/** Converts name tags starting with '#' to 'name:'
* @param value
* @returns Lowercase value with 'name:' replaced with '#'
* @returns String value with 'name:' replaced with '#'
* */
export function expandNameTag<T>(value: T): string {
if (value && typeof value === "string") {
@@ -114,7 +114,7 @@ export function expandNameTag<T>(value: T): string {
value = value.replace(/^#/, "name:") as T;
}
}
return toLower(value);
return value as string;
}
/** Converts string alias to string operator, e.g.: 'gt' to '>'
@@ -233,7 +233,9 @@ export function compare<T>(attribute: string, variant: string, converter?: Conve
* @param validAliases: Array of valid aliases for filters
* @param quoteStrings: Whether to auto quote filter strings in the query
* @param nameMatching: Whether to apply name filter for unspecified filterText
* (e.g. filterText = 'foo' -> 'name:foo')
* (e.g. filterText = 'foo' -> 'name:foo').
* Typically, when this is false, we index every field in
* the backend for unspecified filterText.
* @returns Filtering object
* */
export default class Filtering<T> {
@@ -341,7 +343,7 @@ export default class Filtering<T> {
* @returns Parsed filter text string
* */
getFilterText(filters: Record<string, T>, backendFormatted = false): string {
filters = this.getValidFilters(filters, backendFormatted);
filters = this.getValidFilters(filters, backendFormatted).validFilters;
const hasDefaults = this.containsDefaults(filters);
let newFilterText = "";
@@ -385,10 +387,10 @@ export default class Filtering<T> {
/** Parses single text input into a dict of field->value pairs.
* @param filterText Raw filter text string
* @param removeAny default: `true` Whether to remove default filters if the are set to `any`
* @param removeAny default: `true` Whether to remove default filters if they are set to `any`
* @returns Filters as 2D array of of [field, value] pairs
* */
getFiltersForText(filterText: string, removeAny = true): [string, T][] {
getFiltersForText(filterText: string, removeAny = true, validate = true): [string, T][] {
const pairSplitRE = this.quoteStrings
? /[^\s'"]+(?:['"][^'"]*['"][^\s'"]*)*|(?:['"][^'"]*['"][^\s'"]*)+/g
: /(\S+):(.*?)(?=\s+\S+:|$)/g;
@@ -414,8 +416,8 @@ export default class Filtering<T> {
const normalizedField = field?.split("-").join("_");
if (
normalizedField &&
this.validFilters[normalizedField] &&
this.validFilters[normalizedField]?.boolType !== "is"
this.validFilters[normalizedField]?.boolType !== "is" &&
((!validate && normalizedField !== "is") || this.validFilters[normalizedField])
) {
// removes quotation and applies lower-case to filter value
const newVal = this.quoteStrings ? (toLowerNoQuotes(value) as T) : (value as T);
@@ -430,7 +432,12 @@ export default class Filtering<T> {
result[normalizedField] = newVal;
}
hasMatches = true;
} else if (value && field === "is" && elg === ":" && this.validFilters[value]?.boolType === "is") {
} else if (
value &&
field === "is" &&
elg === ":" &&
(!validate || this.validFilters[value]?.boolType === "is")
) {
// handle `is:filter` syntax
result[value] = true as T;
hasMatches = true;
@@ -471,7 +478,7 @@ export default class Filtering<T> {
* @returns Parsed `filterText` string with added/removed filter(s)
*/
applyFiltersToText(filters: Record<string, T>, existingText: string, remove = false) {
let validFilters = this.getValidFilters(filters);
let { validFilters } = this.getValidFilters(filters);
const existingFilters = Object.fromEntries(this.getFiltersForText(existingText, false));
if (remove) {
validFilters = omit(existingFilters, Object.keys(validFilters));
@@ -485,10 +492,11 @@ export default class Filtering<T> {
*
* @param filters A filters object (e.g.: {hid: "3", name: "test", invalid: "x"}})
* @param backendFormatted default: `false` Whether to convert the values to backend format
* @returns valid filters object (e.g.: {hid: "3", name: "test"}})
* @returns a _valid_ filters object (e.g.: {hid: "3", name: "test"}}) and one with invalid filters
*/
getValidFilters(filters: Record<string, T>, backendFormatted = false) {
const validFilters: Record<string, T> = {};
const invalidFilters: Record<string, T> = {};
Object.entries(filters).forEach(([key, value]) => {
if (this.validFilters[key]?.type === "MultiTags" && Array.isArray(value)) {
const validValues = value
@@ -497,14 +505,22 @@ export default class Filtering<T> {
if (validValues.length > 0) {
validFilters[key] = validValues as T;
}
const invalidValues = value.filter(
(v) => !validValues.includes(this.getConvertedValue(key, v, backendFormatted))
);
if (invalidValues.length > 0) {
invalidFilters[key] = invalidValues as T;
}
} else {
const validValue = this.getConvertedValue(key, value, backendFormatted);
if (validValue !== undefined) {
validFilters[key] = validValue;
} else {
invalidFilters[key] = value;
}
}
});
return validFilters;
return { validFilters, invalidFilters };
}
/** Convert a valid filter key (`filter`/`filter-gt`) to alias filter key (`filter:`/`filter>`)
@@ -574,11 +590,10 @@ export default class Filtering<T> {
if (converter) {
if (
(converter == toBool && filterValue == "any") ||
(!backendFormatted && /^(['"]).*\1$/.test(filterValue as string))
(!backendFormatted && /^(['"]).*\1$/.test(filterValue as string)) ||
(!backendFormatted && ([expandNameTag, toDate] as Converter<T>[]).includes(converter))
) {
return filterValue;
} else if (!backendFormatted && ([expandNameTag, toDate] as Converter<T>[]).includes(converter)) {
return toLower(filterValue) as T;
}
return converter(filterValue);
} else {
+1 -1
View File
@@ -2236,7 +2236,7 @@ class InputDataCollectionStep(WorkflowStepBase):
class InputParameterStep(WorkflowStepBase):
type: Literal["parameter"]
type: Literal["parameter_input"]
class PauseStep(WorkflowStepBase):
+3 -2
View File
@@ -122,10 +122,11 @@ class UsersService(ServiceBase):
def _anon_user_api_value(self, trans: ProvidesHistoryContext):
"""Return data for an anonymous user, truncated to only usage and quota_percent"""
if not trans.user and not trans.history:
usage = None
usage: Optional[float] = 0.0
percent: Optional[int] = 0
else:
usage = self.quota_agent.get_usage(trans, history=trans.history)
percent = self.quota_agent.get_percent(trans=trans, usage=usage)
percent = self.quota_agent.get_percent(trans=trans, usage=usage)
usage = usage or 0
return {
"total_disk_usage": int(usage),
+2 -1
View File
@@ -49,7 +49,8 @@ class TestTrsImport(SeleniumIntegrationTestCase):
return cls.temp_config_dir("trs")
def assert_workflow_imported(self, name):
self.workflow_index_search_for(name)
# surround name with quotes to consider case where name contains colons
self.workflow_index_search_for(f'"{name}"')
assert len(self.workflow_card_elements()) == 1, f"workflow ${name} not imported"
def test_import_workflow_by_url_dockstore(self):