mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-09-24 16:30:27 +08:00
Merge branch 'release_24.0' into dev
This commit is contained in:
@@ -6938,7 +6938,7 @@ export interface components {
|
||||
* Type
|
||||
* @constant
|
||||
*/
|
||||
type: "parameter";
|
||||
type: "parameter_input";
|
||||
/** When */
|
||||
when: string | null;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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><tag_name>:<tag_value></code> or
|
||||
<code><tag_name>:'<tag_value>'</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><tag_name>:<tag_value></code> or
|
||||
<code><tag_name>:'<tag_value>'</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">
|
||||
|
||||
@@ -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><tag_name>:<tag_value></code> or
|
||||
<code><tag_name>:'<tag_value>'</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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2236,7 +2236,7 @@ class InputDataCollectionStep(WorkflowStepBase):
|
||||
|
||||
|
||||
class InputParameterStep(WorkflowStepBase):
|
||||
type: Literal["parameter"]
|
||||
type: Literal["parameter_input"]
|
||||
|
||||
|
||||
class PauseStep(WorkflowStepBase):
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user