fix(editor): Simplifying empty project deletion (#15834)

This commit is contained in:
Csaba Tuncsik
2025-05-30 10:29:36 +02:00
committed by GitHub
parent 29a41a48a4
commit 6bf2d8a4d4
6 changed files with 160 additions and 55 deletions

View File

@@ -0,0 +1,74 @@
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render';
import ProjectDeleteDialog from '@/components/Projects/ProjectDeleteDialog.vue';
import { createTestProject } from '@/__tests__/data/projects';
const renderComponent = createComponentRenderer(ProjectDeleteDialog, {
pinia: createTestingPinia(),
});
const currentProject = createTestProject({
id: 'xyz123',
name: 'Test',
});
describe('ProjectDeleteDialog', () => {
let user: ReturnType<typeof userEvent.setup>;
beforeEach(() => {
user = userEvent.setup();
});
it('should render the dialog with correct title and content when project is empty', async () => {
const { findByRole, getByRole, queryAllByRole } = renderComponent({
props: {
currentProject,
projects: [],
isCurrentProjectEmpty: true,
modelValue: true,
},
});
const dialog = await findByRole('dialog');
expect(dialog).toBeInTheDocument();
expect(getByRole('heading', { name: 'Delete "Test" Project?' })).toBeInTheDocument();
expect(queryAllByRole('radio')).toHaveLength(0);
expect(getByRole('button', { name: 'Delete this project' })).toBeEnabled();
});
it('should render the dialog with correct title and content when project is not empty', async () => {
const { findByRole, getByRole, getAllByRole, getByTestId, queryByTestId, emitted } =
renderComponent({
props: {
currentProject,
projects: [],
isCurrentProjectEmpty: false,
modelValue: true,
},
});
const dialog = await findByRole('dialog');
expect(dialog).toBeInTheDocument();
expect(getByRole('heading', { name: 'Delete "Test" Project?' })).toBeInTheDocument();
expect(getByRole('button', { name: 'Delete this project' })).toBeDisabled();
expect(getAllByRole('radio')).toHaveLength(2);
await user.click(getAllByRole('radio')[0]);
expect(getByTestId('project-sharing-select')).toBeVisible();
expect(queryByTestId('project-delete-confirm-input')).not.toBeInTheDocument();
expect(getByRole('button', { name: 'Delete this project' })).toBeDisabled();
await user.click(getAllByRole('radio')[1]);
expect(queryByTestId('project-sharing-select')).not.toBeInTheDocument();
expect(getByTestId('project-delete-confirm-input')).toBeVisible();
expect(getByRole('button', { name: 'Delete this project' })).toBeDisabled();
await user.type(getByTestId('project-delete-confirm-input'), 'delete all data');
expect(getByRole('button', { name: 'Delete this project' })).toBeEnabled();
await user.click(getByRole('button', { name: 'Delete this project' }));
expect(emitted()).toHaveProperty('confirmDelete');
});
});

View File

@@ -7,6 +7,7 @@ import { useI18n } from '@/composables/useI18n';
type Props = { type Props = {
currentProject: Project | null; currentProject: Project | null;
projects: ProjectListItem[]; projects: ProjectListItem[];
isCurrentProjectEmpty: boolean;
}; };
const props = defineProps<Props>(); const props = defineProps<Props>();
@@ -21,16 +22,15 @@ const selectedProject = ref<ProjectSharingData | null>(null);
const operation = ref<'transfer' | 'wipe' | null>(null); const operation = ref<'transfer' | 'wipe' | null>(null);
const wipeConfirmText = ref(''); const wipeConfirmText = ref('');
const isValid = computed(() => { const isValid = computed(() => {
if (operation.value === 'transfer') { const expectedWipeConfirmation = locale.baseText(
return !!selectedProject.value; 'projects.settings.delete.question.wipe.placeholder',
} );
if (operation.value === 'wipe') {
return ( return (
wipeConfirmText.value === props.isCurrentProjectEmpty ||
locale.baseText('projects.settings.delete.question.wipe.placeholder') (operation.value === 'transfer' && !!selectedProject.value) ||
); (operation.value === 'wipe' && wipeConfirmText.value === expectedWipeConfirmation)
} );
return false;
}); });
const onDelete = () => { const onDelete = () => {
@@ -55,47 +55,55 @@ const onDelete = () => {
" "
width="500" width="500"
> >
<n8n-text color="text-base">{{ locale.baseText('projects.settings.delete.message') }}</n8n-text> <n8n-text v-if="isCurrentProjectEmpty" color="text-base">{{
<div class="pt-l"> locale.baseText('projects.settings.delete.message.empty')
<el-radio }}</n8n-text>
:model-value="operation" <div v-else>
label="transfer" <n8n-text color="text-base">{{
class="mb-s" locale.baseText('projects.settings.delete.message')
@update:model-value="operation = 'transfer'" }}</n8n-text>
> <div class="pt-l">
<n8n-text color="text-dark">{{ <el-radio
locale.baseText('projects.settings.delete.question.transfer.label') :model-value="operation"
}}</n8n-text> label="transfer"
</el-radio> class="mb-s"
<div v-if="operation === 'transfer'" :class="$style.operation"> @update:model-value="operation = 'transfer'"
<n8n-text color="text-dark">{{ >
locale.baseText('projects.settings.delete.question.transfer.title') <n8n-text color="text-dark">{{
}}</n8n-text> locale.baseText('projects.settings.delete.question.transfer.label')
<ProjectSharing }}</n8n-text>
v-model="selectedProject" </el-radio>
class="pt-2xs" <div v-if="operation === 'transfer'" :class="$style.operation">
:projects="props.projects" <n8n-text color="text-dark">{{
:empty-options-text="locale.baseText('projects.sharing.noMatchingProjects')" locale.baseText('projects.settings.delete.question.transfer.title')
/> }}</n8n-text>
</div> <ProjectSharing
v-model="selectedProject"
<el-radio class="pt-2xs"
:model-value="operation" :projects="props.projects"
label="wipe" :empty-options-text="locale.baseText('projects.sharing.noMatchingProjects')"
class="mb-s"
@update:model-value="operation = 'wipe'"
>
<n8n-text color="text-dark">{{
locale.baseText('projects.settings.delete.question.wipe.label')
}}</n8n-text>
</el-radio>
<div v-if="operation === 'wipe'" :class="$style.operation">
<n8n-input-label :label="locale.baseText('projects.settings.delete.question.wipe.title')">
<n8n-input
v-model="wipeConfirmText"
:placeholder="locale.baseText('projects.settings.delete.question.wipe.placeholder')"
/> />
</n8n-input-label> </div>
<el-radio
:model-value="operation"
label="wipe"
class="mb-s"
@update:model-value="operation = 'wipe'"
>
<n8n-text color="text-dark">{{
locale.baseText('projects.settings.delete.question.wipe.label')
}}</n8n-text>
</el-radio>
<div v-if="operation === 'wipe'" :class="$style.operation">
<n8n-input-label :label="locale.baseText('projects.settings.delete.question.wipe.title')">
<n8n-input
v-model="wipeConfirmText"
data-test-id="project-delete-confirm-input"
:placeholder="locale.baseText('projects.settings.delete.question.wipe.placeholder')"
/>
</n8n-input-label>
</div>
</div> </div>
</div> </div>
<template #footer> <template #footer>

View File

@@ -2798,8 +2798,9 @@
"projects.settings.role.admin": "Admin", "projects.settings.role.admin": "Admin",
"projects.settings.role.editor": "Editor", "projects.settings.role.editor": "Editor",
"projects.settings.role.viewer": "Viewer", "projects.settings.role.viewer": "Viewer",
"projects.settings.delete.title": "Delete {projectName}", "projects.settings.delete.title": "Delete \"{projectName}\" Project?",
"projects.settings.delete.message": "What should we do with the project data?", "projects.settings.delete.message": "What should we do with the project data?",
"projects.settings.delete.message.empty": "There are no workflows or credentials in this project.",
"projects.settings.delete.question.transfer.label": "Transfer its workflows and credentials to another project or user", "projects.settings.delete.question.transfer.label": "Transfer its workflows and credentials to another project or user",
"projects.settings.delete.question.transfer.title": "Project or user to transfer to", "projects.settings.delete.question.transfer.title": "Project or user to transfer to",
"projects.settings.delete.question.wipe.label": "Delete its workflows and credentials", "projects.settings.delete.question.wipe.label": "Delete its workflows and credentials",

View File

@@ -3,7 +3,9 @@ import { ref, watch, computed } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { useRootStore } from '@n8n/stores/useRootStore'; import { useRootStore } from '@n8n/stores/useRootStore';
import * as projectsApi from '@/api/projects.api'; import * as projectsApi from '@/api/projects.api';
import * as workflowsApi from '@/api/workflows';
import * as workflowsEEApi from '@/api/workflows.ee'; import * as workflowsEEApi from '@/api/workflows.ee';
import * as credentialsApi from '@/api/credentials';
import * as credentialsEEApi from '@/api/credentials.ee'; import * as credentialsEEApi from '@/api/credentials.ee';
import type { Project, ProjectListItem, ProjectsCount } from '@/types/projects.types'; import type { Project, ProjectListItem, ProjectsCount } from '@/types/projects.types';
import { ProjectTypes } from '@/types/projects.types'; import { ProjectTypes } from '@/types/projects.types';
@@ -191,6 +193,15 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
} }
}; };
const isProjectEmpty = async (projectId: string) => {
const [credentials, workflows] = await Promise.all([
credentialsApi.getAllCredentials(rootStore.restApiContext, { projectId }),
workflowsApi.getWorkflows(rootStore.restApiContext, { projectId }),
]);
return credentials.length === 0 && workflows.count === 0;
};
watch( watch(
route, route,
async (newRoute) => { async (newRoute) => {
@@ -252,5 +263,6 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
getProjectsCount, getProjectsCount,
setProjectNavActiveIdByWorkflowHomeProject, setProjectNavActiveIdByWorkflowHomeProject,
moveResourceToProject, moveResourceToProject,
isProjectEmpty,
}; };
}); });

