Repository: kestra-io/kestra
Issue: Button Rework - "Mass Action"
Labels: frontend

### Feature description

Following #1003 issue, we want to change the positions of "mass action" buttons.

👉 Move them in the top of tables. When the user selects multiple elements, buttons should appear on the headers

See @Nico-Kestra prototypes in comment

Issue comments:
When I do mass actions the buttons replace the content of the first line. This saves space for positioning the buttons, like on Notion or crowd.
<img width="1668" alt="Capture d’écran 2023-05-19 à 09 35 24" src="https://github.com/kestra-io/kestra/assets/125994028/efb7c723-b1ce-4732-b93e-c65bed62bbc9">
[Figma link](https://www.figma.com/file/4tLhsoCsh9yVzYRY1N70kS/App-Kestra?type=design&node-id=843%3A3633&t=LkChqhww36cq4mBn-1)

Pull request review comments:

Patch that resolved the issue:
diff --git a/ui/src/components/executions/Executions.vue b/ui/src/components/executions/Executions.vue
index 1a6364437d..0a1d4b1e7e 100644
--- a/ui/src/components/executions/Executions.vue
+++ b/ui/src/components/executions/Executions.vue
@@ -49,9 +49,9 @@
             </template>
 
             <template #table>
-                <el-table
+                <select-table
+                    ref="selectTable"
                     :data="executions"
-                    ref="table"
                     :default-sort="{prop: 'state.startDate', order: 'descending'}"
                     stripe
                     table-layout="auto"
@@ -59,93 +59,116 @@
                     @row-dblclick="onRowDoubleClick"
                     @sort-change="onSort"
                     @selection-change="handleSelectionChange"
+                    :selectable="!hidden.includes('selection') && canCheck"
                 >
-                    <el-table-column type="selection" v-if="!hidden.includes('selection') && (canCheck)" />
+                    <template #select-actions>
+                        <bulk-select
+                            :select-all="queryBulkAction"
+                            :selections="selection"
+                            :total="total"
+                            @update:select-all="toggleAllSelection"
+                            @unselect="toggleAllUnselected"
+                        >
+                            <el-button v-if="canUpdate" :icon="Restart" @click="restartExecutions()">
+                                {{ $t('restart') }}
+                            </el-button>
+                            <el-button v-if="canUpdate" :icon="StopCircleOutline" @click="killExecutions()">
+                                {{ $t('kill') }}
+                            </el-button>
+                            <el-button v-if="canDelete" :icon="Delete" type="default" @click="deleteExecutions()">
+                                {{ $t('delete') }}
+                            </el-button>
+                        </bulk-select>
+                    </template>
+                    <template #default>
+                        <el-table-column prop="id" v-if="!hidden.includes('id')" sortable="custom"
+                                         :sort-orders="['ascending', 'descending']" :label="$t('id')">
+                            <template #default="scope">
+                                <id :value="scope.row.id" :shrink="true" />
+                            </template>
+                        </el-table-column>
 
-                    <el-table-column prop="id" v-if="!hidden.includes('id')" sortable="custom" :sort-orders="['ascending', 'descending']" :label="$t('id')">
-                        <template #default="scope">
-                            <id :value="scope.row.id" :shrink="true" />
-                        </template>
-                    </el-table-column>
+                        <el-table-column prop="state.startDate" v-if="!hidden.includes('state.startDate')"
+                                         sortable="custom"
+                                         :sort-orders="['ascending', 'descending']" :label="$t('start date')">
+                            <template #default="scope">
+                                <date-ago :inverted="true" :date="scope.row.state.startDate" />
+                            </template>
+                        </el-table-column>
 
-                    <el-table-column prop="state.startDate" v-if="!hidden.includes('state.startDate')" sortable="custom" :sort-orders="['ascending', 'descending']" :label="$t('start date')">
-                        <template #default="scope">
-                            <date-ago :inverted="true" :date="scope.row.state.startDate" />
-                        </template>
-                    </el-table-column>
+                        <el-table-column prop="state.endDate" v-if="!hidden.includes('state.endDate')" sortable="custom"
+                                         :sort-orders="['ascending', 'descending']" :label="$t('end date')">
+                            <template #default="scope">
+                                <date-ago :inverted="true" :date="scope.row.state.endDate" />
+                            </template>
+                        </el-table-column>
 
-                    <el-table-column prop="state.endDate" v-if="!hidden.includes('state.endDate')" sortable="custom" :sort-orders="['ascending', 'descending']" :label="$t('end date')">
-                        <template #default="scope">
-                            <date-ago :inverted="true" :date="scope.row.state.endDate" />
-                        </template>
-                    </el-table-column>
+                        <el-table-column prop="state.duration" v-if="!hidden.includes('state.duration')"
+                                         sortable="custom"
+                                         :sort-orders="['ascending', 'descending']" :label="$t('duration')">
+                            <template #default="scope">
+                                <span v-if="isRunning(scope.row)">{{
+                                        $filters.humanizeDuration(durationFrom(scope.row))
+                                    }}</span>
+                                <span v-else>{{ $filters.humanizeDuration(scope.row.state.duration) }}</span>
+                            </template>
+                        </el-table-column>
 
-                    <el-table-column prop="state.duration" v-if="!hidden.includes('state.duration')" sortable="custom" :sort-orders="['ascending', 'descending']" :label="$t('duration')">
-                        <template #default="scope">
-                            <span v-if="isRunning(scope.row)">{{ $filters.humanizeDuration(durationFrom(scope.row)) }}</span>
-                            <span v-else>{{ $filters.humanizeDuration(scope.row.state.duration) }}</span>
-                        </template>
-                    </el-table-column>
+                        <el-table-column v-if="$route.name !== 'flows/update' && !hidden.includes('namespace')"
+                                         prop="namespace"
+                                         sortable="custom" :sort-orders="['ascending', 'descending']"
+                                         :label="$t('namespace')"
+                                         :formatter="(_, __, cellValue) => $filters.invisibleSpace(cellValue)" />
 
-                    <el-table-column v-if="$route.name !== 'flows/update' && !hidden.includes('namespace')" prop="namespace" sortable="custom" :sort-orders="['ascending', 'descending']" :label="$t('namespace')" :formatter="(_, __, cellValue) => $filters.invisibleSpace(cellValue)" />
+                        <el-table-column v-if="$route.name !== 'flows/update' && !hidden.includes('flowId')"
+                                         prop="flowId"
+                                         sortable="custom" :sort-orders="['ascending', 'descending']"
+                                         :label="$t('flow')">
+                            <template #default="scope">
+                                <router-link
+                                    :to="{name: 'flows/update', params: {namespace: scope.row.namespace, id: scope.row.flowId}}">
+                                    {{ $filters.invisibleSpace(scope.row.flowId) }}
+                                </router-link>
+                            </template>
+                        </el-table-column>
 
-                    <el-table-column v-if="$route.name !== 'flows/update' && !hidden.includes('flowId')" prop="flowId" sortable="custom" :sort-orders="['ascending', 'descending']" :label="$t('flow')">
-                        <template #default="scope">
-                            <router-link :to="{name: 'flows/update', params: {namespace: scope.row.namespace, id: scope.row.flowId}}">
-                                {{ $filters.invisibleSpace(scope.row.flowId) }}
-                            </router-link>
-                        </template>
-                    </el-table-column>
+                        <el-table-column v-if="!hidden.includes('labels')" :label="$t('labels')">
+                            <template #default="scope">
+                                <labels :labels="scope.row.labels" />
+                            </template>
+                        </el-table-column>
 
-                    <el-table-column v-if="!hidden.includes('labels')" :label="$t('labels')">
-                        <template #default="scope">
-                            <labels :labels="scope.row.labels" />
-                        </template>
-                    </el-table-column>
+                        <el-table-column prop="state.current" v-if="!hidden.includes('state.current')" sortable="custom"
+                                         :sort-orders="['ascending', 'descending']" :label="$t('state')">
+                            <template #default="scope">
+                                <status :status="scope.row.state.current" size="small" />
+                            </template>
+                        </el-table-column>
 
-                    <el-table-column prop="state.current" v-if="!hidden.includes('state.current')" sortable="custom" :sort-orders="['ascending', 'descending']" :label="$t('state')">
-                        <template #default="scope">
-                            <status :status="scope.row.state.current" size="small" />
-                        </template>
-                    </el-table-column>
+                        <el-table-column prop="triggers" v-if="!hidden.includes('triggers')" :label="$t('triggers')"
+                                         class-name="shrink">
+                            <template #default="scope">
+                                <trigger-avatar :execution="scope.row" />
+                            </template>
+                        </el-table-column>
 
-                    <el-table-column prop="triggers" v-if="!hidden.includes('triggers')" :label="$t('triggers')" class-name="shrink">
-                        <template #default="scope">
-                            <trigger-avatar :execution="scope.row" />
-                        </template>
-                    </el-table-column>
-
-                    <el-table-column column-key="action" class-name="row-action">
-                        <template #default="scope">
-                            <router-link :to="{name: 'executions/update', params: {namespace: scope.row.namespace, flowId: scope.row.flowId, id: scope.row.id}}">
-                                <kicon :tooltip="$t('details')" placement="left">
-                                    <eye />
-                                </kicon>
-                            </router-link>
-                        </template>
-                    </el-table-column>
-                </el-table>
+                        <el-table-column column-key="action" class-name="row-action">
+                            <template #default="scope">
+                                <router-link
+                                    :to="{name: 'executions/update', params: {namespace: scope.row.namespace, flowId: scope.row.flowId, id: scope.row.id}}">
+                                    <kicon :tooltip="$t('details')" placement="left">
+                                        <eye />
+                                    </kicon>
+                                </router-link>
+                            </template>
+                        </el-table-column>
+                    </template>
+                </select-table>
             </template>
         </data-table>
 
         <bottom-line v-if="displayBottomBar">
             <ul>
-                <li v-if="executionsSelection.length !== 0 && (canUpdate || canDelete)">
-                    <bottom-line-counter v-model="queryBulkAction" :selections="executionsSelection" :total="total" @update:model-value="selectAll()">
-                        <el-button v-if="canUpdate" :icon="Restart" size="large" @click="restartExecutions()">
-                            {{ $t('restart') }}
-                        </el-button>
-                        <el-button v-if="canUpdate" :icon="StopCircleOutline" size="large" @click="killExecutions()">
-                            {{ $t('kill') }}
-                        </el-button>
-                        <el-button v-if="canDelete" :icon="Delete" size="large" type="default" @click="deleteExecutions()">
-                            {{ $t('delete') }}
-                        </el-button>
-                    </bottom-line-counter>
-                </li>
-
-                <li class="spacer" />
-
                 <template v-if="$route.name === 'flows/update'">
                     <li>
                         <template v-if="isAllowedEdit">
@@ -155,7 +178,8 @@
                         </template>
                     </li>
                     <li>
-                        <trigger-flow v-if="flow" :disabled="flow.disabled" :flow-id="flow.id" :namespace="flow.namespace" />
+                        <trigger-flow v-if="flow" :disabled="flow.disabled" :flow-id="flow.id"
+                                      :namespace="flow.namespace" />
                     </li>
                 </template>
             </ul>
@@ -164,6 +188,8 @@
 </template>
 
 <script setup>
+    import BulkSelect from "../layout/BulkSelect.vue";
+    import SelectTable from "../layout/SelectTable.vue";
     import Restart from "vue-material-design-icons/Restart.vue";
     import Delete from "vue-material-design-icons/Delete.vue";
     import StopCircleOutline from "vue-material-design-icons/StopCircleOutline.vue";
@@ -178,6 +204,7 @@
     import Status from "../Status.vue";
     import RouteContext from "../../mixins/routeContext";
     import DataTableActions from "../../mixins/dataTableActions";
+    import SelectTableActions from "../../mixins/selectTableActions";
     import SearchField from "../layout/SearchField.vue";
     import NamespaceSelect from "../namespace/NamespaceSelect.vue";
     import LabelFilter from "../labels/LabelFilter.vue";
@@ -194,13 +221,12 @@
     import Id from "../Id.vue";
     import _merge from "lodash/merge";
     import BottomLine from "../layout/BottomLine.vue";
-    import BottomLineCounter from "../layout/BottomLineCounter.vue";
     import permission from "../../models/permission";
     import action from "../../models/action";
     import TriggerFlow from "../../components/flows/TriggerFlow.vue";
 
     export default {
-        mixins: [RouteContext, RestoreUrl, DataTableActions],
+        mixins: [RouteContext, RestoreUrl, DataTableActions, SelectTableActions],
         components: {
             Status,
             Eye,
@@ -218,7 +244,6 @@
             Labels,
             Id,
             BottomLine,
-            BottomLineCounter,
             TriggerFlow
         },
         props: {
@@ -240,9 +265,7 @@
                 isDefaultNamespaceAllow: true,
                 dailyReady: false,
                 dblClickRouteName: "executions/update",
-                flowTriggerDetails: undefined,
-                executionsSelection: [],
-                queryBulkAction: false
+                flowTriggerDetails: undefined
             };
         },
         computed: {
@@ -263,8 +286,7 @@
                     .add(-30, "days").toISOString(true);
             },
             displayBottomBar() {
-                return (this.executionsSelection.length !== 0 && (this.canUpdate || this.canDelete)) ||
-                    (this.$route.name === "flows/update");
+                return (this.$route.name === "flows/update");
             },
             canCheck() {
                 return this.canDelete || this.canUpdate;
@@ -280,18 +302,10 @@
             },
         },
         methods: {
-            handleSelectionChange(val) {
-                if (val.length === 0) {
-                    this.queryBulkAction = false
-                }
-                this.executionsSelection = val.map(x => x.id);
+            selectionMapper(execution) {
+                return execution.id
             },
-            selectAll() {
-                if (this.$refs.table.getSelectionRows().length !== this.$refs.table.data.length) {
-                    this.$refs.table.toggleAllSelection();
-                }
-            },
-            isRunning(item){
+            isRunning(item) {
                 return State.isRunning(item.state.current);
             },
             onStatusChange() {
@@ -338,7 +352,7 @@
             },
             restartExecutions() {
                 this.$toast().confirm(
-                    this.$t("bulk restart", {"executionCount": this.queryBulkAction ? this.total : this.executionsSelection.length}),
+                    this.$t("bulk restart", {"executionCount": this.queryBulkAction ? this.total : this.selection.length}),
                     () => {
                         if (this.queryBulkAction) {
                             return this.$store
@@ -352,7 +366,7 @@
                                 })
                         } else {
                             return this.$store
-                                .dispatch("execution/bulkRestartExecution", {executionsId: this.executionsSelection})
+                                .dispatch("execution/bulkRestartExecution", {executionsId: this.selection})
                                 .then(r => {
                                     this.$toast().success(this.$t("executions restarted", {executionCount: r.data.count}));
                                     this.loadData();
@@ -361,12 +375,13 @@
                                 }), this.$t(e.message)))
                         }
                     },
-                    () => {}
+                    () => {
+                    }
                 )
             },
             deleteExecutions() {
                 this.$toast().confirm(
-                    this.$t("bulk delete", {"executionCount": this.queryBulkAction ? this.total : this.executionsSelection.length}),
+                    this.$t("bulk delete", {"executionCount": this.queryBulkAction ? this.total : this.selection.length}),
                     () => {
                         if (this.queryBulkAction) {
                             return this.$store
@@ -380,7 +395,7 @@
                                 })
                         } else {
                             return this.$store
-                                .dispatch("execution/bulkDeleteExecution", {executionsId: this.executionsSelection})
+                                .dispatch("execution/bulkDeleteExecution", {executionsId: this.selection})
                                 .then(r => {
                                     this.$toast().success(this.$t("executions deleted", {executionCount: r.data.count}));
                                     this.loadData();
@@ -389,12 +404,13 @@
                                 }), this.$t(e.message)))
                         }
                     },
