feat(core): Change workflow deletions to soft deletes (#14894)

Adds soft‑deletion support for workflows through a new boolean column `isArchived`.

When a workflow is archived we now set `isArchived` flag to true and the workflows
stays in the database and is omitted from the default workflow listing query.

Archived workflows can be viewed in read-only mode, but they cannot be activated.

Archived workflows are still available by ID and can be invoked as sub-executions,
so existing Execute Workflow nodes continue to work. Execution engine doesn't
care about isArchived flag.

Users can restore workflows via Unarchive action at the UI.
This commit is contained in:
Jaakko Husso
2025-05-06 17:48:24 +03:00
committed by GitHub
parent 32b72011e6
commit 3a13139f78
64 changed files with 1616 additions and 124 deletions

View File

@@ -72,13 +72,14 @@ const FILTERS_DEBOUNCE_TIME = 100;
interface Filters extends BaseFilters {
status: string | boolean;
showArchived: boolean;
tags: string[];
}
const StatusFilter = {
ALL: '',
ACTIVE: 'active',
DEACTIVATED: 'deactivated',
ALL: '',
};
/** Maps sort values from the ResourcesListLayout component to values expected by workflows endpoint */
@@ -121,6 +122,7 @@ const filters = ref<Filters>({
search: '',
homeProject: '',
status: StatusFilter.ALL,
showArchived: false,
tags: [],
});
@@ -281,13 +283,14 @@ const workflowListResources = computed<Resource[]>(() => {
workflowCount: resource.workflowCount,
subFolderCount: resource.subFolderCount,
parentFolder: resource.parentFolder,
} as FolderResource;
} satisfies FolderResource;
} else {
return {
resourceType: 'workflow',
id: resource.id,
name: resource.name,
active: resource.active ?? false,
isArchived: resource.isArchived,
updatedAt: resource.updatedAt.toString(),
createdAt: resource.createdAt.toString(),
homeProject: resource.homeProject,
@@ -296,7 +299,7 @@ const workflowListResources = computed<Resource[]>(() => {
readOnly: !getResourcePermissions(resource.scopes).workflow.update,
tags: resource.tags,
parentFolder: resource.parentFolder,
} as WorkflowResource;
} satisfies WorkflowResource;
}
});
return resources;
@@ -345,6 +348,7 @@ const hasFilters = computed(() => {
return !!(
filters.value.search ||
filters.value.status !== StatusFilter.ALL ||
filters.value.showArchived ||
filters.value.tags.length
);
});
@@ -375,6 +379,7 @@ watch(
async (newVal) => {
currentFolderId.value = newVal as string;
filters.value.search = '';
saveFiltersOnQueryString();
await fetchWorkflows();
},
);
@@ -384,7 +389,7 @@ sourceControlStore.$onAction(({ name, after }) => {
after(async () => await initialize());
});
const onWorkflowDeleted = async () => {
const refreshWorkflows = async () => {
await Promise.all([
fetchWorkflows(),
foldersStore.fetchTotalWorkflowsAndFoldersCount(route.params.projectId as string | undefined),
@@ -483,11 +488,14 @@ const fetchWorkflows = async () => {
const tags = filters.value.tags.length
? filters.value.tags.map((tagId) => tagsStore.tagsById[tagId]?.name)
: [];
const activeFilter =
filters.value.status === StatusFilter.ALL
? undefined
: filters.value.status === StatusFilter.ACTIVE;
const archivedFilter = filters.value.showArchived ? undefined : false;
// Only fetch folders if showFolders is enabled and there are not tags or active filter applied
const fetchFolders = showFolders.value && !tags.length && activeFilter === undefined;
@@ -500,6 +508,7 @@ const fetchWorkflows = async () => {
{
name: filters.value.search || undefined,
active: activeFilter,
isArchived: archivedFilter,
tags: tags.length ? tags : undefined,
parentFolderId:
parentFolder ??
@@ -609,6 +618,12 @@ const saveFiltersOnQueryString = () => {
delete currentQuery.status;
}
if (filters.value.showArchived) {
currentQuery.showArchived = 'true';
} else {
delete currentQuery.showArchived;
}
if (filters.value.tags.length) {
currentQuery.tags = filters.value.tags.join(',');
} else {
@@ -628,7 +643,7 @@ const saveFiltersOnQueryString = () => {
const setFiltersFromQueryString = async () => {
const newQuery: LocationQueryRaw = { ...route.query };
const { tags, status, search, homeProject, sort } = route.query ?? {};
const { tags, status, search, homeProject, sort, showArchived } = route.query ?? {};
// Helper to check if string value is not empty
const isValidString = (value: unknown): value is string =>
@@ -673,8 +688,7 @@ const setFiltersFromQueryString = async () => {
}
// Handle status
const validStatusValues = ['true', 'false'];
if (isValidString(status) && validStatusValues.includes(status)) {
if (isValidString(status)) {
newQuery.status = status;
filters.value.status = status === 'true' ? StatusFilter.ACTIVE : StatusFilter.DEACTIVATED;
} else {
@@ -690,6 +704,14 @@ const setFiltersFromQueryString = async () => {
delete newQuery.sort;
}
if (isValidString(showArchived)) {
newQuery.showArchived = showArchived;
filters.value.showArchived = showArchived === 'true';
} else {
delete newQuery.showArchived;
filters.value.showArchived = false;
}
void router.replace({ query: newQuery });
};
@@ -1531,7 +1553,9 @@ const onNameSubmit = async ({
:show-ownership-badge="showCardsBadge"
data-target="workflow"
@click:tag="onClickTag"
@workflow:deleted="onWorkflowDeleted"
@workflow:deleted="refreshWorkflows"
@workflow:archived="refreshWorkflows"
@workflow:unarchived="refreshWorkflows"
@workflow:moved="fetchWorkflows"
@workflow:duplicated="fetchWorkflows"
@workflow:active-toggle="onWorkflowActiveToggle"
@@ -1623,6 +1647,14 @@ const onNameSubmit = async ({
</N8nOption>
</N8nSelect>
</div>
<div class="mb-s">
<N8nCheckbox
:label="i18n.baseText('workflows.filters.showArchived')"
:model-value="filters.showArchived || false"
data-test-id="show-archived-checkbox"
@update:model-value="setKeyValue('showArchived', $event)"
/>
</div>
</template>
<template #postamble>
<div