View File

@@ -53,6 +53,7 @@ describe('ProjectSettings', () => {
vi.spyOn(usersStore, 'fetchUsers').mockImplementation(async () => await Promise.resolve()); vi.spyOn(usersStore, 'fetchUsers').mockImplementation(async () => await Promise.resolve());
vi.spyOn(projectsStore, 'getAvailableProjects').mockImplementation(async () => {}); vi.spyOn(projectsStore, 'getAvailableProjects').mockImplementation(async () => {});
vi.spyOn(projectsStore, 'availableProjects', 'get').mockReturnValue(projects); vi.spyOn(projectsStore, 'availableProjects', 'get').mockReturnValue(projects);
vi.spyOn(projectsStore, 'isProjectEmpty').mockResolvedValue(false);
vi.spyOn(settingsStore, 'settings', 'get').mockReturnValue({ vi.spyOn(settingsStore, 'settings', 'get').mockReturnValue({
enterprise: { enterprise: {
projects: { projects: {
@@ -82,12 +83,12 @@ describe('ProjectSettings', () => {
.spyOn(projectsStore, 'deleteProject') .spyOn(projectsStore, 'deleteProject')
.mockImplementation(async () => {}); .mockImplementation(async () => {});
const { getByTestId, getByRole } = renderComponent(); const { getByTestId, findByRole } = renderComponent();
const deleteButton = getByTestId('project-settings-delete-button'); const deleteButton = getByTestId('project-settings-delete-button');
await userEvent.click(deleteButton); await userEvent.click(deleteButton);
expect(deleteProjectSpy).not.toHaveBeenCalled(); expect(deleteProjectSpy).not.toHaveBeenCalled();
const modal = getByRole('dialog'); const modal = await findByRole('dialog');
expect(modal).toBeVisible(); expect(modal).toBeVisible();
const confirmButton = getByTestId('project-settings-delete-confirm-button'); const confirmButton = getByTestId('project-settings-delete-confirm-button');
expect(confirmButton).toBeDisabled(); expect(confirmButton).toBeDisabled();
@@ -108,12 +109,12 @@ describe('ProjectSettings', () => {
.spyOn(projectsStore, 'deleteProject') .spyOn(projectsStore, 'deleteProject')
.mockImplementation(async () => {}); .mockImplementation(async () => {});
const { getByTestId, getByRole } = renderComponent(); const { getByTestId, findByRole } = renderComponent();
const deleteButton = getByTestId('project-settings-delete-button'); const deleteButton = getByTestId('project-settings-delete-button');
await userEvent.click(deleteButton); await userEvent.click(deleteButton);
expect(deleteProjectSpy).not.toHaveBeenCalled(); expect(deleteProjectSpy).not.toHaveBeenCalled();
const modal = getByRole('dialog'); const modal = await findByRole('dialog');
expect(modal).toBeVisible(); expect(modal).toBeVisible();
const confirmButton = getByTestId('project-settings-delete-confirm-button'); const confirmButton = getByTestId('project-settings-delete-confirm-button');
expect(confirmButton).toBeDisabled(); expect(confirmButton).toBeDisabled();

View File

@@ -43,6 +43,7 @@ const upgradeDialogVisible = ref(false);
const isDirty = ref(false); const isDirty = ref(false);
const isValid = ref(false); const isValid = ref(false);
const isCurrentProjectEmpty = ref(true);
const formData = ref<Pick<Project, 'name' | 'relations'>>({ const formData = ref<Pick<Project, 'name' | 'relations'>>({
name: '', name: '',
relations: [], relations: [],
@@ -222,6 +223,13 @@ const onSubmit = async () => {
const onDelete = async () => { const onDelete = async () => {
await projectsStore.getAvailableProjects(); await projectsStore.getAvailableProjects();
if (projectsStore.currentProjectId) {
isCurrentProjectEmpty.value = await projectsStore.isProjectEmpty(
projectsStore.currentProjectId,
);
}
dialogVisible.value = true; dialogVisible.value = true;
}; };
@@ -429,6 +437,7 @@ onMounted(() => {
<ProjectDeleteDialog <ProjectDeleteDialog
v-model="dialogVisible" v-model="dialogVisible"
:current-project="projectsStore.currentProject" :current-project="projectsStore.currentProject"
:is-current-project-empty="isCurrentProjectEmpty"
:projects="projects" :projects="projects"
@confirm-delete="onConfirmDelete" @confirm-delete="onConfirmDelete"
/> />