-                    () => {}
+                    () => {
+                    }
                 )
             },
             killExecutions() {
                 this.$toast().confirm(
-                    this.$t("bulk kill", {"executionCount": this.queryBulkAction ? this.total : this.executionsSelection.length}),
+                    this.$t("bulk kill", {"executionCount": this.queryBulkAction ? this.total : this.selection.length}),
                     () => {
                         if (this.queryBulkAction) {
                             return this.$store
@@ -408,7 +424,7 @@
                                 })
                         } else {
                             return this.$store
-                                .dispatch("execution/bulkKill", {executionsId: this.executionsSelection})
+                                .dispatch("execution/bulkKill", {executionsId: this.selection})
                                 .then(r => {
                                     this.$toast().success(this.$t("executions killed", {executionCount: r.data.count}));
                                     this.loadData();
@@ -417,15 +433,18 @@
                                 }), this.$t(e.message)))
                         }
                     },
-                    () => {}
+                    () => {
+                    }
                 )
             },
             editFlow() {
-                this.$router.push({name:"flows/update", params: {
-                    namespace: this.flow.namespace,
-                    id: this.flow.id,
-                    tab: "editor"
-                }})
+                this.$router.push({
+                    name: "flows/update", params: {
+                        namespace: this.flow.namespace,
+                        id: this.flow.id,
+                        tab: "editor"
+                    }
+                })
             },
         }
     };
