diff --git a/dashboard/pkg/epinio/components/BulkDeleteModal.vue b/dashboard/pkg/epinio/components/BulkDeleteModal.vue new file mode 100644 index 00000000..13095f30 --- /dev/null +++ b/dashboard/pkg/epinio/components/BulkDeleteModal.vue @@ -0,0 +1,146 @@ + + + + + diff --git a/dashboard/pkg/epinio/components/tables/DataTable.vue b/dashboard/pkg/epinio/components/tables/DataTable.vue index c52afe92..4f47604e 100644 --- a/dashboard/pkg/epinio/components/tables/DataTable.vue +++ b/dashboard/pkg/epinio/components/tables/DataTable.vue @@ -16,6 +16,9 @@ interface Props { keyField?: string; rowActions?: boolean; rowActionsWidth?: number; + selectable?: boolean; + selectedRowKeys?: string[]; + rowSelectable?: (row: DataTableRow) => boolean; } const props = withDefaults(defineProps(), { @@ -26,9 +29,17 @@ const props = withDefaults(defineProps(), { 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 for row to allow dynamic properties defineSlots<{ [key: `cell:${string}`]: (props: { row: Record; value: any; column: DataTableColumn }) => any; @@ -40,6 +51,8 @@ const searchQuery = ref(''); const currentPage = ref(1); const sortColumn = ref(null); const sortDirection = ref('asc'); +const internalSelectedKeys = ref>(new Set()); +const selectAllCheckbox = ref(null); const store = useStore(); // Apply namespace filter first @@ -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); @@ -201,7 +234,49 @@ 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 @@ -209,6 +284,33 @@ 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, @@ -247,6 +349,18 @@ defineExpose({ + -
+ + + + +
+ {{ searchQuery ? 'No results found' : 'No data available' }} @@ -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 { @@ -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; diff --git a/dashboard/pkg/epinio/l10n/en-us.yaml b/dashboard/pkg/epinio/l10n/en-us.yaml index 52c9bf3e..370f410e 100644 --- a/dashboard/pkg/epinio/l10n/en-us.yaml +++ b/dashboard/pkg/epinio/l10n/en-us.yaml @@ -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: diff --git a/dashboard/pkg/epinio/list/configurations.vue b/dashboard/pkg/epinio/list/configurations.vue index fb3f5870..665e7aca 100644 --- a/dashboard/pkg/epinio/list/configurations.vue +++ b/dashboard/pkg/epinio/list/configurations.vue @@ -6,6 +6,7 @@ import { createEpinioRoute } from '../utils/custom-routing'; import LinkDetail from '@shell/components/formatter/LinkDetail.vue'; import BadgeStateFormatter from '@shell/components/formatter/BadgeStateFormatter.vue'; import Masthead from '@shell/components/ResourceList/Masthead'; +import BulkDeleteModal from '../components/BulkDeleteModal.vue'; import { useStore } from 'vuex'; import { ref, computed, onMounted, onUnmounted } from 'vue'; @@ -37,6 +38,17 @@ const canCreateConfiguration = computed(() => { return can('configuration_write') || can('configuration'); }); +const canDeleteConfiguration = computed(() => { + const can = store.getters['epinio/can']; + const perms = store.getters['epinio/permissions']?.(); + + if (!can || !perms || Object.keys(perms).length === 0) { + return false; + } + + return can('configuration_write') || can('configuration'); +}); + const pending = ref(true); onMounted(async () => { @@ -68,6 +80,68 @@ onUnmounted(() => { const rows = computed(() => { return store.getters['epinio/all'](EPINIO_TYPES.CONFIGURATION); }); +const selectedConfigurationIds = ref([]); +const showDeleteModal = ref(false); +const deletingSelected = ref(false); +const selectedCount = computed(() => selectedConfigurations.value.length); + +function canDeleteConfigurationRow(config: any): boolean { + return !!config?._canDelete; +} + +function configKey(config: any): string { + return String(config?.id || `${ config?.meta?.namespace || 'default' }/${ config?.meta?.name || '' }`); +} + +const selectedConfigurations = computed(() => { + const selectedSet = new Set(selectedConfigurationIds.value); + return rows.value.filter((config: any) => canDeleteConfigurationRow(config) && selectedSet.has(configKey(config))); +}); + +const selectedConfigurationLabels = computed(() => { + return selectedConfigurations.value.map((config: any) => `${ config.meta?.namespace }/${ config.meta?.name }`); +}); + +function selectedKeysForRows(items: any[]) { + const selectedSet = new Set(selectedConfigurationIds.value); + return items + .filter((item) => canDeleteConfigurationRow(item)) + .map((item) => configKey(item)) + .filter((id) => selectedSet.has(id)); +} + +function onSelectionChange(selectedRows: any[]) { + selectedConfigurationIds.value = selectedRows.map((row: any) => configKey(row)); +} + +function openDeleteModal() { + if (!canDeleteConfiguration.value || selectedCount.value === 0 || deletingSelected.value) { + return; + } + showDeleteModal.value = true; +} + +function closeDeleteModal() { + showDeleteModal.value = false; +} + +async function deleteSelectedConfigurations() { + if (!selectedConfigurations.value.length || deletingSelected.value) { + return; + } + + deletingSelected.value = true; + try { + await selectedConfigurations.value[0].bulkRemove(selectedConfigurations.value, {}); + selectedConfigurationIds.value = []; + closeDeleteModal(); + await store.dispatch('epinio/findAll', { type: EPINIO_TYPES.CONFIGURATION, opt: { force: true } }); + } catch (e: any) { + await store.dispatch('growl/fromError', { title: t('generic.notification.error'), err: e }, { root: true }); + } finally { + deletingSelected.value = false; + } +} const columns: DataTableColumn[] = [ { @@ -106,6 +180,14 @@ const columns: DataTableColumn[] = [ :resource="resource" > + + diff --git a/dashboard/pkg/epinio/list/namespaces.vue b/dashboard/pkg/epinio/list/namespaces.vue index f66e04dd..36f8ecad 100644 --- a/dashboard/pkg/epinio/list/namespaces.vue +++ b/dashboard/pkg/epinio/list/namespaces.vue @@ -14,8 +14,9 @@ import { epinioExceptionToErrorsArray } from '../utils/errors'; import LabeledInput from '@components/Form/LabeledInput/LabeledInput.vue'; import { validateKubernetesName } from '@shell/utils/validators/kubernetes-name'; import { startPolling, stopPolling } from '../utils/polling'; +import BulkDeleteModal from '../components/BulkDeleteModal.vue'; -defineProps<{ +const props = defineProps<{ schema: object, rows: Array, }>(); @@ -48,6 +49,22 @@ const canCreateNamespace = computed(() => { return can('namespace_write') || can('namespace'); }); +const canDeleteNamespace = computed(() => { + const can = store.getters['epinio/can']; + const perms = store.getters['epinio/permissions']?.(); + + if (!can || !perms || Object.keys(perms).length === 0) { + return false; + } + + return can('namespace_write') || can('namespace'); +}); + +const selectedNamespaceIds = ref([]); +const showDeleteModal = ref(false); +const deletingSelected = ref(false); +const selectedCount = computed(() => selectedNamespaceIds.value.length); + const validationPassed = computed(() => { // Add here fields that need validation if (!creatingNamespace.value) { @@ -156,6 +173,57 @@ function getNamespaceErrors(name) { return []; } +function namespaceKey(ns: any): string { + return String(ns?._key || ns?.id || ns?.meta?.name || ns?.name || ''); +} + +const selectedNamespaces = computed(() => { + const selectedSet = new Set(selectedNamespaceIds.value); + return (props.rows || []).filter((ns: any) => selectedSet.has(namespaceKey(ns))); +}); + +const selectedNamespaceLabels = computed(() => { + return selectedNamespaces.value.map((ns: any) => ns.meta?.name || ns.name || ns.id); +}); + +function onNamespaceSelectionChange(selectedRows: any[]) { + selectedNamespaceIds.value = selectedRows.map((row: any) => namespaceKey(row)); +} + +function selectedKeysForRows(items: any[]) { + const selectedSet = new Set(selectedNamespaceIds.value); + return (items || []).map((item: any) => namespaceKey(item)).filter((id: string) => selectedSet.has(id)); +} + +function openDeleteModal() { + if (!canDeleteNamespace.value || selectedCount.value === 0 || deletingSelected.value) { + return; + } + showDeleteModal.value = true; +} + +function closeDeleteModal() { + showDeleteModal.value = false; +} + +async function deleteSelectedNamespaces() { + if (!selectedNamespaces.value.length || deletingSelected.value) { + return; + } + + deletingSelected.value = true; + try { + await Promise.all(selectedNamespaces.value.map((ns: any) => ns.remove())); + selectedNamespaceIds.value = []; + closeDeleteModal(); + await store.dispatch('epinio/findAll', { type: EPINIO_TYPES.NAMESPACE, opt: { force: true } }); + } catch (e: any) { + await store.dispatch('growl/fromError', { title: t('generic.notification.error'), err: e }, { root: true }); + } finally { + deletingSelected.value = false; + } +} + const columns: DataTableColumn[] = [ { field: 'meta.name', @@ -180,10 +248,18 @@ const columns: DataTableColumn[] = [