mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
fix(editor): Simplifying empty project deletion (#15834)
This commit is contained in:
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user