diff --git a/ui/src/components/flows/Flows.vue b/ui/src/components/flows/Flows.vue
index 711e0f02ad..8bc5279482 100644
--- a/ui/src/components/flows/Flows.vue
+++ b/ui/src/components/flows/Flows.vue
@@ -35,9 +35,9 @@
                 </template>
 
                 <template #table>
-                    <el-table
+                    <select-table
+                        ref="selectTable"
                         :data="flows"
-                        ref="table"
                         :default-sort="{prop: 'id', order: 'ascending'}"
                         stripe
                         table-layout="auto"
@@ -46,89 +46,99 @@
                         @sort-change="onSort"
                         :row-class-name="rowClasses"
                         @selection-change="handleSelectionChange"
+                        :selectable="canCheck"
                     >
-                        <el-table-column type="selection" v-if="(canCheck)" />
-                        <el-table-column prop="id" sortable="custom" :sort-orders="['ascending', 'descending']" :label="$t('id')">
-                            <template #default="scope">
-                                <router-link
-                                    :to="{name: 'flows/update', params: {namespace: scope.row.namespace, id: scope.row.id}}"
-                                >
-                                    {{ $filters.invisibleSpace(scope.row.id) }}
-                                </router-link>
-                                &nbsp;<markdown-tooltip
+                        <template #select-actions>
+                            <bulk-select
+                                :select-all="queryBulkAction"
+                                :selections="selection"
+                                :total="total"
+                                @update:select-all="toggleAllSelection"
+                                @unselect="toggleAllUnselected"
+                            >
+                                <el-button v-if="canRead" :icon="Download" @click="exportFlows()">
+                                    {{ $t('export') }}
+                                </el-button>
+                                <el-button v-if="canDelete" @click="deleteFlows" :icon="TrashCan">
+                                    {{ $t('delete') }}
+                                </el-button>
+                                <el-button v-if="canUpdate" @click="enableFlows" :icon="FileDocumentCheckOutline">
+                                    {{ $t('enable') }}
+                                </el-button>
+                                <el-button v-if="canUpdate" @click="disableFlows" :icon="FileDocumentRemoveOutline">
+                                    {{ $t('disable') }}
+                                </el-button>
+                            </bulk-select>
+                        </template>
+                        <template #default>
+                            <el-table-column prop="id" sortable="custom" :sort-orders="['ascending', 'descending']"
+                                             :label="$t('id')">
+                                <template #default="scope">
+                                    <router-link
+                                        :to="{name: 'flows/update', params: {namespace: scope.row.namespace, id: scope.row.id}}"
+                                    >
+                                        {{ $filters.invisibleSpace(scope.row.id) }}
+                                    </router-link>
+                                    &nbsp;<markdown-tooltip
                                     :id="scope.row.namespace + '-' + scope.row.id"
                                     :description="scope.row.description"
                                     :title="scope.row.namespace + '.' + scope.row.id"
                                 />
