Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions dashboard/pkg/epinio/components/BulkDeleteModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useStore } from 'vuex';
import { Card } from '@components/Card';
import Checkbox from '@components/Form/Checkbox/Checkbox.vue';

interface Props {
show: boolean;
title: string;
itemLabels: string[];
deleting?: boolean;
showDeleteImage?: boolean;
description?: string;
}

const props = withDefaults(defineProps<Props>(), {
deleting: false,
showDeleteImage: false,
description: ''
});

const emit = defineEmits<{
(event: 'close'): void;
(event: 'confirm', payload: { deleteImage: boolean }): void;
}>();

const deleteImage = ref(false);
const store = useStore();
const t = store.getters['i18n/t'];

watch(() => props.show, (show) => {
if (!show) {
deleteImage.value = false;
}
});

function onConfirm() {
emit('confirm', { deleteImage: deleteImage.value });
}
</script>

<template>
<div
v-if="show"
class="modal"
>
<Card
class="modal-content"
:show-actions="true"
>
<template #title>
<h4>{{ title }}</h4>
</template>
<template #body>
<p class="mb-10">{{ description }}</p>
<ul class="delete-list">
<li
v-for="label in itemLabels"
:key="label"
>
{{ label }}
</li>
</ul>
<div
v-if="showDeleteImage"
class="mt-20"
>
<Checkbox
v-model:value="deleteImage"
:label="t('epinio.applications.deleteImage.label')"
/>
</div>
</template>
<template #actions>
<div class="modal-actions">
<button
class="btn role-secondary mr-10"
:disabled="deleting"
@click="$emit('close')"
>
{{ t('generic.cancel') }}
</button>
<button
class="btn bulk-delete-confirm-btn"
:disabled="deleting || itemLabels.length === 0"
@click="onConfirm"
>
{{ deleting ? t('epinio.bulkDelete.deletingAction') : t('epinio.bulkDelete.confirmAction', { count: itemLabels.length }) }}
</button>
</div>
</template>
</Card>
</div>
</template>

<style scoped lang="scss">
.modal {
position: fixed;
z-index: 50;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.4);
border-radius: var(--border-radius);
}

.modal-content {
background-color: var(--default);
margin: 10% auto;
padding: 20px;
border: 1px solid #888;
width: 50%;
max-width: 650px;
}

.modal-actions {
display: flex;
justify-content: flex-end;
}

.delete-list {
max-height: 220px;
overflow: auto;
margin: 0;
padding-left: 20px;
}

.bulk-delete-confirm-btn:disabled {
background-color: var(--disabled-bg) !important;
border-color: var(--border) !important;
color: var(--disabled-text) !important;
opacity: 1;
}

