Skip to content

Commit

Permalink
Merge pull request #17282 from guerler/grids_batchoperation
Browse files Browse the repository at this point in the history
Adds delete, purge and undelete batch operations to History Grid
  • Loading branch information
guerler authored Jan 30, 2024
2 parents ae711b1 + 4407ef9 commit da12de9
Show file tree
Hide file tree
Showing 9 changed files with 372 additions and 18 deletions.
2 changes: 2 additions & 0 deletions client/src/api/histories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,7 @@ import { fetcher } from "@/api/schema";
export const historiesFetcher = fetcher.path("/api/histories").method("get").create();
export const archivedHistoriesFetcher = fetcher.path("/api/histories/archived").method("get").create();
export const deleteHistory = fetcher.path("/api/histories/{history_id}").method("delete").create();
export const deleteHistories = fetcher.path("/api/histories/batch/delete").method("put").create();
export const undeleteHistory = fetcher.path("/api/histories/deleted/{history_id}/undelete").method("post").create();
export const undeleteHistories = fetcher.path("/api/histories/batch/undelete").method("put").create();
export const historiesQuery = fetcher.path("/api/histories/query").method("get").create();
107 changes: 107 additions & 0 deletions client/src/api/schema/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,14 @@ export interface paths {
*/
get: operations["get_archived_histories_api_histories_archived_get"];
};
"/api/histories/batch/delete": {
/** Marks several histories with the given IDs as deleted. */
put: operations["batch_delete_api_histories_batch_delete_put"];
};
"/api/histories/batch/undelete": {
/** Marks several histories with the given IDs as undeleted. */
put: operations["batch_undelete_api_histories_batch_undelete_put"];
};
"/api/histories/count": {
/** Returns number of histories for the current user. */
get: operations["count_api_histories_count_get"];
Expand Down Expand Up @@ -4042,6 +4050,20 @@ export interface components {
*/
success_count: number;
};
/** DeleteHistoriesPayload */
DeleteHistoriesPayload: {
/**
* IDs
* @description List of history IDs to be deleted.
*/
ids: string[];
/**
* Purge
* @description Whether to definitely remove this history from disk.
* @default false
*/
purge?: boolean;
};
/** DeleteHistoryContentPayload */
DeleteHistoryContentPayload: {
/**
Expand Down Expand Up @@ -10453,6 +10475,14 @@ export interface components {
*/
title?: string | null;
};
/** UndeleteHistoriesPayload */
UndeleteHistoriesPayload: {
/**
* IDs
* @description List of history IDs to be undeleted.
*/
ids: string[];
};
/**
* UpdateCollectionAttributePayload
* @description Contains attributes that can be updated for all elements in a dataset collection.
Expand Down Expand Up @@ -13931,6 +13961,83 @@ export interface operations {
};
};
};
batch_delete_api_histories_batch_delete_put: {
/** Marks several histories with the given IDs as deleted. */
parameters?: {
/** @description View to be passed to the serializer */
/** @description Comma-separated list of keys to be passed to the serializer */
query?: {
purge?: boolean;
view?: string | null;
keys?: string | null;
};
/** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */
header?: {
"run-as"?: string | null;
};
};
requestBody: {
content: {
"application/json": components["schemas"]["DeleteHistoriesPayload"];
};
};
responses: {
/** @description Successful Response */
200: {
content: {
"application/json": (
| components["schemas"]["HistoryDetailed"]
| components["schemas"]["HistorySummary"]
| components["schemas"]["HistoryMinimal"]
)[];
};
};
/** @description Validation Error */
422: {
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
batch_undelete_api_histories_batch_undelete_put: {
/** Marks several histories with the given IDs as undeleted. */
parameters?: {
/** @description View to be passed to the serializer */
/** @description Comma-separated list of keys to be passed to the serializer */
query?: {
view?: string | null;
keys?: string | null;
};
/** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */
header?: {
"run-as"?: string | null;
};
};
requestBody: {
content: {
"application/json": components["schemas"]["UndeleteHistoriesPayload"];
};
};
responses: {
/** @description Successful Response */
200: {
content: {
"application/json": (
| components["schemas"]["HistoryDetailed"]
| components["schemas"]["HistorySummary"]
| components["schemas"]["HistoryMinimal"]
)[];
};
};
/** @description Validation Error */
422: {
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
count_api_histories_count_get: {
/** Returns number of histories for the current user. */
parameters?: {
Expand Down
79 changes: 74 additions & 5 deletions client/src/components/Grid/GridList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import { library } from "@fortawesome/fontawesome-svg-core";
import { faCaretDown, faCaretUp, faShieldAlt } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { useDebounceFn, useEventBus } from "@vueuse/core";
import { BAlert, BButton, BPagination } from "bootstrap-vue";
import { BAlert, BButton, BFormCheckbox, BPagination } from "bootstrap-vue";
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { useRouter } from "vue-router/composables";
import { FieldHandler, GridConfig, Operation, RowData } from "./configs/types";
import { BatchOperation, FieldHandler, GridConfig, Operation, RowData } from "./configs/types";
import GridBoolean from "./GridElements/GridBoolean.vue";
import GridDatasets from "./GridElements/GridDatasets.vue";
Expand Down Expand Up @@ -49,6 +49,11 @@ const errorMessage = ref("");
const operationMessage = ref("");
const operationStatus = ref("");
// selection references
const selected = ref(new Set<RowData>());
const selectedAll = computed(() => gridData.value.length === selected.value.size);
const selectedIndeterminate = computed(() => ![0, gridData.value.length].includes(selected.value.size));
// page references
const currentPage = ref(1);
const totalRows = ref(0);
Expand Down Expand Up @@ -98,6 +103,7 @@ function displayInitialMessage() {
* Request grid data
*/
async function getGridData() {
selected.value = new Set();
if (props.gridConfig) {
try {
const offset = props.limit * (currentPage.value - 1);
Expand All @@ -122,6 +128,15 @@ async function getGridData() {
/**
* Execute grid operation and display message if available
*/
async function onBatchOperation(operation: BatchOperation, rowDataArray: Array<RowData>) {
const response = await operation.handler(rowDataArray);
if (response) {
await getGridData();
operationMessage.value = response.message;
operationStatus.value = response.status || "success";
}
}
async function onOperation(operation: Operation, rowData: RowData) {
const response = await operation.handler(rowData);
if (response) {
Expand Down Expand Up @@ -172,6 +187,24 @@ function onFilter(filter?: string) {
}
}
// Select multiple rows
function onSelect(rowData: RowData) {
if (selected.value.has(rowData)) {
selected.value.delete(rowData);
} else {
selected.value.add(rowData);
}
selected.value = new Set(selected.value);
}
function onSelectAll(current: boolean): void {
if (current) {
selected.value = new Set(gridData.value);
} else {
selected.value = new Set();
}
}
/**
* Initialize grid data
*/
Expand Down Expand Up @@ -242,6 +275,13 @@ watch(operationMessage, () => {
</BAlert>
<table v-else class="grid-table">
<thead>
<th v-if="!!gridConfig.batch">
<BFormCheckbox
class="m-2"
:checked="selectedAll"
:indeterminate="selectedIndeterminate"
@change="onSelectAll" />
</th>
<th
v-for="(fieldEntry, fieldIndex) in gridConfig.fields"
:key="fieldIndex"
Expand All @@ -264,6 +304,13 @@ watch(operationMessage, () => {
</th>
</thead>
<tr v-for="(rowData, rowIndex) in gridData" :key="rowIndex" :class="{ 'grid-dark-row': rowIndex % 2 }">
<td v-if="!!gridConfig.batch">
<BFormCheckbox
:checked="selected.has(rowData)"
class="m-2 cursor-pointer"
data-description="grid selected"
@change="onSelect(rowData)" />
</td>
<td
v-for="(fieldEntry, fieldIndex) in gridConfig.fields"
:key="fieldIndex"
Expand Down Expand Up @@ -304,8 +351,30 @@ watch(operationMessage, () => {
</tr>
</table>
<div class="flex-grow-1 h-100" />
<div v-if="isAvailable" class="grid-footer d-flex justify-content-center pt-3">
<BPagination v-model="currentPage" :total-rows="totalRows" :per-page="limit" class="m-0" size="sm" />
<div class="grid-footer">
<div v-if="isAvailable && gridConfig.batch" class="d-flex justify-content-between pt-3">
<div class="d-flex">
<div v-for="(batchOperation, batchIndex) in gridConfig.batch" :key="batchIndex">
<BButton
v-if="
selected.size > 0 &&
(!batchOperation.condition || batchOperation.condition(Array.from(selected)))
"
class="mr-2"
size="sm"
variant="primary"
:data-description="`grid batch ${batchOperation.title.toLowerCase()}`"
@click="onBatchOperation(batchOperation, Array.from(selected))">
<Icon :icon="batchOperation.icon" class="mr-1" />
<span v-localize>{{ batchOperation.title }} ({{ selected.size }})</span>
</BButton>
</div>
</div>
<BPagination v-model="currentPage" :total-rows="totalRows" :per-page="limit" class="m-0" size="sm" />
</div>
<div v-else-if="isAvailable" class="d-flex justify-content-center pt-3">
<BPagination v-model="currentPage" :total-rows="totalRows" :per-page="limit" class="m-0" size="sm" />
</div>
</div>
</div>
</template>
Expand All @@ -322,7 +391,7 @@ watch(operationMessage, () => {
top: 0;
}
.grid-sticky {
z-index: 1;
z-index: 2;
background: $white;
opacity: 0.95;
position: sticky;
Expand Down
75 changes: 73 additions & 2 deletions client/src/components/Grid/configs/histories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ import {
} from "@fortawesome/free-solid-svg-icons";
import { useEventBus } from "@vueuse/core";

import { deleteHistory, historiesQuery, undeleteHistory } from "@/api/histories";
import { deleteHistories, deleteHistory, historiesQuery, undeleteHistories, undeleteHistory } from "@/api/histories";
import { updateTags } from "@/api/tags";
import { useHistoryStore } from "@/stores/historyStore";
import Filtering, { contains, equals, expandNameTag, toBool, type ValidFilter } from "@/utils/filtering";
import _l from "@/utils/localization";
import { errorMessageAsString, rethrowSimple } from "@/utils/simple-error";

import type { ActionArray, FieldArray, GridConfig } from "./types";
import type { ActionArray, BatchOperationArray, FieldArray, GridConfig } from "./types";

const { emit } = useEventBus<string>("grid-router-push");

Expand Down Expand Up @@ -57,6 +57,76 @@ const actions: ActionArray = [
},
];

// Batch operation
const batch: BatchOperationArray = [
{
title: "Delete",
icon: faTrash,
condition: (data: Array<HistoryEntry>) => !data.some((x) => x.deleted),
handler: async (data: Array<HistoryEntry>) => {
if (confirm(_l(`Are you sure that you want to delete the selected histories?`))) {
try {
const historyIds = data.map((x) => String(x.id));
await deleteHistories({ ids: historyIds });
return {
status: "success",
message: `Deleted ${data.length} histories.`,
};
} catch (e) {
return {
status: "danger",
message: `Failed to delete histories: ${errorMessageAsString(e)}`,
};
}
}
},
},
{
title: "Restore",
icon: faTrashRestore,
condition: (data: Array<HistoryEntry>) => !data.some((x) => !x.deleted || x.purged),
handler: async (data: Array<HistoryEntry>) => {
if (confirm(_l(`Are you sure that you want to restore the selected histories?`))) {
try {
const historyIds = data.map((x) => String(x.id));
await undeleteHistories({ ids: historyIds });
return {
status: "success",
message: `Restored ${data.length} histories.`,
};
} catch (e) {
return {
status: "danger",
message: `Failed to restore histories: ${errorMessageAsString(e)}`,
};
}
}
},
},
{
title: "Purge",
icon: faTrash,
condition: (data: Array<HistoryEntry>) => !data.some((x) => x.purged),
handler: async (data: Array<HistoryEntry>) => {
if (confirm(_l(`Are you sure that you want to permanently delete the selected histories?`))) {
try {
const historyIds = data.map((x) => String(x.id));
await deleteHistories({ ids: historyIds, purge: true });
return {
status: "success",
message: `Purged ${data.length} histories.`,
};
} catch (e) {
return {
status: "danger",
message: `Failed to delete histories: ${errorMessageAsString(e)}`,
};
}
}
},
},
];

/**
* Declare columns to be displayed
*/
Expand Down Expand Up @@ -258,6 +328,7 @@ const gridConfig: GridConfig = {
fields: fields,
filtering: new Filtering(validFilters, undefined, false, false),
getData: getData,
batch: batch,
plural: "Histories",
sortBy: "update_time",
sortDesc: true,
Expand Down
Loading

0 comments on commit da12de9

Please sign in to comment.