-                            </template>
-                        </el-table-column>
+                                </template>
+                            </el-table-column>
 
-                        <el-table-column :label="$t('labels')">
-                            <template #default="scope">
-                                <labels :labels="scope.row.labels" />
-                            </template>
-                        </el-table-column>
+                            <el-table-column :label="$t('labels')">
+                                <template #default="scope">
+                                    <labels :labels="scope.row.labels" />
+                                </template>
+                            </el-table-column>
 
-                        <el-table-column prop="namespace" sortable="custom" :sort-orders="['ascending', 'descending']" :label="$t('namespace')" :formatter="(_, __, cellValue) => $filters.invisibleSpace(cellValue)" />
+                            <el-table-column prop="namespace" sortable="custom"
+                                             :sort-orders="['ascending', 'descending']"
+                                             :label="$t('namespace')"
+                                             :formatter="(_, __, cellValue) => $filters.invisibleSpace(cellValue)" />
 
-                        <el-table-column
-                            prop="state"
-                            :label="$t('execution statistics')"
-                            v-if="user.hasAny(permission.EXECUTION)"
-                            class-name="row-graph"
-                        >
-                            <template #default="scope">
-                                <state-chart
-                                    :duration="true"
-                                    :namespace="scope.row.namespace"
-                                    :flow-id="scope.row.id"
-                                    v-if="dailyGroupByFlowReady"
-                                    :data="chartData(scope.row)"
-                                />
-                            </template>
-                        </el-table-column>
+                            <el-table-column
+                                prop="state"
+                                :label="$t('execution statistics')"
+                                v-if="user.hasAny(permission.EXECUTION)"
+                                class-name="row-graph"
+                            >
+                                <template #default="scope">
+                                    <state-chart
+                                        :duration="true"
+                                        :namespace="scope.row.namespace"
+                                        :flow-id="scope.row.id"
+                                        v-if="dailyGroupByFlowReady"
+                                        :data="chartData(scope.row)"
+                                    />
+                                </template>
+                            </el-table-column>
 
-                        <el-table-column :label="$t('triggers')" class-name="row-action">
-                            <template #default="scope">
-                                <trigger-avatar :flow="scope.row" />
-                            </template>
-                        </el-table-column>
+                            <el-table-column :label="$t('triggers')" class-name="row-action">
+                                <template #default="scope">
+                                    <trigger-avatar :flow="scope.row" />
+                                </template>
+                            </el-table-column>
 
-                        <el-table-column column-key="action" class-name="row-action">
-                            <template #default="scope">
-                                <router-link :to="{name: 'flows/update', params : {namespace: scope.row.namespace, id: scope.row.id}}">
-                                    <kicon :tooltip="$t('details')" placement="left">
-                                        <eye />
-                                    </kicon>
-                                </router-link>
-                            </template>
-                        </el-table-column>
-                    </el-table>
+                            <el-table-column column-key="action" class-name="row-action">
+                                <template #default="scope">
+                                    <router-link
+                                        :to="{name: 'flows/update', params : {namespace: scope.row.namespace, id: scope.row.id}}">
+                                        <kicon :tooltip="$t('details')" placement="left">
+                                            <eye />
+                                        </kicon>
+                                    </router-link>
+                                </template>
+                            </el-table-column>
+                        </template>
+                    </select-table>
                 </template>
             </data-table>
         </div>
 
         <bottom-line>
             <ul>
