diff --git a/src/designer/elsa-workflows-studio/src/components/screens/workflow-instances-list/elsa-workflow-instances-list-screen/elsa-workflow-instances-list-screen.tsx b/src/designer/elsa-workflows-studio/src/components/screens/workflow-instances-list/elsa-workflow-instances-list-screen/elsa-workflow-instances-list-screen.tsx index d13746ac5..12e3a6f08 100644 --- a/src/designer/elsa-workflows-studio/src/components/screens/workflow-instances-list/elsa-workflow-instances-list-screen/elsa-workflow-instances-list-screen.tsx +++ b/src/designer/elsa-workflows-studio/src/components/screens/workflow-instances-list/elsa-workflow-instances-list-screen/elsa-workflow-instances-list-screen.tsx @@ -3,7 +3,7 @@ import {LocationSegments, RouterHistory} from "@stencil/router"; import * as collection from 'lodash/collection'; import * as array from 'lodash/array'; import {createElsaClient} from "../../../../services/elsa-client"; -import {OrderBy, PagedList, VersionOptions, WorkflowBlueprintSummary, WorkflowInstanceSummary, WorkflowStatus} from "../../../../models"; +import {OrderBy, PagedList, VersionOptions, WorkflowBlueprintSummary, WorkflowDefinitionSummary, WorkflowInstanceSummary, WorkflowStatus} from "../../../../models"; import {DropdownButtonItem, DropdownButtonOrigin} from "../../../controls/elsa-dropdown-button/models"; import {Map, parseQuery} from '../../../../utils/utils'; @@ -32,10 +32,10 @@ export class ElsaWorkflowInstancesListScreen { await this.loadWorkflowBlueprints(); await this.loadWorkflowInstances(); } - - applyQueryString(queryString?: string){ + + applyQueryString(queryString?: string) { const query = parseQuery(queryString); - + this.selectedWorkflowId = query.workflow; this.selectedWorkflowStatus = query.status; this.selectedOrderBy = query.orderBy ?? OrderBy.Started; @@ -52,7 +52,7 @@ export class ElsaWorkflowInstancesListScreen { const elsaClient = this.createClient(); const page = 1; const pageSize = 25; - + this.workflowInstances = await elsaClient.workflowInstancesApi.list(page, pageSize, this.selectedWorkflowId, this.selectedWorkflowStatus, this.selectedOrderBy); } @@ -100,25 +100,25 @@ export class ElsaWorkflowInstancesListScreen { } async routeChanged(e: LocationSegments) { - if(!e.pathname.toLowerCase().indexOf('workflow-instances')) + if (!e.pathname.toLowerCase().indexOf('workflow-instances')) return; this.applyQueryString(e.search); await this.loadWorkflowInstances(); } - onSelectAllCheckChange(e: Event){ + onSelectAllCheckChange(e: Event) { const checkBox = e.target as HTMLInputElement; const isChecked = checkBox.checked; this.selectAllChecked = isChecked; this.selectedWorkflowInstanceIds = []; - - if(isChecked) + + if (isChecked) this.selectedWorkflowInstanceIds = this.workflowInstances.items.map(x => x.id); } - onWorkflowInstanceCheckChange(e: Event, workflowInstance: WorkflowInstanceSummary){ + onWorkflowInstanceCheckChange(e: Event, workflowInstance: WorkflowInstanceSummary) { const checkBox = e.target as HTMLInputElement; const isChecked = checkBox.checked; @@ -130,10 +130,60 @@ export class ElsaWorkflowInstancesListScreen { this.selectAllChecked = this.workflowInstances.items.findIndex(x => this.selectedWorkflowInstanceIds.findIndex(id => id == x.id) < 0) < 0; } + async onDeleteClick(e: Event, workflowInstance: WorkflowInstanceSummary) { + const result = await this.confirmDialog.show('Delete Workflow Instance', 'Are you sure you wish to permanently delete this workflow instance?'); + + if (!result) + return; + + const elsaClient = this.createClient(); + await elsaClient.workflowInstancesApi.delete(workflowInstance.id); + await this.loadWorkflowInstances(); + } + + async onBulkDelete(){ + const result = await this.confirmDialog.show('Delete Selected Workflow Instances', 'Are you sure you wish to permanently delete all selected workflow instances?'); + + if (!result) + return; + + const elsaClient = this.createClient(); + await elsaClient.workflowInstancesApi.bulkDelete({workflowInstanceIds: this.selectedWorkflowInstanceIds}); + await this.loadWorkflowInstances(); + } + + async onBulkActionSelected(e: CustomEvent) { + const action = e.detail; + + switch(action.name) + { + case 'Delete': + await this.onBulkDelete(); + } + } + render() { const workflowInstances = this.workflowInstances.items; const workflowBlueprints = this.workflowBlueprints; + const viewIcon = ( + + + + + ); + + const deleteIcon = ( + + + + + + + + + ); + return (
@@ -147,7 +197,7 @@ export class ElsaWorkflowInstancesListScreen { {this.renderStatusFilter()} {this.renderOrderByFilter()}
- +
@@ -191,7 +241,6 @@ export class ElsaWorkflowInstancesListScreen { const workflowBlueprint = workflowBlueprints.find(x => x.id == workflowInstance.definitionId && x.version == workflowInstance.version) ?? {displayName: '(Workflow definition not found)'}; const displayName = workflowBlueprint.displayName; const statusColor = this.getStatusColor(workflowInstance.workflowStatus); - debugger; const viewUrl = `/workflow-instances/${workflowInstance.id}/viewer`; const instanceName = !workflowInstance.name ? '' : workflowInstance.name; const isSelected = this.selectedWorkflowInstanceIds.findIndex(x => x === workflowInstance.id) >= 0; @@ -199,7 +248,8 @@ export class ElsaWorkflowInstancesListScreen { return })} @@ -255,10 +308,10 @@ export class ElsaWorkflowInstancesListScreen { const actions: Array = [{ text: 'Delete', - name: 'Delete' + name: 'Delete', }]; - return + return this.onBulkActionSelected(e)}/> } renderWorkflowFilter() { @@ -271,7 +324,7 @@ export class ElsaWorkflowInstancesListScreen { let items: Array = latestWorkflowBlueprints.map(x => ({text: x.displayName!, value: x.id, url: this.buildFilterUrl(x.id, selectedWorkflowStatus, SelectedOrderBy), isSelected: x.id == selectedWorkflowId})); items = [{text: 'All', value: null, url: this.buildFilterUrl(null, selectedWorkflowStatus, SelectedOrderBy), isSelected: !selectedWorkflowId}, ...items]; - + const icon = @@ -282,32 +335,33 @@ export class ElsaWorkflowInstancesListScreen { return this.selectedWorkflowId = e.detail.value as string}/> } - - renderStatusFilter(){ + + renderStatusFilter() { const selectedWorkflowStatus = this.selectedWorkflowStatus; const selectedWorkflowStatusText = !!selectedWorkflowStatus ? selectedWorkflowStatus : 'Status'; const statuses: Array = [null, WorkflowStatus.Running, WorkflowStatus.Suspended, WorkflowStatus.Finished, WorkflowStatus.Faulted, WorkflowStatus.Cancelled, WorkflowStatus.Idle]; - + const items: Array = statuses.map(x => { const text = x ?? 'All'; return ({text: text, url: this.buildFilterUrl(this.selectedWorkflowId, x, this.selectedOrderBy), isSelected: x == selectedWorkflowStatus}); }); - + const icon = - + + ; - + return } - renderOrderByFilter(){ + renderOrderByFilter() { const selectedOrderBy = this.selectedOrderBy; const selectedOrderByText = !!selectedOrderBy ? `Sort by: ${selectedOrderBy}` : 'Sort'; const orderByValues: Array = [OrderBy.Finished, OrderBy.LastExecuted, OrderBy.Started]; const items: Array = orderByValues.map(x => { return ({text: x, url: this.buildFilterUrl(this.selectedWorkflowId, this.selectedWorkflowStatus, x), isSelected: x == selectedOrderBy}); }); - + const icon = ; diff --git a/src/designer/elsa-workflows-studio/src/services/elsa-client.ts b/src/designer/elsa-workflows-studio/src/services/elsa-client.ts index 74281f6a3..f5836776f 100644 --- a/src/designer/elsa-workflows-studio/src/services/elsa-client.ts +++ b/src/designer/elsa-workflows-studio/src/services/elsa-client.ts @@ -82,27 +82,36 @@ export const createElsaClient = function (serverUrl: string): ElsaClient { const response = await httpClient.get>(`v1/workflow-registry?version=${versionOptionsString}`); return response.data; } - }, + }, workflowInstancesApi: { list: async (page?: number, pageSize?: number, workflowDefinitionId?: string, workflowStatus?: WorkflowStatus, orderBy?: OrderBy, searchTerm?: string): Promise> => { const queryString = {}; - - if(!!workflowDefinitionId) + + if (!!workflowDefinitionId) queryString['workflow'] = workflowDefinitionId; - - if(workflowStatus != null) + + if (workflowStatus != null) queryString['status'] = workflowStatus; - - if(!!orderBy) + + if (!!orderBy) queryString['orderBy'] = orderBy; - - if(!!searchTerm) + + if (!!searchTerm) queryString['searchTerm'] = searchTerm; - + const queryStringItems = collection.map(queryString, (v, k) => `${k}=${v}`); const queryStringText = queryStringItems.length > 0 ? `?${queryStringItems.join('&')}` : ''; const response = await httpClient.get>(`v1/workflow-instances${queryStringText}`); return response.data; + }, + delete: async id => { + await httpClient.delete(`v1/workflow-instances/${id}`); + }, + bulkDelete: async request => { + const response = await httpClient.delete(`v1/workflow-instances/bulk`, { + data: request + }); + return response.data; } }, scriptingApi: { @@ -128,27 +137,39 @@ export interface ActivitiesApi { } export interface WorkflowDefinitionsApi { - getByDefinitionAndVersion(definitionId: string, versionOptions: VersionOptions): Promise + getByDefinitionAndVersion(definitionId: string, versionOptions: VersionOptions): Promise; - list(page?: number, pageSize?: number, versionOptions?: VersionOptions): Promise> + list(page?: number, pageSize?: number, versionOptions?: VersionOptions): Promise>; - save(request: SaveWorkflowDefinitionRequest): Promise + save(request: SaveWorkflowDefinitionRequest): Promise; - delete(definitionId: string): Promise + delete(definitionId: string): Promise; - retract(workflowDefinitionId: string): Promise + retract(workflowDefinitionId: string): Promise; - export(workflowDefinitionId: string, versionOptions: VersionOptions): Promise + export(workflowDefinitionId: string, versionOptions: VersionOptions): Promise; - import(workflowDefinitionId: string, file: File): Promise + import(workflowDefinitionId: string, file: File): Promise; } export interface WorkflowRegistryApi { - list(page?: number, pageSize?: number, versionOptions?: VersionOptions): Promise> + list(page?: number, pageSize?: number, versionOptions?: VersionOptions): Promise>; } export interface WorkflowInstancesApi { - list(page?: number, pageSize?: number, workflowDefinitionId?: string, workflowStatus?: WorkflowStatus, orderBy?: OrderBy, searchTerm?: string): Promise> + list(page?: number, pageSize?: number, workflowDefinitionId?: string, workflowStatus?: WorkflowStatus, orderBy?: OrderBy, searchTerm?: string): Promise>; + + delete(id: string): Promise; + + bulkDelete(request: BulkDeleteWorkflowsRequest): Promise; +} + +export interface BulkDeleteWorkflowsRequest { + workflowInstanceIds: Array; +} + +export interface BulkDeleteWorkflowsResponse { + deletedWorkflowCount: number; } export interface ScriptingApi { @@ -156,18 +177,18 @@ export interface ScriptingApi { } export interface SaveWorkflowDefinitionRequest { - workflowDefinitionId?: string - name?: string - displayName?: string - description?: string - variables?: Variables - contextOptions?: WorkflowContextOptions - isSingleton?: boolean - persistenceBehavior?: WorkflowPersistenceBehavior - deleteCompletedInstances?: boolean - publish?: boolean - activities: Array - connections: Array + workflowDefinitionId?: string; + name?: string; + displayName?: string; + description?: string; + variables?: Variables; + contextOptions?: WorkflowContextOptions; + isSingleton?: boolean; + persistenceBehavior?: WorkflowPersistenceBehavior; + deleteCompletedInstances?: boolean; + publish?: boolean; + activities: Array; + connections: Array; } export interface ExportWorkflowResponse { diff --git a/src/persistence/Elsa.Persistence.EntityFramework/Elsa.Persistence.EntityFramework.Core/Elsa.Persistence.EntityFramework.Core.csproj b/src/persistence/Elsa.Persistence.EntityFramework/Elsa.Persistence.EntityFramework.Core/Elsa.Persistence.EntityFramework.Core.csproj index fad3fde8b..22e3a3a30 100644 --- a/src/persistence/Elsa.Persistence.EntityFramework/Elsa.Persistence.EntityFramework.Core/Elsa.Persistence.EntityFramework.Core.csproj +++ b/src/persistence/Elsa.Persistence.EntityFramework/Elsa.Persistence.EntityFramework.Core/Elsa.Persistence.EntityFramework.Core.csproj @@ -1,8 +1,8 @@ - - - + + + netstandard2.1 @@ -13,12 +13,12 @@ - - + + - + diff --git a/src/persistence/Elsa.Persistence.EntityFramework/Elsa.Persistence.EntityFramework.Core/ElsaContext.cs b/src/persistence/Elsa.Persistence.EntityFramework/Elsa.Persistence.EntityFramework.Core/ElsaContext.cs index c2e2a7e2a..3ef7f5605 100644 --- a/src/persistence/Elsa.Persistence.EntityFramework/Elsa.Persistence.EntityFramework.Core/ElsaContext.cs +++ b/src/persistence/Elsa.Persistence.EntityFramework/Elsa.Persistence.EntityFramework.Core/ElsaContext.cs @@ -1,6 +1,7 @@ using System.Linq; using Elsa.Models; using Elsa.Persistence.EntityFramework.Core.Configuration; +using Elsa.Persistence.EntityFramework.Core.Extensions; using Microsoft.EntityFrameworkCore; using NodaTime; @@ -21,7 +22,7 @@ namespace Elsa.Persistence.EntityFramework.Core { modelBuilder.ApplyConfigurationsFromAssembly(typeof(ElsaContext).Assembly); - if (Database.ProviderName == "Microsoft.EntityFrameworkCore.Sqlite") + if (Database.IsSqlite()) { // SQLite does not have proper support for DateTimeOffset via Entity Framework Core, see the limitations // here: https://docs.microsoft.com/en-us/ef/core/providers/sqlite/limitations#query-limitations diff --git a/src/persistence/Elsa.Persistence.EntityFramework/Elsa.Persistence.EntityFramework.Core/Stores/EntityFrameworkStore.cs b/src/persistence/Elsa.Persistence.EntityFramework/Elsa.Persistence.EntityFramework.Core/Stores/EntityFrameworkStore.cs index 8dad06eab..8a4341a92 100644 --- a/src/persistence/Elsa.Persistence.EntityFramework/Elsa.Persistence.EntityFramework.Core/Stores/EntityFrameworkStore.cs +++ b/src/persistence/Elsa.Persistence.EntityFramework/Elsa.Persistence.EntityFramework.Core/Stores/EntityFrameworkStore.cs @@ -1,11 +1,14 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; using System.Threading; using System.Threading.Tasks; using AutoMapper; +using EFCore.BulkExtensions; using Elsa.Models; +using Elsa.Persistence.EntityFramework.Core.Extensions; using Elsa.Persistence.Specifications; using Microsoft.EntityFrameworkCore; @@ -34,7 +37,7 @@ namespace Elsa.Persistence.EntityFramework.Core.Stores { var dbSet = dbContext.Set(); var existingEntity = await dbSet.FindAsync(new object[] { entity.Id }, cancellationToken); - + if (existingEntity == null) { await dbSet.AddAsync(entity, cancellationToken); @@ -44,7 +47,7 @@ namespace Elsa.Persistence.EntityFramework.Core.Stores { // Can't use the approach on the next line because we explicitly ignore certain properties (in order for them to be stored in the Data shadow property). // dbContext.Entry(existingEntity).CurrentValues.SetValues(entity); - + // Therefore using AutoMapper to copy properties instead. existingEntity = _mapper.Map(entity, existingEntity); } @@ -94,12 +97,12 @@ namespace Elsa.Persistence.EntityFramework.Core.Stores }, cancellationToken); } - public virtual async Task DeleteAsync(T entity, CancellationToken cancellationToken = default) => await DoWorkOnSet(async dbSet => await dbSet.DeleteByKeyAsync(cancellationToken, entity.Id), cancellationToken); + public virtual async Task DeleteAsync(T entity, CancellationToken cancellationToken = default) => await DoWorkOnSet(async dbSet => await dbSet.AsQueryable().Where(x => x.Id == entity.Id).BatchDeleteAsync(cancellationToken), cancellationToken); public virtual async Task DeleteManyAsync(ISpecification specification, CancellationToken cancellationToken = default) { var filter = MapSpecification(specification); - return await DoWorkOnSet(async dbSet => await dbSet.Where(filter).DeleteFromQueryAsync(cancellationToken), cancellationToken); + return await DoWorkOnSet(async dbSet => await dbSet.Where(filter).BatchDeleteAsync(cancellationToken), cancellationToken); } public async Task> FindManyAsync(ISpecification specification, IOrderBy? orderBy = default, IPaging? paging = default, CancellationToken cancellationToken = default) @@ -169,7 +172,7 @@ namespace Elsa.Persistence.EntityFramework.Core.Stores work(dbContext); await dbContext.SaveChangesAsync(cancellationToken); } - + protected virtual void OnSaving(ElsaContext dbContext, T entity) => OnSaving(entity); protected virtual void OnSaving(T entity) diff --git a/src/persistence/Elsa.Persistence.EntityFramework/Elsa.Persistence.EntityFramework.Core/Stores/EntityFrameworkWorkflowInstanceStore.cs b/src/persistence/Elsa.Persistence.EntityFramework/Elsa.Persistence.EntityFramework.Core/Stores/EntityFrameworkWorkflowInstanceStore.cs index 3b7b4550c..244eb64d8 100644 --- a/src/persistence/Elsa.Persistence.EntityFramework/Elsa.Persistence.EntityFramework.Core/Stores/EntityFrameworkWorkflowInstanceStore.cs +++ b/src/persistence/Elsa.Persistence.EntityFramework/Elsa.Persistence.EntityFramework.Core/Stores/EntityFrameworkWorkflowInstanceStore.cs @@ -4,6 +4,7 @@ using System.Linq.Expressions; using System.Threading; using System.Threading.Tasks; using AutoMapper; +using EFCore.BulkExtensions; using Elsa.Models; using Elsa.Persistence.Specifications; using Elsa.Serialization; @@ -24,25 +25,25 @@ namespace Elsa.Persistence.EntityFramework.Core.Stores public override async Task DeleteAsync(WorkflowInstance entity, CancellationToken cancellationToken = default) { var workflowInstanceId = entity.Id; - + await DoWork(async dbContext => { - await dbContext.Set().AsQueryable().Where(x => x.WorkflowInstanceId == workflowInstanceId).DeleteFromQueryAsync(cancellationToken); - await dbContext.Set().AsQueryable().Where(x => x.WorkflowInstanceId == workflowInstanceId).DeleteFromQueryAsync(cancellationToken); - await dbContext.Set().DeleteByKeyAsync(cancellationToken, workflowInstanceId); + await dbContext.Set().AsQueryable().Where(x => x.WorkflowInstanceId == workflowInstanceId).BatchDeleteAsync(cancellationToken); + await dbContext.Set().AsQueryable().Where(x => x.WorkflowInstanceId == workflowInstanceId).BatchDeleteAsync(cancellationToken); + await dbContext.Set().AsQueryable().Where(x => x.Id == workflowInstanceId).BatchDeleteAsync(cancellationToken); }, cancellationToken); } public override async Task DeleteManyAsync(ISpecification specification, CancellationToken cancellationToken = default) { var workflowInstances = (await FindManyAsync(specification, cancellationToken: cancellationToken)).ToList(); - var workflowInstanceIds = workflowInstances.Select(x => x.Id).ToList(); + var workflowInstanceIds = workflowInstances.Select(x => x.Id).ToArray(); await DoWork(async dbContext => { - await dbContext.Set().WhereBulkContains(workflowInstanceIds, x => x.WorkflowInstanceId).DeleteFromQueryAsync(cancellationToken); - await dbContext.Set().WhereBulkContains(workflowInstanceIds, x => x.WorkflowInstanceId).DeleteFromQueryAsync(cancellationToken); - await dbContext.Set().WhereBulkContains(workflowInstanceIds, x => x.Id).DeleteFromQueryAsync(cancellationToken); + await dbContext.Set().AsQueryable().Where(x => workflowInstanceIds.Contains(x.WorkflowInstanceId)).BatchDeleteAsync(cancellationToken); + await dbContext.Set().AsQueryable().Where(x => workflowInstanceIds.Contains(x.WorkflowInstanceId)).BatchDeleteAsync(cancellationToken); + await dbContext.Set().AsQueryable().Where(x => workflowInstanceIds.Contains(x.Id)).BatchDeleteAsync(cancellationToken); }, cancellationToken); return workflowInstances.Count; @@ -69,7 +70,7 @@ namespace Elsa.Persistence.EntityFramework.Core.Stores }; var json = _contentSerializer.Serialize(data); - + dbContext.Entry(entity).Property("Data").CurrentValue = json; } @@ -87,12 +88,12 @@ namespace Elsa.Persistence.EntityFramework.Core.Stores entity.Fault, entity.CurrentActivity }; - - var json = (string)dbContext.Entry(entity).Property("Data").CurrentValue; - - if(!string.IsNullOrWhiteSpace(json)) - data = JsonConvert.DeserializeAnonymousType(json, data, DefaultContentSerializer.CreateDefaultJsonSerializationSettings()); - + + var json = (string) dbContext.Entry(entity).Property("Data").CurrentValue; + + if (!string.IsNullOrWhiteSpace(json)) + data = JsonConvert.DeserializeAnonymousType(json, data, DefaultContentSerializer.CreateDefaultJsonSerializationSettings())!; + entity.Output = data.Output; entity.Variables = data.Variables; entity.ActivityData = data.ActivityData;
- this.onWorkflowInstanceCheckChange(e, workflowInstance)} class="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded"/> + this.onWorkflowInstanceCheckChange(e, workflowInstance)} + class="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded"/> {workflowInstance.id} @@ -234,7 +284,10 @@ export class ElsaWorkflowInstancesListScreen { {!!workflowInstance.faultedAt ? workflowInstance.faultedAt.toString() : '-'} - + this.onDeleteClick(e, workflowInstance), icon: deleteIcon} + ]}/>