Loading client/src/components/Common/ListHeader.vue +56 −28 Original line number Diff line number Diff line Loading @@ -2,7 +2,7 @@ import { library } from "@fortawesome/fontawesome-svg-core"; import { faAngleDown, faAngleUp, faBars, faGripVertical } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { BButton } from "bootstrap-vue"; import { BButton, BButtonGroup, BFormCheckbox } from "bootstrap-vue"; import { computed, ref } from "vue"; import { useUserStore } from "@/stores/userStore"; Loading @@ -13,13 +13,25 @@ type ListView = "grid" | "list"; type SortBy = "create_time" | "update_time" | "name"; interface Props { allSelected?: boolean; showSelectAll?: boolean; showViewToggle?: boolean; selectAllDisabled?: boolean; indeterminateSelected?: boolean; } withDefaults(defineProps<Props>(), { allSelected: false, showSelectAll: false, showViewToggle: false, selectAllDisabled: false, indeterminateSelected: false, }); const emit = defineEmits<{ (e: "select-all"): void; }>(); const userStore = useUserStore(); const sortDesc = ref(true); Loading Loading @@ -47,7 +59,22 @@ defineExpose({ <template> <div class="list-header"> <div class="list-header-select-all"> <slot name="select-all"> <BFormCheckbox v-if="showSelectAll" id="list-header-select-all" :disabled="selectAllDisabled" :checked="allSelected" :indeterminate="indeterminateSelected" @change="emit('select-all')"> Select all </BFormCheckbox> </slot> </div> <div class="list-header-filters"> <div> Sort by: <BButtonGroup> <BButton Loading @@ -74,6 +101,7 @@ defineExpose({ Update time </BButton> </BButtonGroup> </div> <slot name="extra-filter" /> </div> Loading Loading @@ -115,7 +143,7 @@ defineExpose({ .list-header-filters { display: flex; gap: 0.25rem; gap: 1rem; flex-wrap: wrap; align-items: center; } Loading client/src/components/Common/TagsSelectionDialog.vue 0 → 100644 +33 −0 Original line number Diff line number Diff line <script setup lang="ts"> import { BModal } from "bootstrap-vue"; import { ref } from "vue"; import StatelessTags from "@/components/TagsMultiselect/StatelessTags.vue"; interface Props { title?: string; initialTags?: string[]; } const props = withDefaults(defineProps<Props>(), { title: "Select tags to add", initialTags: () => [], }); const tags = ref(props.initialTags); const emit = defineEmits<{ (e: "cancel"): void; (e: "ok", tags: string[]): void; }>(); function onTagsChange(newTags: string[]) { tags.value = newTags; } </script> <template> <BModal visible centered size="lg" :title="title" @ok="emit('ok', tags)" @hide="emit('cancel')"> <StatelessTags :value="tags" @input="onTagsChange($event)" /> </BModal> </template> client/src/components/Workflow/List/WorkflowCard.vue +40 −11 Original line number Diff line number Diff line <script setup lang="ts"> import { faPen } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { BButton, BLink } from "bootstrap-vue"; import { BButton, BFormCheckbox, BLink } from "bootstrap-vue"; import { storeToRefs } from "pinia"; import { computed, ref } from "vue"; import { type StoredWorkflowDetailed } from "@/api/workflows"; import { updateWorkflow } from "@/components/Workflow/workflows.services"; import { useUserStore } from "@/stores/userStore"; Loading @@ -16,13 +17,15 @@ import WorkflowIndicators from "@/components/Workflow/List/WorkflowIndicators.vu import WorkflowInvocationsCount from "@/components/Workflow/WorkflowInvocationsCount.vue"; interface Props { workflow: any; workflow: StoredWorkflowDetailed; gridView?: boolean; hideRuns?: boolean; filterable?: boolean; publishedView?: boolean; editorView?: boolean; current?: boolean; selected?: boolean; selectable?: boolean; } const props = withDefaults(defineProps<Props>(), { Loading @@ -32,9 +35,12 @@ const props = withDefaults(defineProps<Props>(), { filterable: true, editorView: false, current: false, selected: false, selectable: false, }); const emit = defineEmits<{ (e: "select", workflow: any): void; (e: "tagClick", tag: string): void; (e: "refreshList", overlayLoading?: boolean, silent?: boolean): void; (e: "updateFilter", key: string, value: any): void; Loading @@ -56,7 +62,7 @@ const shared = computed(() => { const description = computed(() => { if (workflow.value.annotations && workflow.value.annotations.length > 0) { return workflow.value.annotations[0].trim(); return workflow.value.annotations[0]?.trim(); } else { return null; } Loading Loading @@ -85,8 +91,17 @@ const dropdownOpen = ref(false); class="workflow-card-container" :class="{ 'workflow-shared': workflow.published, 'workflow-card-selected': props.selected, }"> <div class="workflow-card-header"> <BFormCheckbox v-if="props.selectable && !shared" v-b-tooltip.hover.noninteractive :checked="props.selected" class="workflow-card-select-checkbox" title="Select for bulk actions" @change="emit('select', workflow)" /> <WorkflowIndicators class="workflow-card-indicators" :workflow="workflow" Loading Loading @@ -117,6 +132,7 @@ const dropdownOpen = ref(false); @click.stop.prevent="emit('preview', props.workflow.id)"> {{ workflow.name }} </BLink> <BButton v-if="!props.current && !shared && !workflow.deleted" v-b-tooltip.hover.noninteractive Loading Loading @@ -190,7 +206,13 @@ const dropdownOpen = ref(false); gap: 0.5rem; flex-direction: column; justify-content: space-between; border: 1px solid $brand-secondary; border: 0.1rem solid $brand-secondary; &.workflow-card-selected { background-color: $brand-light; border: 0.1rem solid $brand-primary; } border-radius: 0.5rem; padding: 0.5rem; Loading @@ -203,21 +225,28 @@ const dropdownOpen = ref(false); position: relative; align-items: start; grid-template-areas: "i b" "n n" "s s"; "i d b" "n n n" "s s s"; grid-template-columns: auto 1fr auto; &:has(.invocations-count) { @container workflow-card (max-width: #{$breakpoint-xs}) { grid-template-areas: "i b" "n b" "s s"; "i d b" "n n b" "s s s"; } } .workflow-card-indicators { .workflow-card-select-checkbox { grid-area: i; margin: 0%; } .workflow-card-indicators { grid-area: d; } .workflow-count-actions { Loading client/src/components/Workflow/List/WorkflowCardList.vue +28 −10 Original line number Diff line number Diff line Loading @@ -16,11 +16,21 @@ interface Props { publishedView?: boolean; editorView?: boolean; currentWorkflowId?: string; selectedWorkflowIds?: Workflow[]; } const props = defineProps<Props>(); const props = withDefaults(defineProps<Props>(), { gridView: false, hideRuns: false, filterable: true, publishedView: false, editorView: false, currentWorkflowId: "", selectedWorkflowIds: () => [], }); const emit = defineEmits<{ (e: "select", workflow: Workflow): void; (e: "tagClick", tag: string): void; (e: "refreshList", overlayLoading?: boolean, silent?: boolean): void; (e: "updateFilter", key: string, value: any): void; Loading @@ -39,6 +49,7 @@ const modalOptions = reactive({ }); const showRename = ref(false); const showPreview = ref(false); function onRenameClose() { showRename.value = false; Loading @@ -51,8 +62,6 @@ function onRename(id: string, name: string) { showRename.value = true; } const showPreview = ref(false); function onPreview(id: string) { modalOptions.preview.id = id; showPreview.value = true; Loading @@ -74,6 +83,8 @@ function onInsertSteps(workflow: Workflow) { v-for="workflow in props.workflows" :key="workflow.id" :workflow="workflow" :selectable="!publishedView && !editorView" :selected="props.selectedWorkflowIds.some((w) => w.id === workflow.id)" :grid-view="props.gridView" :hide-runs="props.hideRuns" :filterable="props.filterable" Loading @@ -81,6 +92,7 @@ function onInsertSteps(workflow: Workflow) { :editor-view="props.editorView" :current="workflow.id === props.currentWorkflowId" class="workflow-card" @select="(...args) => emit('select', ...args)" @tagClick="(...args) => emit('tagClick', ...args)" @refreshList="(...args) => emit('refreshList', ...args)" @updateFilter="(...args) => emit('updateFilter', ...args)" Loading Loading @@ -122,6 +134,7 @@ function onInsertSteps(workflow: Workflow) { @import "_breakpoints.scss"; .workflow-card-list { overflow: auto; container: card-list / inline-size; display: flex; flex-wrap: wrap; Loading @@ -130,7 +143,11 @@ function onInsertSteps(workflow: Workflow) { width: 100%; } &.grid .workflow-card { &.grid { // it is overwriting the base non used css for the grid class padding-top: 0 !important; .workflow-card { width: calc(100% / 3); @container card-list (max-width: #{$breakpoint-xl}) { Loading @@ -142,4 +159,5 @@ function onInsertSteps(workflow: Workflow) { } } } } </style> client/src/components/Workflow/List/WorkflowList.vue +280 −23 Original line number Diff line number Diff line <script setup lang="ts"> import { library } from "@fortawesome/fontawesome-svg-core"; import { faStar, faTrash } from "@fortawesome/free-solid-svg-icons"; import { faStar, faTags, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { BAlert, BButton, BNav, BNavItem, BOverlay, BPagination } from "bootstrap-vue"; import { faTrashRestore } from "font-awesome-6"; import { filter } from "underscore"; import { computed, onMounted, ref, watch } from "vue"; import { useRouter } from "vue-router/composables"; import { GalaxyApi } from "@/api"; import { getWorkflowFilters, helpHtml } from "@/components/Workflow/List/workflowFilters"; import { deleteWorkflow, undeleteWorkflow, updateWorkflow } from "@/components/Workflow/workflows.services"; import { useConfirmDialog } from "@/composables/confirmDialog"; import { Toast } from "@/composables/toast"; import { useUserStore } from "@/stores/userStore"; import { rethrowSimple } from "@/utils/simple-error"; Loading @@ -18,14 +20,20 @@ import FilterMenu from "@/components/Common/FilterMenu.vue"; import Heading from "@/components/Common/Heading.vue"; import ListHeader from "@/components/Common/ListHeader.vue"; import LoginRequired from "@/components/Common/LoginRequired.vue"; import TagsSelectionDialog from "@/components/Common/TagsSelectionDialog.vue"; import LoadingSpan from "@/components/LoadingSpan.vue"; import WorkflowListActions from "@/components/Workflow/List/WorkflowListActions.vue"; library.add(faStar, faTrash); type ListView = "grid" | "list"; type WorkflowsList = Record<string, never>[]; // Interface to match the `Workflow` interface from `WorkflowCard` interface SelectedWorkflow { id: string; name: string; published: boolean; } interface Props { activeList?: "my" | "shared_with_me" | "published"; } Loading @@ -36,6 +44,7 @@ const props = withDefaults(defineProps<Props>(), { const router = useRouter(); const userStore = useUserStore(); const { confirm } = useConfirmDialog(); const limit = ref(24); const offset = ref(0); Loading @@ -45,7 +54,11 @@ const filterText = ref(""); const totalWorkflows = ref(0); const showAdvanced = ref(false); const listHeader = ref<any>(null); const showBulkAddTagsModal = ref(false); const bulkTagsLoading = ref(false); const bulkDeleteOrRestoreLoading = ref(false); const workflowsLoaded = ref<WorkflowsList>([]); const selectedWorkflowIds = ref<SelectedWorkflow[]>([]); const searchPlaceHolder = computed(() => { let placeHolder = "Search my workflows"; Loading @@ -70,13 +83,12 @@ const view = computed(() => (userStore.preferredListViewMode as ListView) || "gr const sortDesc = computed(() => (listHeader.value && listHeader.value.sortDesc) ?? true); const sortBy = computed(() => (listHeader.value && listHeader.value.sortBy) || "update_time"); const noItems = computed(() => !loading.value && workflowsLoaded.value.length === 0 && !filterText.value); const noResults = computed(() => !loading.value && workflowsLoaded.value.length === 0 && filterText.value); const noResults = computed(() => !loading.value && workflowsLoaded.value.length === 0 && Boolean(filterText.value)); const deleteButtonTitle = computed(() => (showDeleted.value ? "Hide deleted workflows" : "Show deleted workflows")); const bookmarkButtonTitle = computed(() => showBookmarked.value ? "Hide bookmarked workflows" : "Show bookmarked workflows" ); // Filtering computed refs const workflowFilters = computed(() => getWorkflowFilters(props.activeList)); const rawFilters = computed(() => Object.fromEntries(workflowFilters.value.getFiltersForText(filterText.value, true, false)) Loading @@ -85,6 +97,10 @@ const validFilters = computed(() => workflowFilters.value.getValidFilters(rawFil 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); const indeterminateSelected = computed(() => selectedWorkflowIds.value.length > 0 && !allSelected.value); const allSelected = computed( () => selectedWorkflowIds.value.length !== 0 && selectedWorkflowIds.value.length === workflowsLoaded.value.length ); function updateFilterValue(filterKey: string, newValue: any) { const currentFilterText = filterText.value; Loading @@ -107,7 +123,9 @@ async function load(overlayLoading = false, silent = false) { loading.value = true; } } let search; if (!hasInvalidFilters.value) { search = validatedFilterText(); Loading Loading @@ -178,8 +196,154 @@ function validatedFilterText() { return workflowFilters.value.getFilterText(validFilters.value, true); } function onSelectWorkflow(w: SelectedWorkflow) { const index = selectedWorkflowIds.value.findIndex((selected) => selected.id === w.id); if (index === -1) { selectedWorkflowIds.value.push(w); } else { selectedWorkflowIds.value.splice(index, 1); } } function onSelectAllWorkflows() { if (selectedWorkflowIds.value.length === workflowsLoaded.value.length) { selectedWorkflowIds.value = []; } else { selectedWorkflowIds.value = workflowsLoaded.value.map((w: any) => { return { id: w.id, name: w.name, published: w.published, }; }); } } async function onBulkDelete() { const totalSelected = selectedWorkflowIds.value.length; const hasPublished = selectedWorkflowIds.value.some((w) => w.published); const confirmed = await confirm( `${hasPublished ? "Some of the selected workflows are published and will be removed from public view. " : ""} Are you sure you want to delete ${totalSelected} workflows?`, { title: "Delete workflows", okTitle: "Delete workflows", okVariant: "danger", } ); if (confirmed) { const tmpSelected = [...selectedWorkflowIds.value]; try { overlay.value = true; bulkDeleteOrRestoreLoading.value = true; for (const w of selectedWorkflowIds.value) { await deleteWorkflow(w.id); tmpSelected.splice( tmpSelected.findIndex((s) => s.id === w.id), 1 ); } Toast.success(`Deleted ${totalSelected} workflows.`); selectedWorkflowIds.value = []; } catch (e) { Toast.error(`Failed to delete some workflows.`); } finally { bulkDeleteOrRestoreLoading.value = false; selectedWorkflowIds.value = tmpSelected; await load(true); } } } async function onBulkRestore() { const totalSelected = selectedWorkflowIds.value.length; const confirmed = await confirm(`Are you sure you want to restore ${totalSelected} workflows?`, { okTitle: "Restore workflows", okVariant: "primary", }); if (confirmed) { const tmpSelected = [...selectedWorkflowIds.value]; try { overlay.value = true; bulkDeleteOrRestoreLoading.value = true; for (const w of selectedWorkflowIds.value) { await undeleteWorkflow(w.id); tmpSelected.splice( tmpSelected.findIndex((s) => s.id === w.id), 1 ); } Toast.success(`Restored ${totalSelected} workflows.`); selectedWorkflowIds.value = []; } catch (e) { Toast.error(`Failed to restore some workflows.`); } finally { bulkDeleteOrRestoreLoading.value = false; selectedWorkflowIds.value = tmpSelected; await load(true); } } } async function onToggleBulkTags() { showBulkAddTagsModal.value = !showBulkAddTagsModal.value; } async function onBulkTagsAdd(tags: string[]) { const tmpSelected = [...selectedWorkflowIds.value]; const totalSelected = selectedWorkflowIds.value.length; try { overlay.value = true; bulkTagsLoading.value = true; for (const w of selectedWorkflowIds.value) { const prevTags = workflowsLoaded.value.find((workflow) => workflow.id === w.id)?.tags || []; await updateWorkflow(w.id, { tags: [...new Set([...prevTags, ...tags])] }); tmpSelected.splice( tmpSelected.findIndex((s) => s.id === w.id), 1 ); } Toast.success(`Added tag(s) to ${totalSelected} workflows.`); } catch (e) { Toast.error(`Failed to add tag(s) to some workflows. ${e}`); } finally { bulkTagsLoading.value = false; selectedWorkflowIds.value = tmpSelected; await load(true); } } watch([filterText, sortBy, sortDesc], async () => { offset.value = 0; selectedWorkflowIds.value = []; await load(true); }); Loading Loading @@ -236,7 +400,14 @@ onMounted(() => { </template> </FilterMenu> <ListHeader ref="listHeader" show-view-toggle> <ListHeader ref="listHeader" show-view-toggle :show-select-all="!published && !sharedWithMe" :select-all-disabled="loading || overlay || noItems || noResults" :all-selected="allSelected" :indeterminate-selected="indeterminateSelected" @select-all="onSelectAllWorkflows"> <template v-slot:extra-filter> <div v-if="activeList === 'my'"> Filter: Loading Loading @@ -268,16 +439,17 @@ onMounted(() => { </ListHeader> </div> <BAlert v-if="loading" variant="info" show> <LoadingSpan message="Loading workflows..." /> <div v-if="loading" class="workflow-list-alert"> <BAlert variant="info" show> <LoadingSpan message="Loading workflows" /> </BAlert> <BAlert v-if="!loading && !overlay && noItems" id="workflow-list-empty" variant="info" show> </div> <div v-else-if="!loading && !overlay && noItems" class="workflow-list-alert"> <BAlert id="workflow-list-empty" variant="info" show> No workflows found. You may create or import new workflows using the buttons above. </BAlert> <!-- There are either `noResults` or `invalidFilters` --> <span v-else-if="!loading && !overlay && (noResults || hasInvalidFilters)"> </div> <span v-else-if="!loading && !overlay && (noResults || hasInvalidFilters)" class="workflow-list-alert"> <BAlert v-if="!hasInvalidFilters" id="no-workflow-found" variant="info" show> No workflows found matching: <span class="font-weight-bold">{{ filterText }}</span> </BAlert> Loading @@ -304,27 +476,89 @@ onMounted(() => { </a> </BAlert> </span> <BOverlay v-else id="workflow-cards" :show="overlay" rounded="sm" class="cards-list mt-2"> <BOverlay v-else id="workflow-cards" :show="overlay" rounded="sm" class="cards-list"> <WorkflowCardList :workflows="workflowsLoaded" :published-view="published" :grid-view="view === 'grid'" :selected-workflow-ids="selectedWorkflowIds" @select="onSelectWorkflow" @refreshList="load" @tagClick="(tag) => updateFilterValue('tag', `'${tag}'`)" @updateFilter="updateFilterValue" /> </BOverlay> <div class="workflow-list-footer"> <div v-if="!published && !sharedWithMe && selectedWorkflowIds.length" class="workflow-list-footer-bulk-actions"> <BButton v-if="!showDeleted" id="workflow-list-footer-bulk-delete-button" v-b-tooltip.hover :title="bulkDeleteOrRestoreLoading ? 'Deleting workflows' : 'Delete selected workflows'" :disabled="bulkDeleteOrRestoreLoading" size="sm" variant="primary" @click="onBulkDelete"> <span v-if="!bulkDeleteOrRestoreLoading"> <FontAwesomeIcon :icon="faTrash" fixed-width /> Delete ({{ selectedWorkflowIds.length }}) </span> <LoadingSpan v-else message="Deleting" /> </BButton> <BButton v-else id="workflow-list-footer-bulk-restore-button" v-b-tooltip.hover :title="bulkDeleteOrRestoreLoading ? 'Restoring workflows' : 'Restore selected workflows'" :disabled="bulkDeleteOrRestoreLoading" size="sm" variant="primary" @click="onBulkRestore"> <span v-if="!bulkDeleteOrRestoreLoading"> <FontAwesomeIcon :icon="faTrashRestore" fixed-width /> Restore ({{ selectedWorkflowIds.length }}) </span> <LoadingSpan v-else message="Restoring" /> </BButton> <BButton v-if="!showDeleted" id="workflow-list-footer-bulk-add-tags-button" v-b-tooltip.hover :title="bulkTagsLoading ? 'Adding tags' : 'Add tags to selected workflows'" :disabled="bulkTagsLoading" size="sm" variant="primary" @click="onToggleBulkTags"> <span v-if="!bulkTagsLoading"> <FontAwesomeIcon :icon="faTags" fixed-width /> Add tags ({{ selectedWorkflowIds.length }}) </span> <LoadingSpan v-else message="Adding tags" /> </BButton> </div> <BPagination v-if="!loading && totalWorkflows > limit" class="mt-2 w-100" class="workflow-list-footer-pagination" :value="currentPage" :total-rows="totalWorkflows" :per-page="limit" align="center" align="right" size="sm" first-number last-number @change="onPageChange" /> </BOverlay> </div> <TagsSelectionDialog v-if="showBulkAddTagsModal" :title="`Add tags to ${selectedWorkflowIds.length} selected workflow${ selectedWorkflowIds.length > 1 ? 's' : '' }`" @cancel="onToggleBulkTags" @ok="onBulkTagsAdd" /> </div> </template> Loading @@ -345,7 +579,12 @@ onMounted(() => { z-index: 100; } .workflow-list-alert { height: 100%; } .cards-list { height: 100%; scroll-behavior: smooth; min-height: 150px; display: flex; Loading @@ -354,5 +593,23 @@ onMounted(() => { overflow-y: auto; overflow-x: hidden; } .workflow-list-footer { display: flex; align-items: center; margin-top: 0.5rem; .workflow-list-footer-bulk-actions { display: flex; gap: 0.5rem; width: 100%; position: absolute; } .workflow-list-footer-pagination { margin: 0 auto; width: 100%; } } } </style> Loading
client/src/components/Common/ListHeader.vue +56 −28 Original line number Diff line number Diff line Loading @@ -2,7 +2,7 @@ import { library } from "@fortawesome/fontawesome-svg-core"; import { faAngleDown, faAngleUp, faBars, faGripVertical } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { BButton } from "bootstrap-vue"; import { BButton, BButtonGroup, BFormCheckbox } from "bootstrap-vue"; import { computed, ref } from "vue"; import { useUserStore } from "@/stores/userStore"; Loading @@ -13,13 +13,25 @@ type ListView = "grid" | "list"; type SortBy = "create_time" | "update_time" | "name"; interface Props { allSelected?: boolean; showSelectAll?: boolean; showViewToggle?: boolean; selectAllDisabled?: boolean; indeterminateSelected?: boolean; } withDefaults(defineProps<Props>(), { allSelected: false, showSelectAll: false, showViewToggle: false, selectAllDisabled: false, indeterminateSelected: false, }); const emit = defineEmits<{ (e: "select-all"): void; }>(); const userStore = useUserStore(); const sortDesc = ref(true); Loading Loading @@ -47,7 +59,22 @@ defineExpose({ <template> <div class="list-header"> <div class="list-header-select-all"> <slot name="select-all"> <BFormCheckbox v-if="showSelectAll" id="list-header-select-all" :disabled="selectAllDisabled" :checked="allSelected" :indeterminate="indeterminateSelected" @change="emit('select-all')"> Select all </BFormCheckbox> </slot> </div> <div class="list-header-filters"> <div> Sort by: <BButtonGroup> <BButton Loading @@ -74,6 +101,7 @@ defineExpose({ Update time </BButton> </BButtonGroup> </div> <slot name="extra-filter" /> </div> Loading Loading @@ -115,7 +143,7 @@ defineExpose({ .list-header-filters { display: flex; gap: 0.25rem; gap: 1rem; flex-wrap: wrap; align-items: center; } Loading
client/src/components/Common/TagsSelectionDialog.vue 0 → 100644 +33 −0 Original line number Diff line number Diff line <script setup lang="ts"> import { BModal } from "bootstrap-vue"; import { ref } from "vue"; import StatelessTags from "@/components/TagsMultiselect/StatelessTags.vue"; interface Props { title?: string; initialTags?: string[]; } const props = withDefaults(defineProps<Props>(), { title: "Select tags to add", initialTags: () => [], }); const tags = ref(props.initialTags); const emit = defineEmits<{ (e: "cancel"): void; (e: "ok", tags: string[]): void; }>(); function onTagsChange(newTags: string[]) { tags.value = newTags; } </script> <template> <BModal visible centered size="lg" :title="title" @ok="emit('ok', tags)" @hide="emit('cancel')"> <StatelessTags :value="tags" @input="onTagsChange($event)" /> </BModal> </template>
client/src/components/Workflow/List/WorkflowCard.vue +40 −11 Original line number Diff line number Diff line <script setup lang="ts"> import { faPen } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { BButton, BLink } from "bootstrap-vue"; import { BButton, BFormCheckbox, BLink } from "bootstrap-vue"; import { storeToRefs } from "pinia"; import { computed, ref } from "vue"; import { type StoredWorkflowDetailed } from "@/api/workflows"; import { updateWorkflow } from "@/components/Workflow/workflows.services"; import { useUserStore } from "@/stores/userStore"; Loading @@ -16,13 +17,15 @@ import WorkflowIndicators from "@/components/Workflow/List/WorkflowIndicators.vu import WorkflowInvocationsCount from "@/components/Workflow/WorkflowInvocationsCount.vue"; interface Props { workflow: any; workflow: StoredWorkflowDetailed; gridView?: boolean; hideRuns?: boolean; filterable?: boolean; publishedView?: boolean; editorView?: boolean; current?: boolean; selected?: boolean; selectable?: boolean; } const props = withDefaults(defineProps<Props>(), { Loading @@ -32,9 +35,12 @@ const props = withDefaults(defineProps<Props>(), { filterable: true, editorView: false, current: false, selected: false, selectable: false, }); const emit = defineEmits<{ (e: "select", workflow: any): void; (e: "tagClick", tag: string): void; (e: "refreshList", overlayLoading?: boolean, silent?: boolean): void; (e: "updateFilter", key: string, value: any): void; Loading @@ -56,7 +62,7 @@ const shared = computed(() => { const description = computed(() => { if (workflow.value.annotations && workflow.value.annotations.length > 0) { return workflow.value.annotations[0].trim(); return workflow.value.annotations[0]?.trim(); } else { return null; } Loading Loading @@ -85,8 +91,17 @@ const dropdownOpen = ref(false); class="workflow-card-container" :class="{ 'workflow-shared': workflow.published, 'workflow-card-selected': props.selected, }"> <div class="workflow-card-header"> <BFormCheckbox v-if="props.selectable && !shared" v-b-tooltip.hover.noninteractive :checked="props.selected" class="workflow-card-select-checkbox" title="Select for bulk actions" @change="emit('select', workflow)" /> <WorkflowIndicators class="workflow-card-indicators" :workflow="workflow" Loading Loading @@ -117,6 +132,7 @@ const dropdownOpen = ref(false); @click.stop.prevent="emit('preview', props.workflow.id)"> {{ workflow.name }} </BLink> <BButton v-if="!props.current && !shared && !workflow.deleted" v-b-tooltip.hover.noninteractive Loading Loading @@ -190,7 +206,13 @@ const dropdownOpen = ref(false); gap: 0.5rem; flex-direction: column; justify-content: space-between; border: 1px solid $brand-secondary; border: 0.1rem solid $brand-secondary; &.workflow-card-selected { background-color: $brand-light; border: 0.1rem solid $brand-primary; } border-radius: 0.5rem; padding: 0.5rem; Loading @@ -203,21 +225,28 @@ const dropdownOpen = ref(false); position: relative; align-items: start; grid-template-areas: "i b" "n n" "s s"; "i d b" "n n n" "s s s"; grid-template-columns: auto 1fr auto; &:has(.invocations-count) { @container workflow-card (max-width: #{$breakpoint-xs}) { grid-template-areas: "i b" "n b" "s s"; "i d b" "n n b" "s s s"; } } .workflow-card-indicators { .workflow-card-select-checkbox { grid-area: i; margin: 0%; } .workflow-card-indicators { grid-area: d; } .workflow-count-actions { Loading
client/src/components/Workflow/List/WorkflowCardList.vue +28 −10 Original line number Diff line number Diff line Loading @@ -16,11 +16,21 @@ interface Props { publishedView?: boolean; editorView?: boolean; currentWorkflowId?: string; selectedWorkflowIds?: Workflow[]; } const props = defineProps<Props>(); const props = withDefaults(defineProps<Props>(), { gridView: false, hideRuns: false, filterable: true, publishedView: false, editorView: false, currentWorkflowId: "", selectedWorkflowIds: () => [], }); const emit = defineEmits<{ (e: "select", workflow: Workflow): void; (e: "tagClick", tag: string): void; (e: "refreshList", overlayLoading?: boolean, silent?: boolean): void; (e: "updateFilter", key: string, value: any): void; Loading @@ -39,6 +49,7 @@ const modalOptions = reactive({ }); const showRename = ref(false); const showPreview = ref(false); function onRenameClose() { showRename.value = false; Loading @@ -51,8 +62,6 @@ function onRename(id: string, name: string) { showRename.value = true; } const showPreview = ref(false); function onPreview(id: string) { modalOptions.preview.id = id; showPreview.value = true; Loading @@ -74,6 +83,8 @@ function onInsertSteps(workflow: Workflow) { v-for="workflow in props.workflows" :key="workflow.id" :workflow="workflow" :selectable="!publishedView && !editorView" :selected="props.selectedWorkflowIds.some((w) => w.id === workflow.id)" :grid-view="props.gridView" :hide-runs="props.hideRuns" :filterable="props.filterable" Loading @@ -81,6 +92,7 @@ function onInsertSteps(workflow: Workflow) { :editor-view="props.editorView" :current="workflow.id === props.currentWorkflowId" class="workflow-card" @select="(...args) => emit('select', ...args)" @tagClick="(...args) => emit('tagClick', ...args)" @refreshList="(...args) => emit('refreshList', ...args)" @updateFilter="(...args) => emit('updateFilter', ...args)" Loading Loading @@ -122,6 +134,7 @@ function onInsertSteps(workflow: Workflow) { @import "_breakpoints.scss"; .workflow-card-list { overflow: auto; container: card-list / inline-size; display: flex; flex-wrap: wrap; Loading @@ -130,7 +143,11 @@ function onInsertSteps(workflow: Workflow) { width: 100%; } &.grid .workflow-card { &.grid { // it is overwriting the base non used css for the grid class padding-top: 0 !important; .workflow-card { width: calc(100% / 3); @container card-list (max-width: #{$breakpoint-xl}) { Loading @@ -142,4 +159,5 @@ function onInsertSteps(workflow: Workflow) { } } } } </style>
client/src/components/Workflow/List/WorkflowList.vue +280 −23 Original line number Diff line number Diff line <script setup lang="ts"> import { library } from "@fortawesome/fontawesome-svg-core"; import { faStar, faTrash } from "@fortawesome/free-solid-svg-icons"; import { faStar, faTags, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { BAlert, BButton, BNav, BNavItem, BOverlay, BPagination } from "bootstrap-vue"; import { faTrashRestore } from "font-awesome-6"; import { filter } from "underscore"; import { computed, onMounted, ref, watch } from "vue"; import { useRouter } from "vue-router/composables"; import { GalaxyApi } from "@/api"; import { getWorkflowFilters, helpHtml } from "@/components/Workflow/List/workflowFilters"; import { deleteWorkflow, undeleteWorkflow, updateWorkflow } from "@/components/Workflow/workflows.services"; import { useConfirmDialog } from "@/composables/confirmDialog"; import { Toast } from "@/composables/toast"; import { useUserStore } from "@/stores/userStore"; import { rethrowSimple } from "@/utils/simple-error"; Loading @@ -18,14 +20,20 @@ import FilterMenu from "@/components/Common/FilterMenu.vue"; import Heading from "@/components/Common/Heading.vue"; import ListHeader from "@/components/Common/ListHeader.vue"; import LoginRequired from "@/components/Common/LoginRequired.vue"; import TagsSelectionDialog from "@/components/Common/TagsSelectionDialog.vue"; import LoadingSpan from "@/components/LoadingSpan.vue"; import WorkflowListActions from "@/components/Workflow/List/WorkflowListActions.vue"; library.add(faStar, faTrash); type ListView = "grid" | "list"; type WorkflowsList = Record<string, never>[]; // Interface to match the `Workflow` interface from `WorkflowCard` interface SelectedWorkflow { id: string; name: string; published: boolean; } interface Props { activeList?: "my" | "shared_with_me" | "published"; } Loading @@ -36,6 +44,7 @@ const props = withDefaults(defineProps<Props>(), { const router = useRouter(); const userStore = useUserStore(); const { confirm } = useConfirmDialog(); const limit = ref(24); const offset = ref(0); Loading @@ -45,7 +54,11 @@ const filterText = ref(""); const totalWorkflows = ref(0); const showAdvanced = ref(false); const listHeader = ref<any>(null); const showBulkAddTagsModal = ref(false); const bulkTagsLoading = ref(false); const bulkDeleteOrRestoreLoading = ref(false); const workflowsLoaded = ref<WorkflowsList>([]); const selectedWorkflowIds = ref<SelectedWorkflow[]>([]); const searchPlaceHolder = computed(() => { let placeHolder = "Search my workflows"; Loading @@ -70,13 +83,12 @@ const view = computed(() => (userStore.preferredListViewMode as ListView) || "gr const sortDesc = computed(() => (listHeader.value && listHeader.value.sortDesc) ?? true); const sortBy = computed(() => (listHeader.value && listHeader.value.sortBy) || "update_time"); const noItems = computed(() => !loading.value && workflowsLoaded.value.length === 0 && !filterText.value); const noResults = computed(() => !loading.value && workflowsLoaded.value.length === 0 && filterText.value); const noResults = computed(() => !loading.value && workflowsLoaded.value.length === 0 && Boolean(filterText.value)); const deleteButtonTitle = computed(() => (showDeleted.value ? "Hide deleted workflows" : "Show deleted workflows")); const bookmarkButtonTitle = computed(() => showBookmarked.value ? "Hide bookmarked workflows" : "Show bookmarked workflows" ); // Filtering computed refs const workflowFilters = computed(() => getWorkflowFilters(props.activeList)); const rawFilters = computed(() => Object.fromEntries(workflowFilters.value.getFiltersForText(filterText.value, true, false)) Loading @@ -85,6 +97,10 @@ const validFilters = computed(() => workflowFilters.value.getValidFilters(rawFil 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); const indeterminateSelected = computed(() => selectedWorkflowIds.value.length > 0 && !allSelected.value); const allSelected = computed( () => selectedWorkflowIds.value.length !== 0 && selectedWorkflowIds.value.length === workflowsLoaded.value.length ); function updateFilterValue(filterKey: string, newValue: any) { const currentFilterText = filterText.value; Loading @@ -107,7 +123,9 @@ async function load(overlayLoading = false, silent = false) { loading.value = true; } } let search; if (!hasInvalidFilters.value) { search = validatedFilterText(); Loading Loading @@ -178,8 +196,154 @@ function validatedFilterText() { return workflowFilters.value.getFilterText(validFilters.value, true); } function onSelectWorkflow(w: SelectedWorkflow) { const index = selectedWorkflowIds.value.findIndex((selected) => selected.id === w.id); if (index === -1) { selectedWorkflowIds.value.push(w); } else { selectedWorkflowIds.value.splice(index, 1); } } function onSelectAllWorkflows() { if (selectedWorkflowIds.value.length === workflowsLoaded.value.length) { selectedWorkflowIds.value = []; } else { selectedWorkflowIds.value = workflowsLoaded.value.map((w: any) => { return { id: w.id, name: w.name, published: w.published, }; }); } } async function onBulkDelete() { const totalSelected = selectedWorkflowIds.value.length; const hasPublished = selectedWorkflowIds.value.some((w) => w.published); const confirmed = await confirm( `${hasPublished ? "Some of the selected workflows are published and will be removed from public view. " : ""} Are you sure you want to delete ${totalSelected} workflows?`, { title: "Delete workflows", okTitle: "Delete workflows", okVariant: "danger", } ); if (confirmed) { const tmpSelected = [...selectedWorkflowIds.value]; try { overlay.value = true; bulkDeleteOrRestoreLoading.value = true; for (const w of selectedWorkflowIds.value) { await deleteWorkflow(w.id); tmpSelected.splice( tmpSelected.findIndex((s) => s.id === w.id), 1 ); } Toast.success(`Deleted ${totalSelected} workflows.`); selectedWorkflowIds.value = []; } catch (e) { Toast.error(`Failed to delete some workflows.`); } finally { bulkDeleteOrRestoreLoading.value = false; selectedWorkflowIds.value = tmpSelected; await load(true); } } } async function onBulkRestore() { const totalSelected = selectedWorkflowIds.value.length; const confirmed = await confirm(`Are you sure you want to restore ${totalSelected} workflows?`, { okTitle: "Restore workflows", okVariant: "primary", }); if (confirmed) { const tmpSelected = [...selectedWorkflowIds.value]; try { overlay.value = true; bulkDeleteOrRestoreLoading.value = true; for (const w of selectedWorkflowIds.value) { await undeleteWorkflow(w.id); tmpSelected.splice( tmpSelected.findIndex((s) => s.id === w.id), 1 ); } Toast.success(`Restored ${totalSelected} workflows.`); selectedWorkflowIds.value = []; } catch (e) { Toast.error(`Failed to restore some workflows.`); } finally { bulkDeleteOrRestoreLoading.value = false; selectedWorkflowIds.value = tmpSelected; await load(true); } } } async function onToggleBulkTags() { showBulkAddTagsModal.value = !showBulkAddTagsModal.value; } async function onBulkTagsAdd(tags: string[]) { const tmpSelected = [...selectedWorkflowIds.value]; const totalSelected = selectedWorkflowIds.value.length; try { overlay.value = true; bulkTagsLoading.value = true; for (const w of selectedWorkflowIds.value) { const prevTags = workflowsLoaded.value.find((workflow) => workflow.id === w.id)?.tags || []; await updateWorkflow(w.id, { tags: [...new Set([...prevTags, ...tags])] }); tmpSelected.splice( tmpSelected.findIndex((s) => s.id === w.id), 1 ); } Toast.success(`Added tag(s) to ${totalSelected} workflows.`); } catch (e) { Toast.error(`Failed to add tag(s) to some workflows. ${e}`); } finally { bulkTagsLoading.value = false; selectedWorkflowIds.value = tmpSelected; await load(true); } } watch([filterText, sortBy, sortDesc], async () => { offset.value = 0; selectedWorkflowIds.value = []; await load(true); }); Loading Loading @@ -236,7 +400,14 @@ onMounted(() => { </template> </FilterMenu> <ListHeader ref="listHeader" show-view-toggle> <ListHeader ref="listHeader" show-view-toggle :show-select-all="!published && !sharedWithMe" :select-all-disabled="loading || overlay || noItems || noResults" :all-selected="allSelected" :indeterminate-selected="indeterminateSelected" @select-all="onSelectAllWorkflows"> <template v-slot:extra-filter> <div v-if="activeList === 'my'"> Filter: Loading Loading @@ -268,16 +439,17 @@ onMounted(() => { </ListHeader> </div> <BAlert v-if="loading" variant="info" show> <LoadingSpan message="Loading workflows..." /> <div v-if="loading" class="workflow-list-alert"> <BAlert variant="info" show> <LoadingSpan message="Loading workflows" /> </BAlert> <BAlert v-if="!loading && !overlay && noItems" id="workflow-list-empty" variant="info" show> </div> <div v-else-if="!loading && !overlay && noItems" class="workflow-list-alert"> <BAlert id="workflow-list-empty" variant="info" show> No workflows found. You may create or import new workflows using the buttons above. </BAlert> <!-- There are either `noResults` or `invalidFilters` --> <span v-else-if="!loading && !overlay && (noResults || hasInvalidFilters)"> </div> <span v-else-if="!loading && !overlay && (noResults || hasInvalidFilters)" class="workflow-list-alert"> <BAlert v-if="!hasInvalidFilters" id="no-workflow-found" variant="info" show> No workflows found matching: <span class="font-weight-bold">{{ filterText }}</span> </BAlert> Loading @@ -304,27 +476,89 @@ onMounted(() => { </a> </BAlert> </span> <BOverlay v-else id="workflow-cards" :show="overlay" rounded="sm" class="cards-list mt-2"> <BOverlay v-else id="workflow-cards" :show="overlay" rounded="sm" class="cards-list"> <WorkflowCardList :workflows="workflowsLoaded" :published-view="published" :grid-view="view === 'grid'" :selected-workflow-ids="selectedWorkflowIds" @select="onSelectWorkflow" @refreshList="load" @tagClick="(tag) => updateFilterValue('tag', `'${tag}'`)" @updateFilter="updateFilterValue" /> </BOverlay> <div class="workflow-list-footer"> <div v-if="!published && !sharedWithMe && selectedWorkflowIds.length" class="workflow-list-footer-bulk-actions"> <BButton v-if="!showDeleted" id="workflow-list-footer-bulk-delete-button" v-b-tooltip.hover :title="bulkDeleteOrRestoreLoading ? 'Deleting workflows' : 'Delete selected workflows'" :disabled="bulkDeleteOrRestoreLoading" size="sm" variant="primary" @click="onBulkDelete"> <span v-if="!bulkDeleteOrRestoreLoading"> <FontAwesomeIcon :icon="faTrash" fixed-width /> Delete ({{ selectedWorkflowIds.length }}) </span> <LoadingSpan v-else message="Deleting" /> </BButton> <BButton v-else id="workflow-list-footer-bulk-restore-button" v-b-tooltip.hover :title="bulkDeleteOrRestoreLoading ? 'Restoring workflows' : 'Restore selected workflows'" :disabled="bulkDeleteOrRestoreLoading" size="sm" variant="primary" @click="onBulkRestore"> <span v-if="!bulkDeleteOrRestoreLoading"> <FontAwesomeIcon :icon="faTrashRestore" fixed-width /> Restore ({{ selectedWorkflowIds.length }}) </span> <LoadingSpan v-else message="Restoring" /> </BButton> <BButton v-if="!showDeleted" id="workflow-list-footer-bulk-add-tags-button" v-b-tooltip.hover :title="bulkTagsLoading ? 'Adding tags' : 'Add tags to selected workflows'" :disabled="bulkTagsLoading" size="sm" variant="primary" @click="onToggleBulkTags"> <span v-if="!bulkTagsLoading"> <FontAwesomeIcon :icon="faTags" fixed-width /> Add tags ({{ selectedWorkflowIds.length }}) </span> <LoadingSpan v-else message="Adding tags" /> </BButton> </div> <BPagination v-if="!loading && totalWorkflows > limit" class="mt-2 w-100" class="workflow-list-footer-pagination" :value="currentPage" :total-rows="totalWorkflows" :per-page="limit" align="center" align="right" size="sm" first-number last-number @change="onPageChange" /> </BOverlay> </div> <TagsSelectionDialog v-if="showBulkAddTagsModal" :title="`Add tags to ${selectedWorkflowIds.length} selected workflow${ selectedWorkflowIds.length > 1 ? 's' : '' }`" @cancel="onToggleBulkTags" @ok="onBulkTagsAdd" /> </div> </template> Loading @@ -345,7 +579,12 @@ onMounted(() => { z-index: 100; } .workflow-list-alert { height: 100%; } .cards-list { height: 100%; scroll-behavior: smooth; min-height: 150px; display: flex; Loading @@ -354,5 +593,23 @@ onMounted(() => { overflow-y: auto; overflow-x: hidden; } .workflow-list-footer { display: flex; align-items: center; margin-top: 0.5rem; .workflow-list-footer-bulk-actions { display: flex; gap: 0.5rem; width: 100%; position: absolute; } .workflow-list-footer-pagination { margin: 0 auto; width: 100%; } } } </style>