-                <li>
-                    <ul v-if="flowsSelection.length !== 0 && canRead">
-                        <bottom-line-counter v-model="queryBulkAction" :selections="flowsSelection" :total="total" @update:model-value="selectAll()">
-                            <el-button v-if="canRead" :icon="Download" size="large" @click="exportFlows()">
-                                {{ $t('export') }}
-                            </el-button>
-                            <el-button v-if="canDelete" @click="deleteFlows" size="large" :icon="TrashCan">
-                                {{ $t('delete') }}
-                            </el-button>
-                            <el-button v-if="canUpdate" @click="enableFlows" size="large" :icon="FileDocumentCheckOutline">
-                                {{ $t('enable') }}
-                            </el-button>
-                            <el-button v-if="canUpdate" @click="disableFlows" size="large" :icon="FileDocumentRemoveOutline">
-                                {{ $t('disable') }}
-                            </el-button>
-                        </bottom-line-counter>
-                    </ul>
-                </li>
-                <li class="spacer" />
                 <li>
                     <div class="el-input el-input-file el-input--large custom-upload">
                         <div class="el-input__wrapper">
@@ -166,6 +176,8 @@
 </template>
 
 <script setup>
+    import BulkSelect from "../layout/BulkSelect.vue";
+    import SelectTable from "../layout/SelectTable.vue";
     import Plus from "vue-material-design-icons/Plus.vue";
     import TextBoxSearch from "vue-material-design-icons/TextBoxSearch.vue";
     import Download from "vue-material-design-icons/Download.vue";
@@ -184,6 +196,7 @@
     import BottomLine from "../layout/BottomLine.vue";
     import RouteContext from "../../mixins/routeContext";
     import DataTableActions from "../../mixins/dataTableActions";
+    import SelectTableActions from "../../mixins/selectTableActions";
     import RestoreUrl from "../../mixins/restoreUrl";
     import DataTable from "../layout/DataTable.vue";
     import SearchField from "../layout/SearchField.vue";
@@ -193,12 +206,11 @@
     import MarkdownTooltip from "../layout/MarkdownTooltip.vue"
     import Kicon from "../Kicon.vue"
     import Labels from "../layout/Labels.vue"
-    import BottomLineCounter from "../layout/BottomLineCounter.vue";
     import Upload from "vue-material-design-icons/Upload.vue";
     import LabelFilter from "../labels/LabelFilter.vue";
 
     export default {
-        mixins: [RouteContext, RestoreUrl, DataTableActions],
+        mixins: [RouteContext, RestoreUrl, DataTableActions, SelectTableActions],
         components: {
             NamespaceSelect,
             BottomLine,
@@ -211,7 +223,6 @@
             MarkdownTooltip,
             Kicon,
             Labels,
-            BottomLineCounter,
             Upload,
             LabelFilter
         },
@@ -222,8 +233,6 @@
                 action: action,
                 dailyGroupByFlowReady: false,
                 dailyReady: false,
-                flowsSelection: [],
-                queryBulkAction: false,
                 file: undefined,
             };
         },
@@ -255,28 +264,18 @@
             },
             canUpdate() {
                 return this.user && this.user.isAllowed(permission.FLOW, action.UPDATE, this.$route.query.namespace);
-            },
+            }
         },
         methods: {
-            handleSelectionChange(val) {
-                if (val.length === 0) {
-                    this.queryBulkAction = false
-                }
-                this.flowsSelection = val.map(x => {
-                    return {
-                        id: x.id,
-                        namespace: x.namespace
-                    }
-                });
-            },
-            selectAll() {
-                if (this.$refs.table.getSelectionRows().length !== this.$refs.table.data.length) {
-                    this.$refs.table.toggleAllSelection();
+            selectionMapper(element) {
+                return {
+                    id: element.id,
+                    namespace: element.namespace
                 }
             },
             exportFlows() {
                 this.$toast().confirm(
-                    this.$t("flow export", {"flowCount": this.queryBulkAction ? this.total : this.flowsSelection.length}),
+                    this.$t("flow export", {"flowCount": this.queryBulkAction ? this.total : this.selection.length}),
                     () => {
                         if (this.queryBulkAction) {
                             return this.$store
@@ -289,18 +288,19 @@
                                 })
                         } else {
                             return this.$store
-                                .dispatch("flow/exportFlowByIds", {ids: this.flowsSelection})
+                                .dispatch("flow/exportFlowByIds", {ids: this.selection})
                                 .then(_ => {
                                     this.$toast().success(this.$t("flows exported"));
                                 })
                         }
                     },
-                    () => {}
+                    () => {
+                    }
                 )
             },
-            disableFlows(){
+            disableFlows() {
                 this.$toast().confirm(
-                    this.$t("flow disable", {"flowCount": this.queryBulkAction ? this.total : this.flowsSelection.length}),
+                    this.$t("flow disable", {"flowCount": this.queryBulkAction ? this.total : this.selection.length}),
                     () => {
                         if (this.queryBulkAction) {
                             return this.$store
@@ -310,23 +310,26 @@
                                 }, false))
                                 .then(r => {
                                     this.$toast().success(this.$t("flows disabled", {count: r.data.count}));
-                                    this.loadData(() => {})
+                                    this.loadData(() => {
+                                    })
                                 })
                         } else {
                             return this.$store
-                                .dispatch("flow/disableFlowByIds", {ids: this.flowsSelection})
+                                .dispatch("flow/disableFlowByIds", {ids: this.selection})
                                 .then(r => {
                                     this.$toast().success(this.$t("flows disabled", {count: r.data.count}));
-                                    this.loadData(() => {})
+                                    this.loadData(() => {
+                                    })
                                 })
                         }
                     },
-                    () => {}
+                    () => {
+                    }
                 )
             },
