Skip to content
Snippets Groups Projects
Verified Commit 42c8f391 authored by Miranda Fluharty's avatar Miranda Fluharty
Browse files

Add bulk delete to artifacts frontend

Add selection checkboxes next to jobs and artifacts
Add box at the top with delete button and confirmation modal
parent 7f11b825
No related branches found
No related tags found
1 merge request!111389Add bulk delete to artifacts page (frontend)
<script>
import { GlButtonGroup, GlButton, GlBadge, GlFriendlyWrap } from '@gitlab/ui';
import { GlButtonGroup, GlButton, GlBadge, GlFriendlyWrap, GlFormCheckbox } from '@gitlab/ui';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { I18N_EXPIRED, I18N_DOWNLOAD, I18N_DELETE } from '../constants';
......@@ -10,6 +10,7 @@ export default {
GlButton,
GlBadge,
GlFriendlyWrap,
GlFormCheckbox,
},
inject: ['canDestroyArtifacts'],
props: {
......@@ -17,6 +18,10 @@ export default {
type: Object,
required: true,
},
isSelected: {
type: Boolean,
required: true,
},
isLastRow: {
type: Boolean,
required: true,
......@@ -33,6 +38,13 @@ export default {
return numberToHumanSize(this.artifact.size);
},
},
methods: {
handleInput(checked) {
if (checked === this.isSelected) return;
this.$emit('selectArtifact', this.artifact, checked);
},
},
i18n: {
expired: I18N_EXPIRED,
download: I18N_DOWNLOAD,
......@@ -46,6 +58,9 @@ export default {
:class="{ 'gl-border-b-solid gl-border-b-1 gl-border-gray-100': !isLastRow }"
>
<div class="gl-display-inline-flex gl-align-items-center gl-w-full">
<span v-if="canDestroyArtifacts" class="gl-pl-5">
<gl-form-checkbox :checked="isSelected" @input="handleInput" />
</span>
<span
class="gl-w-half gl-pl-8 gl-display-flex gl-align-items-center"
data-testid="job-artifact-row-name"
......
<script>
import { GlButton, GlModalDirective, GlModal, GlSprintf } from '@gitlab/ui';
import { createAlert } from '~/flash';
import getJobArtifactsQuery from '../graphql/queries/get_job_artifacts.query.graphql';
import bulkDestroyJobArtifactsMutation from '../graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql';
import { removeArtifactFromStore } from '../graphql/cache_update';
import {
I18N_BULK_DELETE_BANNER,
I18N_BULK_DELETE_CLEAR_SELECTION,
I18N_BULK_DELETE_DELETE_SELECTED,
I18N_BULK_DELETE_MODAL_TITLE,
I18N_BULK_DELETE_BODY,
I18N_BULK_DELETE_ACTION,
I18N_BULK_DELETE_CONFIRMATION_TOAST,
I18N_BULK_DELETE_PARTIAL_ERROR,
I18N_BULK_DELETE_ERROR,
I18N_MODAL_CANCEL,
BULK_DELETE_MODAL_ID,
} from '../constants';
export default {
name: 'ArtifactsBulkDelete',
components: {
GlButton,
GlModal,
GlSprintf,
},
directives: {
GlModal: GlModalDirective,
},
props: {
selectedArtifacts: {
type: Array,
required: true,
},
},
data() {
return {
isDeleting: false,
};
},
computed: {
checkedCount() {
return this.selectedArtifacts.length || 0;
},
modalActionPrimary() {
return {
text: I18N_BULK_DELETE_ACTION(this.checkedCount),
attributes: {
loading: this.isDeleting,
variant: 'danger',
},
};
},
modalActionCancel() {
return {
text: I18N_MODAL_CANCEL,
attributes: {
loading: this.isDeleting,
},
};
},
},
methods: {
onClearChecked() {
this.$emit('clearSelectedArtifacts');
},
async onConfirmDelete(e) {
this.isDeleting = true;
e.preventDefault(); // don't close modal until deletion is complete
try {
await this.$apollo.mutate({
mutation: bulkDestroyJobArtifactsMutation,
variables: {
input: {
ids: this.selectedArtifacts,
},
},
update: (store, { data }) => {
const { errors, deletedCount, deletedIds } = data.bulkDestroyJobArtifacts;
if (errors?.length) {
createAlert({
message: I18N_BULK_DELETE_PARTIAL_ERROR,
captureError: true,
error: new Error(errors.join(' ')),
});
}
if (deletedIds?.length) {
this.$toast.show(I18N_BULK_DELETE_CONFIRMATION_TOAST(deletedCount));
// Remove deleted artifacts from the cache
deletedIds.forEach((id) => {
removeArtifactFromStore(store, id, getJobArtifactsQuery, this.queryVariables);
});
store.gc();
}
},
});
} catch (error) {
this.onError(error);
} finally {
this.isDeleting = false;
this.$refs.modal.hide();
}
},
onError(error) {
createAlert({
message: I18N_BULK_DELETE_ERROR,
captureError: true,
error,
});
},
},
i18n: {
banner: I18N_BULK_DELETE_BANNER,
clearSelection: I18N_BULK_DELETE_CLEAR_SELECTION,
deleteSelected: I18N_BULK_DELETE_DELETE_SELECTED,
modalTitle: I18N_BULK_DELETE_MODAL_TITLE,
modalBody: I18N_BULK_DELETE_BODY,
},
BULK_DELETE_MODAL_ID,
};
</script>
<template>
<div class="gl-my-4 gl-p-4 gl-border-1 gl-border-solid gl-border-gray-100">
<div class="gl-display-flex gl-align-items-center">
<div>
<gl-sprintf :message="$options.i18n.banner(checkedCount)">
<template #strong="{ content }">
<strong>{{ content }}</strong>
</template>
</gl-sprintf>
</div>
<div class="gl-ml-auto">
<gl-button variant="default" @click="onClearChecked">
{{ $options.i18n.clearSelection }}
</gl-button>
<gl-button v-gl-modal="$options.BULK_DELETE_MODAL_ID" variant="danger">
{{ $options.i18n.deleteSelected }}
</gl-button>
</div>
</div>
<gl-modal
ref="modal"
size="sm"
:modal-id="$options.BULK_DELETE_MODAL_ID"
:title="$options.i18n.modalTitle(checkedCount)"
:action-primary="modalActionPrimary"
:action-cancel="modalActionCancel"
@primary="onConfirmDelete"
>
<gl-sprintf :message="$options.i18n.modalBody(checkedCount)" />
</gl-modal>
</div>
</template>
......@@ -25,6 +25,10 @@ export default {
type: Object,
required: true,
},
selectedArtifacts: {
type: Array,
required: true,
},
queryVariables: {
type: Object,
required: true,
......@@ -52,6 +56,9 @@ export default {
isLastRow(index) {
return index === this.artifacts.nodes.length - 1;
},
isSelected(item) {
return this.selectedArtifacts.includes(item.id);
},
showModal(item) {
this.deletingArtifactId = item.id;
this.deletingArtifactName = item.name;
......@@ -98,7 +105,9 @@ export default {
<dynamic-scroller-item :item="item" :active="active" :class="{ active }">
<artifact-row
:artifact="item"
:is-selected="isSelected(item)"
:is-last-row="isLastRow(index)"
v-on="$listeners"
@delete="showModal(item)"
/>
</dynamic-scroller-item>
......
......@@ -8,6 +8,7 @@ import {
GlBadge,
GlIcon,
GlPagination,
GlFormCheckbox,
} from '@gitlab/ui';
import { createAlert } from '~/alert';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
......@@ -34,6 +35,8 @@ import {
JOBS_PER_PAGE,
INITIAL_LAST_PAGE_SIZE,
} from '../constants';
import JobCheckbox from './job_checkbox.vue';
import ArtifactsBulkDelete from './artifacts_bulk_delete.vue';
import ArtifactsTableRowDetails from './artifacts_table_row_details.vue';
import FeedbackBanner from './feedback_banner.vue';
......@@ -56,8 +59,11 @@ export default {
GlBadge,
GlIcon,
GlPagination,
GlFormCheckbox,
CiIcon,
TimeAgo,
JobCheckbox,
ArtifactsBulkDelete,
ArtifactsTableRowDetails,
FeedbackBanner,
},
......@@ -94,6 +100,7 @@ export default {
jobArtifacts: [],
pageInfo: {},
expandedJobs: [],
selectedArtifacts: [],
pagination: INITIAL_PAGINATION_STATE,
};
},
......@@ -118,6 +125,18 @@ export default {
nextPage() {
return Number(this.pageInfo.hasNextPage);
},
fields() {
return [
this.canDestroyArtifacts && {
key: 'checkbox',
label: '',
},
...this.$options.fields,
];
},
anyArtifactsSelected() {
return Boolean(this.selectedArtifacts.length);
},
},
methods: {
refetchArtifacts() {
......@@ -158,6 +177,16 @@ export default {
this.expandedJobs.splice(this.expandedJobs.indexOf(id), 1);
}
},
selectArtifact(artifactNode, checked) {
if (checked) {
this.selectedArtifacts.push(artifactNode.id);
} else {
this.selectedArtifacts.splice(this.selectedArtifacts.indexOf(artifactNode.id), 1);
}
},
clearSelectedArtifacts() {
this.selectedArtifacts = [];
},
downloadPath(job) {
return job.archive?.downloadPath;
},
......@@ -217,9 +246,14 @@ export default {
<template>
<div>
<feedback-banner />
<artifacts-bulk-delete
v-if="anyArtifactsSelected"
:selected-artifacts="selectedArtifacts"
@clearSelectedArtifacts="clearSelectedArtifacts"
/>
<gl-table
:items="jobArtifacts"
:fields="$options.fields"
:fields="fields"
:busy="$apollo.queries.jobArtifacts.loading"
stacked="sm"
details-td-class="gl-bg-gray-10! gl-p-0! gl-overflow-auto"
......@@ -227,6 +261,26 @@ export default {
<template #table-busy>
<gl-loading-icon size="lg" />
</template>
<template v-if="canDestroyArtifacts" #head(checkbox)>
<gl-form-checkbox
:disabled="!anyArtifactsSelected"
:checked="anyArtifactsSelected"
:indeterminate="anyArtifactsSelected"
@change="clearSelectedArtifacts"
/>
</template>
<template v-if="canDestroyArtifacts" #cell(checkbox)="{ item: { hasArtifacts, artifacts } }">
<job-checkbox
:has-artifacts="hasArtifacts"
:selected-artifacts="
artifacts.nodes.filter((node) => selectedArtifacts.includes(node.id))
"
:unselected-artifacts="
artifacts.nodes.filter((node) => !selectedArtifacts.includes(node.id))
"
@selectArtifact="selectArtifact"
/>
</template>
<template
#cell(artifacts)="{ item: { id, artifacts, hasArtifacts }, toggleDetails, detailsShowing }"
>
......@@ -323,8 +377,10 @@ export default {
<template #row-details="{ item: { artifacts } }">
<artifacts-table-row-details
:artifacts="artifacts"
:selected-artifacts="selectedArtifacts"
:query-variables="queryVariables"
@refetch="refetchArtifacts"
@selectArtifact="selectArtifact"
/>
</template>
</gl-table>
......
<script>
import { GlFormCheckbox } from '@gitlab/ui';
export default {
name: 'JobCheckbox',
components: {
GlFormCheckbox,
},
props: {
hasArtifacts: {
type: Boolean,
required: true,
},
selectedArtifacts: {
type: Array,
required: true,
},
unselectedArtifacts: {
type: Array,
required: true,
},
},
computed: {
disabled() {
return !this.hasArtifacts;
},
checked() {
return this.hasArtifacts && this.unselectedArtifacts.length === 0;
},
indeterminate() {
return this.selectedArtifacts.length > 0 && this.unselectedArtifacts.length > 0;
},
},
methods: {
handleInput(checked) {
if (checked) {
this.unselectedArtifacts.forEach((node) => this.$emit('selectArtifact', node, true));
} else {
this.selectedArtifacts.forEach((node) => this.$emit('selectArtifact', node, false));
}
},
},
};
</script>
<template>
<gl-form-checkbox
:disabled="disabled"
:checked="checked"
:indeterminate="indeterminate"
@input="handleInput"
/>
</template>
......@@ -54,6 +54,44 @@ export const I18N_FEEDBACK_BANNER_BODY = s__(
export const I18N_FEEDBACK_BANNER_BUTTON = s__('Artifacts|Take a quick survey');
export const FEEDBACK_URL = 'https://gitlab.fra1.qualtrics.com/jfe/form/SV_cI9rAUI20Vo2St8';
export const I18N_BULK_DELETE_BANNER = (count) =>
sprintf(
n__(
'Artifacts|%{strongStart}%{count}%{strongEnd} artifact selected',
'Artifacts|%{strongStart}%{count}%{strongEnd} artifacts selected',
count,
),
{
count,
},
);
export const I18N_BULK_DELETE_CLEAR_SELECTION = s__('Artifacts|Clear selection');
export const I18N_BULK_DELETE_DELETE_SELECTED = s__('Artifacts|Delete selected');
export const BULK_DELETE_MODAL_ID = 'artifacts-bulk-delete-modal';
export const I18N_BULK_DELETE_MODAL_TITLE = (count) =>
n__('Artifacts|Delete %d artifact?', 'Artifacts|Delete %d artifacts?', count);
export const I18N_BULK_DELETE_BODY = (count) =>
sprintf(
n__(
'Artifacts|The selected artifact will be permanently deleted. Any reports generated from these artifacts will be empty.',
'Artifacts|The selected artifacts will be permanently deleted. Any reports generated from these artifacts will be empty.',
count,
),
{ count },
);
export const I18N_BULK_DELETE_ACTION = (count) =>
n__('Artifacts|Delete %d artifact', 'Artifacts|Delete %d artifacts', count);
export const I18N_BULK_DELETE_PARTIAL_ERROR = s__(
'Artifacts|An error occurred while deleting. Some artifacts may not have been deleted.',
);
export const I18N_BULK_DELETE_ERROR = s__(
'Artifacts|Something went wrong while deleting. Please refresh the page to try again.',
);
export const I18N_BULK_DELETE_CONFIRMATION_TOAST = (count) =>
n__('Artifacts|%d selected artifact deleted', 'Artifacts|%d selected artifacts deleted', count);
export const INITIAL_CURRENT_PAGE = 1;
export const INITIAL_PREVIOUS_PAGE_CURSOR = '';
export const INITIAL_NEXT_PAGE_CURSOR = '';
......
mutation bulkDestroyJobArtifacts($ids: [CiJobArtifactID]!) {
bulkDestroyJobArtifacts(input: { ids: $ids }) {
deletedCount
deletedIds
errors
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment