Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds delete, purge and undelete batch operations to History Grid #17282

Merged
merged 15 commits into from
Jan 30, 2024
Merged
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
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 @@ -3985,6 +3993,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 @@ -10354,6 +10376,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 @@ -13832,6 +13862,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
Loading