-            enableFlows(){
+            enableFlows() {
                 this.$toast().confirm(
-                    this.$t("flow enable", {"flowCount": this.queryBulkAction ? this.total : this.flowsSelection.length}),
+                    this.$t("flow enable", {"flowCount": this.queryBulkAction ? this.total : this.selection.length}),
                     () => {
                         if (this.queryBulkAction) {
                             return this.$store
@@ -336,23 +339,26 @@
                                 }, false))
                                 .then(r => {
                                     this.$toast().success(this.$t("flows enabled", {count: r.data.count}));
-                                    this.loadData(() => {})
+                                    this.loadData(() => {
+                                    })
                                 })
                         } else {
                             return this.$store
-                                .dispatch("flow/enableFlowByIds", {ids: this.flowsSelection})
+                                .dispatch("flow/enableFlowByIds", {ids: this.selection})
                                 .then(r => {
                                     this.$toast().success(this.$t("flows enabled", {count: r.data.count}));
-                                    this.loadData(() => {})
+                                    this.loadData(() => {
+                                    })
                                 })
                         }
                     },
-                    () => {}
+                    () => {
+                    }
                 )
             },
-            deleteFlows(){
+            deleteFlows() {
                 this.$toast().confirm(
-                    this.$t("flow delete", {"flowCount": this.queryBulkAction ? this.total : this.flowsSelection.length}),
+                    this.$t("flow delete", {"flowCount": this.queryBulkAction ? this.total : this.selection.length}),
                     () => {
                         if (this.queryBulkAction) {
                             return this.$store
@@ -362,18 +368,21 @@
                                 }, false))
                                 .then(r => {
                                     this.$toast().success(this.$t("flows deleted", {count: r.data.count}));
-                                    this.loadData(() => {})
+                                    this.loadData(() => {
+                                    })
                                 })
                         } else {
                             return this.$store
-                                .dispatch("flow/deleteFlowByIds", {ids: this.flowsSelection})
+                                .dispatch("flow/deleteFlowByIds", {ids: this.selection})
                                 .then(r => {
                                     this.$toast().success(this.$t("flows deleted", {count: r.data.count}));
-                                    this.loadData(() => {})
+                                    this.loadData(() => {
+                                    })
                                 })
                         }
                     },
