Unverified Commit f18ff059 authored by Marius van den Beek's avatar Marius van den Beek Committed by GitHub
Browse files

Merge pull request #19336 from itisAliRH/workflow-bulk-actions

Add workflow selection and bulk actions
parents 7278a5ea 36ec13da
Loading
Loading
Loading
Loading
+56 −28
Original line number Diff line number Diff line
@@ -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";
@@ -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);
@@ -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
@@ -74,6 +101,7 @@ defineExpose({
                        Update time
                    </BButton>
                </BButtonGroup>
            </div>

            <slot name="extra-filter" />
        </div>
@@ -115,7 +143,7 @@ defineExpose({

    .list-header-filters {
        display: flex;
        gap: 0.25rem;
        gap: 1rem;
        flex-wrap: wrap;
        align-items: center;
    }
+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>
+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";

@@ -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>(), {
@@ -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;
@@ -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;
    }
@@ -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"
@@ -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
@@ -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;

@@ -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 {
+28 −10
Original line number Diff line number Diff line
@@ -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;
@@ -39,6 +49,7 @@ const modalOptions = reactive({
});

const showRename = ref(false);
const showPreview = ref(false);

function onRenameClose() {
    showRename.value = false;
@@ -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;
@@ -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"
@@ -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)"
@@ -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;
@@ -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}) {
@@ -142,4 +159,5 @@ function onInsertSteps(workflow: Workflow) {
            }
        }
    }
}
</style>
+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";
@@ -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";
}
@@ -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);
@@ -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";
@@ -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))
@@ -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;
@@ -107,7 +123,9 @@ async function load(overlayLoading = false, silent = false) {
            loading.value = true;
        }
    }

    let search;

    if (!hasInvalidFilters.value) {
        search = validatedFilterText();

@@ -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);
});

@@ -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:
@@ -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>
@@ -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>

@@ -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;
@@ -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>