.bulk-delete-confirm-btn:not(:disabled) {
background-color: var(--error) !important;
border-color: var(--error) !important;
color: var(--error-contrast, #fff) !important;
}

.bulk-delete-confirm-btn:not(:disabled):hover {
filter: brightness(0.92);
}
</style>
144 changes: 141 additions & 3 deletions dashboard/pkg/epinio/components/tables/DataTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ interface Props {
keyField?: string;
rowActions?: boolean;
rowActionsWidth?: number;
selectable?: boolean;
selectedRowKeys?: string[];
rowSelectable?: (row: DataTableRow) => boolean;
}

const props = withDefaults(defineProps<Props>(), {
Expand All @@ -26,9 +29,17 @@ const props = withDefaults(defineProps<Props>(), {
loading: false,
keyField: 'id',
rowActions: true,
rowActionsWidth: 40
rowActionsWidth: 40,
selectable: false,
selectedRowKeys: () => [],
rowSelectable: () => true
});

const emit = defineEmits<{
(event: 'selection-change', selectedRows: DataTableRow[]): void;
(event: 'update:selectedRowKeys', selectedRowKeys: string[]): void;
}>();

// Define slot types - using Record<string, any> for row to allow dynamic properties
defineSlots<{
[key: `cell:${string}`]: (props: { row: Record<string, any>; value: any; column: DataTableColumn }) => any;
Expand All @@ -40,6 +51,8 @@ const searchQuery = ref('');
const currentPage = ref(1);
const sortColumn = ref<string | null>(null);
const sortDirection = ref<SortDirection>('asc');
const internalSelectedKeys = ref<Set<string>>(new Set());
const selectAllCheckbox = ref<HTMLInputElement | null>(null);
const store = useStore();

// Apply namespace filter first
Expand Down Expand Up @@ -146,6 +159,26 @@ const paginationInfo = computed(() => {
};
});

const visibleSelectableRows = computed(() => paginatedRows.value.filter((row) => isRowSelectable(row)));

const allVisibleSelected = computed(() => {
if (!props.selectable || !visibleSelectableRows.value.length) {
return false;
}

return visibleSelectableRows.value.every((row) => internalSelectedKeys.value.has(getRowKey(row, 0)));
});

const someVisibleSelected = computed(() => {
if (!props.selectable || !visibleSelectableRows.value.length) {
return false;
}

const selectedCount = visibleSelectableRows.value.filter((row) => internalSelectedKeys.value.has(getRowKey(row, 0))).length;

return selectedCount > 0 && selectedCount < visibleSelectableRows.value.length;
});

// Methods
function getNestedValue(obj: any, path: string): any {
return path.split('.').reduce((current, key) => current?.[key], obj);
Expand Down Expand Up @@ -201,14 +234,83 @@ function prevPage() {
}

function getRowKey(row: DataTableRow, index: number): string {
return row[props.keyField] || `row-${index}`;
return String(row[props.keyField] || `row-${index}`);
}

function isRowSelectable(row: DataTableRow): boolean {
return props.rowSelectable ? props.rowSelectable(row) : true;
}

function syncSelectionToRows() {
const selected = props.rows.filter((row, index) => isRowSelectable(row) && internalSelectedKeys.value.has(getRowKey(row, index)));
const keys = selected.map((row, index) => getRowKey(row, index));

emit('selection-change', selected);
emit('update:selectedRowKeys', keys);
}

function toggleRowSelection(row: DataTableRow, index: number, selected: boolean) {
if (!isRowSelectable(row)) {
return;
}

const rowKey = getRowKey(row, index);

if (selected) {
internalSelectedKeys.value.add(rowKey);
} else {
internalSelectedKeys.value.delete(rowKey);
}

syncSelectionToRows();
}

function toggleSelectAllVisible(selected: boolean) {
visibleSelectableRows.value.forEach((row, index) => {
const rowKey = getRowKey(row, index);

if (selected) {
internalSelectedKeys.value.add(rowKey);
} else {
internalSelectedKeys.value.delete(rowKey);
}
});

syncSelectionToRows();
}

// Watch: Reset to page 1 when search or sort changes
watch([searchQuery, sortColumn, sortDirection], () => {
currentPage.value = 1;
});

watch(
() => props.rows,
(newRows) => {
const validKeys = new Set(newRows.filter((row) => isRowSelectable(row)).map((row, index) => getRowKey(row, index)));
internalSelectedKeys.value.forEach((key) => {
if (!validKeys.has(key)) {
internalSelectedKeys.value.delete(key);
}
});
},
{ deep: true }
);

watch(
() => props.selectedRowKeys,
(newKeys) => {
internalSelectedKeys.value = new Set(newKeys || []);
},
{ immediate: true }
);

watch([allVisibleSelected, someVisibleSelected], () => {
if (selectAllCheckbox.value) {
selectAllCheckbox.value.indeterminate = someVisibleSelected.value;
}
});

// Expose methods for parent components
defineExpose({
goToPage,
Expand Down Expand Up @@ -247,6 +349,18 @@ defineExpose({
<table class="data-table__table">
<thead class="data-table__thead">
<tr>
<th
v-if="selectable"
class="data-table__th data-table__th--select"
>
<input
ref="selectAllCheckbox"
type="checkbox"
:disabled="visibleSelectableRows.length === 0"
:checked="allVisibleSelected"
@change="toggleSelectAllVisible(($event.target as HTMLInputElement).checked)"
>
</th>
<th
v-for="column in columns"
:key="column.field"
Expand Down Expand Up @@ -289,6 +403,17 @@ defineExpose({
:key="getRowKey(row, index)"
class="data-table__tr"
>
<td
v-if="selectable"
class="data-table__td data-table__td--select"
>
<input
type="checkbox"
:disabled="!isRowSelectable(row)"
:checked="internalSelectedKeys.has(getRowKey(row, index))"
@change="toggleRowSelection(row, index, ($event.target as HTMLInputElement).checked)"
>
</td>
<td
v-for="column in columns"
:key="column.field"
Expand Down Expand Up @@ -318,7 +443,7 @@ defineExpose({

<!-- Empty state -->
<tr v-if="paginatedRows.length === 0" class="data-table__empty">
<td :colspan="rowActions ? columns.length + 1 : columns.length" class="data-table__td--empty">
<td :colspan="(rowActions ? columns.length + 1 : columns.length) + (selectable ? 1 : 0)" class="data-table__td--empty">
<slot name="empty">
<span class="text-muted">
{{ searchQuery ? 'No results found' : 'No data available' }}
Expand Down Expand Up @@ -465,6 +590,12 @@ defineExpose({
width: 40px;
padding: 0.75rem 0.5rem;
}

&--select {
width: 40px;
text-align: center;
padding: 0.75rem 0.5rem;
}
}

&__th-content {
Expand Down Expand Up @@ -515,6 +646,13 @@ defineExpose({
vertical-align: middle;
}

&--select {
width: 40px;
padding: 0.5rem;
text-align: center;
vertical-align: middle;
}

// Badge state styling
:deep(.badge-state) {
display: inline-block;
Expand Down
15 changes: 15 additions & 0 deletions dashboard/pkg/epinio/l10n/en-us.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,21 @@ epinio:
chartVersion: Chart Version
appVersion: Version
helmChart: Helm Chart
bulkDelete:
button: "Delete Selected ({count})"
deletingButton: "Deleting..."
deletingAction: "Deleting..."
confirmAction: "Delete {count} Item(s)"
descriptions:
applications: "The following applications will be deleted:"
services: "The following services will be deleted:"
namespaces: "The following namespaces will be deleted:"
configurations: "The following configurations will be deleted:"
titles:
applications: "Delete Selected Applications ({count})"
services: "Delete Selected Services ({count})"
namespaces: "Delete Selected Namespaces ({count})"
configurations: "Delete Selected Configurations ({count})"
warnings:
noNamespace: There are no namespaces. Please create one before proceeding
login:
Expand Down
Loading
Loading