-                    () => {}
+                    () => {
+                    }
                 )
             },
             importFlows() {
@@ -383,7 +392,8 @@
                     .dispatch("flow/importFlows", formData)
                     .then(_ => {
                         this.$toast().success(this.$t("flows imported"));
-                        this.loadData(() => {})
+                        this.loadData(() => {
+                        })
                     })
             },
             chartData(row) {
diff --git a/ui/src/components/layout/BottomLineCounter.vue b/ui/src/components/layout/BottomLineCounter.vue
deleted file mode 100644
index c7e51116c7..0000000000
--- a/ui/src/components/layout/BottomLineCounter.vue
+++ /dev/null
@@ -1,37 +0,0 @@
-<template>
-    <el-button-group>
-        <el-button size="large" @click="all()">
-            <span v-html="$t('selection.selected', {count: modelValue ? total : selections.length})" />
-
-            <span class="counter" v-if="selections.length<total && !modelValue" v-html="$t('selection.all', {count: total})" />
-        </el-button>
-        <slot />
-    </el-button-group>
-</template>
-<script>
-    export default {
-        props: {
-            total: {type: Number, required: true},
-            selections: {type: Array, required: true},
-            modelValue: {type: Boolean, required: true},
-        },
-        emits: ["update:modelValue"],
-        methods: {
-            all() {
-                this.$emit("update:modelValue", true);
-            }
-        }
-    }
-</script>
-<style lang="scss" scoped>
-    span {
-        color: var(--bs-tertiary);
-    }
-
-    span.counter {
-        border-left: 1px solid var(--bs-gray-100-darken-5);
-        padding-left: 0.5rem;
-        margin-left: 5px;
-        margin-right: 5px;
-    }
-</style>
diff --git a/ui/src/components/layout/BulkSelect.vue b/ui/src/components/layout/BulkSelect.vue
new file mode 100644
index 0000000000..539c2b49e3
--- /dev/null
+++ b/ui/src/components/layout/BulkSelect.vue
@@ -0,0 +1,69 @@
+<template>
+    <div class="bulk-select">
+        <el-checkbox
+            :model-value="selections.length > 0"
+            @change="toggle"
+            :indeterminate="partialCheck"
+        >
+            <span v-html="$t('selection.selected', {count: selectAll ? total : selections.length})" />
+        </el-checkbox>
+        <el-button-group>
+            <el-button
+                :type="selectAll ? 'primary' : 'default'"
+                @click="toggleAll"
+                v-if="selections.length < total"
+                v-html="$t('selection.all', {count: total})"
+            />
+            <slot />
+        </el-button-group>
+    </div>
+</template>
+<script>
+    export default {
+        props: {
+            total: {type: Number, required: true},
+            selections: {type: Array, required: true},
+            selectAll: {type: Boolean, required: true},
+        },
+        emits: ["update:selectAll", "unselect"],
+        methods: {
+            toggle(value) {
+                if (!value) {
+                    this.$emit("unselect");
+                }
+            },
+            toggleAll(value) {
+                this.$emit("update:selectAll", !this.selectAll);
+            }
+        },
+        computed: {
+            partialCheck() {
+                return !this.selectAll && this.selections.length < this.total;
+            },
+        }
+    }
+</script>
+
+<style lang="scss" scoped>
+    .bulk-select {
+        height: 100%;
+        display: flex;
+        align-items: center;
+
+        .el-checkbox {
+            height: 100%;
+
+            span {
+                padding-left: calc(var(--spacer) * 1.5);
+            }
+        }
+
+        > * {
+            padding: 0 8px;
+        }
+    }
+
+    span {
+        font-weight: bold;
+    }
+</style>
diff --git a/ui/src/components/layout/SelectTable.vue b/ui/src/components/layout/SelectTable.vue
new file mode 100644
index 0000000000..efa3a9cec3
--- /dev/null
+++ b/ui/src/components/layout/SelectTable.vue
@@ -0,0 +1,63 @@
+<script>
+    export default {
+        data() {
+            return {
+                hasSelection: false
+            }
+        },
+        methods: {
+            selectionChanged(selection) {
+                this.hasSelection = selection.length > 0;
+                this.$emit("selection-change", selection);
+            },
+            computeHeaderSize() {
+                const tableHead = this.$refs.table.$el.querySelector('thead');
+                this.$el.style.setProperty("--table-header-width", `${tableHead.clientWidth}px`);
+                this.$el.style.setProperty("--table-header-height", `${tableHead.clientHeight}px`);
+            }
+        },
+        props: {
+            selectable: {
+                type: Boolean,
+                default: true
+            },
+            data: {
+                type: Array
+            }
+        },
+        mounted() {
+            window.onresize = this.computeHeaderSize;
+        },
+        updated() {
+            this.computeHeaderSize();
+        }
+    }
+</script>
+
+<template>
+    <div class="position-relative">
+        <div v-if="hasSelection" class="bulk-select-header">
+            <slot name="select-actions" />
+        </div>
+        <el-table ref="table" v-bind="$attrs" :data="data" @selection-change="selectionChanged">
+            <el-table-column type="selection" v-if="selectable" />
+            <slot name="default" />
+        </el-table>
+    </div>
+</template>
+
+<style scoped lang="scss">
+    .bulk-select-header {
+        z-index: 1;
+        position: absolute;
+        height: var(--table-header-height);
+        width: var(--table-header-width);
+        background-color: var(--bs-gray-100-darken-3);
+        border-radius: var(--bs-border-radius-lg) var(--bs-border-radius-lg) 0 0;
+        border-bottom: 1px solid var(--bs-border-color);
+
+        & ~ .el-table {
+            z-index: 0;
+        }
+    }
+</style>
diff --git a/ui/src/components/templates/Templates.vue b/ui/src/components/templates/Templates.vue
index e94535cb97..9477dbc52e 100644
--- a/ui/src/components/templates/Templates.vue
+++ b/ui/src/components/templates/Templates.vue
@@ -20,9 +20,9 @@
                 </template>
 
                 <template #table>
-                    <el-table
+                    <select-table
+                        ref="selectTable"
                         :data="templates"
-                        ref="table"
                         :default-sort="{prop: 'id', order: 'ascending'}"
                         stripe
                         table-layout="auto"
@@ -30,36 +30,58 @@
                         @row-dblclick="onRowDoubleClick"
                         @sort-change="onSort"
                         @selection-change="handleSelectionChange"
+                        :selectable="canRead || canDelete"
                     >
-                        <el-table-column type="selection" v-if="(canRead)" />
-
-                        <el-table-column prop="id" sortable="custom" :sort-orders="['ascending', 'descending']" :label="$t('id')">
-                            <template #default="scope">
-                                <router-link
-                                    :to="{name: 'templates/update', params: {namespace: scope.row.namespace, id: scope.row.id}}"
-                                >
-                                    {{ scope.row.id }}
-                                </router-link>
-                                &nbsp;<markdown-tooltip
+                        <template #select-actions>
+                            <bulk-select
+                                :select-all="queryBulkAction"
+                                :selections="selection"
+                                :total="total"
+                                @update:select-all="toggleAllSelection"
+                                @unselect="toggleAllUnselected"
+                            >
+                                <el-button v-if="canRead" :icon="Download" @click="exportTemplates()">
+                                    {{ $t('export') }}
+                                </el-button>
+                                <el-button v-if="canDelete" @click="deleteTemplates" :icon="TrashCan">
+                                    {{ $t('delete') }}
+                                </el-button>
+                            </bulk-select>
+                        </template>
+                        <template #default>
+                            <el-table-column prop="id" sortable="custom" :sort-orders="['ascending', 'descending']"
+                                             :label="$t('id')">
+                                <template #default="scope">
+                                    <router-link
+                                        :to="{name: 'templates/update', params: {namespace: scope.row.namespace, id: scope.row.id}}"
+                                    >
+                                        {{ scope.row.id }}
+                                    </router-link>
+                                    &nbsp;<markdown-tooltip
                                     :id="scope.row.namespace + '-' + scope.row.id"
                                     :description="scope.row.description"
                                     :title="scope.row.namespace + '.' + scope.row.id"
                                 />
-                            </template>
-                        </el-table-column>
+                                </template>
+                            </el-table-column>
 
-                        <el-table-column prop="namespace" sortable="custom" :sort-orders="['ascending', 'descending']" :label="$t('namespace')" :formatter="(_, __, cellValue) => $filters.invisibleSpace(cellValue)" />
+                            <el-table-column prop="namespace" sortable="custom"
+                                             :sort-orders="['ascending', 'descending']"
+                                             :label="$t('namespace')"
+                                             :formatter="(_, __, cellValue) => $filters.invisibleSpace(cellValue)" />
 
-                        <el-table-column column-key="action" class-name="row-action">
-                            <template #default="scope">
-                                <router-link :to="{name: 'templates/update', params : {namespace: scope.row.namespace, id: scope.row.id}}">
-                                    <kicon :tooltip="$t('details')" placement="left">
-                                        <eye />
-                                    </kicon>
-                                </router-link>
-                            </template>
-                        </el-table-column>
-                    </el-table>
+                            <el-table-column column-key="action" class-name="row-action">
+                                <template #default="scope">
+                                    <router-link
+                                        :to="{name: 'templates/update', params : {namespace: scope.row.namespace, id: scope.row.id}}">
+                                        <kicon :tooltip="$t('details')" placement="left">
+                                            <eye />
+                                        </kicon>
+                                    </router-link>
+                                </template>
+                            </el-table-column>
+                        </template>
+                    </select-table>
                 </template>
             </data-table>
         </div>
@@ -67,18 +89,6 @@
 
         <bottom-line v-if="user && user.hasAnyAction(permission.TEMPLATE, action.CREATE)">
             <ul>
-                <ul v-if="templatesSelection.length !== 0 && canRead">
-                    <bottom-line-counter v-model="queryBulkAction" :selections="templatesSelection" :total="total" @update:model-value="selectAll()">
-                        <el-button v-if="canRead" :icon="Download" size="large" @click="exportTemplates()">
-                            {{ $t('export') }}
-                        </el-button>
-                        <el-button v-if="canDelete" @click="deleteTemplates" size="large" :icon="TrashCan">
-                            {{ $t('delete') }}
-                        </el-button>
-                    </bottom-line-counter>
-                </ul>
-
-                <li class="spacer" />
                 <li>
                     <div class="el-input el-input-file el-input--large custom-upload">
                         <div class="el-input__wrapper">
@@ -109,6 +119,8 @@
 </template>
 
 <script setup>
+    import BulkSelect from "../layout/BulkSelect.vue";
+    import SelectTable from "../layout/SelectTable.vue";
     import Plus from "vue-material-design-icons/Plus.vue";
     import Download from "vue-material-design-icons/Download.vue";
     import TrashCan from "vue-material-design-icons/TrashCan.vue";
@@ -129,11 +141,11 @@
     import RestoreUrl from "../../mixins/restoreUrl";
     import _merge from "lodash/merge";
     import MarkdownTooltip from "../../components/layout/MarkdownTooltip.vue";
-    import BottomLineCounter from "../layout/BottomLineCounter.vue";
     import Upload from "vue-material-design-icons/Upload.vue";
+    import SelectTableActions from "../../mixins/selectTableActions";
 
     export default {
-        mixins: [RouteContext, RestoreUrl, DataTableActions],
+        mixins: [RouteContext, RestoreUrl, DataTableActions, SelectTableActions],
         components: {
             BottomLine,
             Eye,
@@ -142,16 +154,13 @@
             NamespaceSelect,
             Kicon,
             MarkdownTooltip,
-            BottomLineCounter,
             Upload
         },
         data() {
             return {
                 isDefaultNamespaceAllow: true,
                 permission: permission,
-                action: action,
-                templatesSelection: [],
-                queryBulkAction: false
+                action: action
             };
         },
         computed: {
@@ -187,25 +196,15 @@
                         callback();
                     });
             },
-            handleSelectionChange(val) {
-                if (val.length === 0) {
-                    this.queryBulkAction = false
-                }
-                this.templatesSelection = val.map(x => {
-                    return {
-                        id: x.id,
-                        namespace: x.namespace
-                    }
-                });
-            },
-            selectAll() {
-                if (this.$refs.table.getSelectionRows().length !== this.$refs.table.data.length) {
-                    this.$refs.table.toggleAllSelection();
-                }
+            selectionMapper(element) {
+                return {
+                    id: element.id,
+                    namespace: element.namespace
+                };
             },
             exportTemplates() {
                 this.$toast().confirm(
-                    this.$t("template export", {"templateCount": this.queryBulkAction ? this.total : this.templatesSelection.length}),
+                    this.$t("template export", {"templateCount": this.queryBulkAction ? this.total : this.selection.length}),
                     () => {
                         if (this.queryBulkAction) {
                             return this.$store
@@ -218,13 +217,14 @@
                                 })
                         } else {
                             return this.$store
-                                .dispatch("template/exportTemplateByIds", {ids: this.templatesSelection})
+                                .dispatch("template/exportTemplateByIds", {ids: this.selection})
                                 .then(_ => {
                                     this.$toast().success(this.$t("templates exported"));
                                 })
                         }
                     },
-                    () => {}
+                    () => {
+                    }
                 )
             },
             importTemplates() {
@@ -234,12 +234,13 @@
                     .dispatch("template/importTemplates", formData)
                     .then(_ => {
                         this.$toast().success(this.$t("templates imported"));
-                        this.loadData(() => {})
+                        this.loadData(() => {
+                        })
                     })
             },
-            deleteTemplates(){
+            deleteTemplates() {
                 this.$toast().confirm(
-                    this.$t("template delete", {"templateCount": this.queryBulkAction ? this.total : this.templatesSelection.length}),
+                    this.$t("template delete", {"templateCount": this.queryBulkAction ? this.total : this.selection.length}),
                     () => {
                         if (this.queryBulkAction) {
                             return this.$store
@@ -249,18 +250,21 @@
                                 }, false))
                                 .then(r => {
                                     this.$toast().success(this.$t("templates deleted", {count: r.data.count}));
-                                    this.loadData(() => {})
+                                    this.loadData(() => {
+                                    })
                                 })
                         } else {
                             return this.$store
-                                .dispatch("template/deleteTemplateByIds", {ids: this.templatesSelection})
+                                .dispatch("template/deleteTemplateByIds", {ids: this.selection})
                                 .then(r => {
                                     this.$toast().success(this.$t("templates deleted", {count: r.data.count}));
-                                    this.loadData(() => {})
+                                    this.loadData(() => {
+                                    })
                                 })
                         }
                     },
-                    () => {}
+                    () => {
+                    }
                 )
             },
         },
diff --git a/ui/src/mixins/selectTableActions.js b/ui/src/mixins/selectTableActions.js
new file mode 100644
index 0000000000..6b4bf5e096
--- /dev/null
+++ b/ui/src/mixins/selectTableActions.js
@@ -0,0 +1,37 @@
+export default {
+    data() {
+        return {
+            queryBulkAction: false,
+            selection: []
+        };
+    },
+    methods: {
+        handleSelectionChange(val) {
+            if (val.length === this.total) {
+                this.queryBulkAction = true
+            } else if (val.length < this.internalPageSize) {
+                this.queryBulkAction = false
+            }
+            this.selection = val.map(this.selectionMapper);
+        },
+        toggleAllUnselected() {
+            this.elTable.clearSelection()
+        },
+        toggleAllSelection(active) {
+            // only some are selected, we should set queryBulkAction to true because it will select all
+            if (active && this.elTable.getSelectionRows().length > 0 && !this.queryBulkAction) {
+                this.queryBulkAction = true;
+            } else {
+                this.queryBulkAction = false;
+            }
+        },
+        selectionMapper(element) {
+            return element;
+        }
+    },
+    computed: {
+        elTable() {
+            return this.$refs.selectTable.$refs.table;
+        }
+    }
+}


Test patch: