mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
refactor(editor): Move editor-ui and design-system to frontend dir (no-changelog) (#13564)
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue';
|
||||
import { truncate } from '@n8n/utils/string/truncate';
|
||||
|
||||
const renderComponent = createComponentRenderer(ProjectCardBadge);
|
||||
|
||||
describe('ProjectCardBadge', () => {
|
||||
it('should show "Personal" badge if there is no homeProject', () => {
|
||||
const { getByText } = renderComponent({
|
||||
props: {
|
||||
resource: {},
|
||||
personalProject: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByText('Personal')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should show "Personal" badge if homeProject ID equals personalProject ID', () => {
|
||||
const { getByText } = renderComponent({
|
||||
props: {
|
||||
resource: {
|
||||
homeProject: {
|
||||
id: '1',
|
||||
name: 'John',
|
||||
},
|
||||
},
|
||||
resourceType: 'workflow',
|
||||
personalProject: {
|
||||
id: '1',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByText('Personal')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should show shared with count', () => {
|
||||
const { getByText } = renderComponent({
|
||||
props: {
|
||||
resource: {
|
||||
sharedWithProjects: [{}, {}, {}],
|
||||
homeProject: {
|
||||
id: '1',
|
||||
name: 'John',
|
||||
},
|
||||
},
|
||||
resourceType: 'workflow',
|
||||
personalProject: {
|
||||
id: '1',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByText('+ 3')).toBeVisible();
|
||||
});
|
||||
|
||||
test.each([
|
||||
['First Last <email@domain.com>', 'First Last'],
|
||||
['First Last Third <email@domain.com>', 'First Last Third'],
|
||||
['First Last Third Fourth <email@domain.com>', 'First Last Third Fourth'],
|
||||
['<email@domain.com>', 'email@domain.com'],
|
||||
[' <email@domain.com>', 'email@domain.com'],
|
||||
['My project', 'My project'],
|
||||
['MyProject', 'MyProject'],
|
||||
])('should show the correct owner badge for project name: "%s"', (name, result) => {
|
||||
const { getByText } = renderComponent({
|
||||
props: {
|
||||
resource: {
|
||||
homeProject: {
|
||||
id: '1',
|
||||
name,
|
||||
},
|
||||
},
|
||||
resourceType: 'workflow',
|
||||
personalProject: {
|
||||
id: '2',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(getByText(truncate(result, 20))).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import type { ResourceType } from '@/utils/projects.utils';
|
||||
import { splitName } from '@/utils/projects.utils';
|
||||
import type { Project, ProjectIcon as BadgeIcon } from '@/types/projects.types';
|
||||
import { ProjectTypes } from '@/types/projects.types';
|
||||
import type { CredentialsResource, WorkflowResource } from '../layouts/ResourcesListLayout.vue';
|
||||
|
||||
type Props = {
|
||||
resource: WorkflowResource | CredentialsResource;
|
||||
resourceType: ResourceType;
|
||||
resourceTypeLabel: string;
|
||||
personalProject: Project | null;
|
||||
};
|
||||
|
||||
const enum ProjectState {
|
||||
SharedPersonal = 'shared-personal',
|
||||
SharedOwned = 'shared-owned',
|
||||
Owned = 'owned',
|
||||
Personal = 'personal',
|
||||
Team = 'team',
|
||||
SharedTeam = 'shared-team',
|
||||
Unknown = 'unknown',
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const projectState = computed(() => {
|
||||
if (
|
||||
(props.resource.homeProject &&
|
||||
props.personalProject &&
|
||||
props.resource.homeProject.id === props.personalProject.id) ||
|
||||
!props.resource.homeProject
|
||||
) {
|
||||
if (props.resource.sharedWithProjects?.length) {
|
||||
return ProjectState.SharedOwned;
|
||||
}
|
||||
return ProjectState.Owned;
|
||||
} else if (props.resource.homeProject?.type !== ProjectTypes.Team) {
|
||||
if (props.resource.sharedWithProjects?.length) {
|
||||
return ProjectState.SharedPersonal;
|
||||
}
|
||||
return ProjectState.Personal;
|
||||
} else if (props.resource.homeProject?.type === ProjectTypes.Team) {
|
||||
if (props.resource.sharedWithProjects?.length) {
|
||||
return ProjectState.SharedTeam;
|
||||
}
|
||||
return ProjectState.Team;
|
||||
}
|
||||
return ProjectState.Unknown;
|
||||
});
|
||||
|
||||
const numberOfMembersInHomeTeamProject = computed(
|
||||
() => props.resource.sharedWithProjects?.length ?? 0,
|
||||
);
|
||||
|
||||
const badgeText = computed(() => {
|
||||
if (
|
||||
projectState.value === ProjectState.Owned ||
|
||||
projectState.value === ProjectState.SharedOwned
|
||||
) {
|
||||
return i18n.baseText('projects.menu.personal');
|
||||
} else {
|
||||
const { name, email } = splitName(props.resource.homeProject?.name ?? '');
|
||||
return name ?? email ?? '';
|
||||
}
|
||||
});
|
||||
const badgeIcon = computed<BadgeIcon>(() => {
|
||||
switch (projectState.value) {
|
||||
case ProjectState.Owned:
|
||||
case ProjectState.SharedOwned:
|
||||
return { type: 'icon', value: 'user' };
|
||||
case ProjectState.Team:
|
||||
case ProjectState.SharedTeam:
|
||||
return props.resource.homeProject?.icon ?? { type: 'icon', value: 'layer-group' };
|
||||
default:
|
||||
return { type: 'icon', value: 'layer-group' };
|
||||
}
|
||||
});
|
||||
const badgeTooltip = computed(() => {
|
||||
switch (projectState.value) {
|
||||
case ProjectState.SharedOwned:
|
||||
return i18n.baseText('projects.badge.tooltip.sharedOwned', {
|
||||
interpolate: {
|
||||
resourceTypeLabel: props.resourceTypeLabel,
|
||||
count: numberOfMembersInHomeTeamProject.value,
|
||||
},
|
||||
});
|
||||
case ProjectState.SharedPersonal:
|
||||
return i18n.baseText('projects.badge.tooltip.sharedPersonal', {
|
||||
interpolate: {
|
||||
resourceTypeLabel: props.resourceTypeLabel,
|
||||
name: badgeText.value,
|
||||
count: numberOfMembersInHomeTeamProject.value,
|
||||
},
|
||||
});
|
||||
case ProjectState.Personal:
|
||||
return i18n.baseText('projects.badge.tooltip.personal', {
|
||||
interpolate: {
|
||||
resourceTypeLabel: props.resourceTypeLabel,
|
||||
name: badgeText.value,
|
||||
},
|
||||
});
|
||||
case ProjectState.Team:
|
||||
return i18n.baseText('projects.badge.tooltip.team', {
|
||||
interpolate: {
|
||||
resourceTypeLabel: props.resourceTypeLabel,
|
||||
name: badgeText.value,
|
||||
},
|
||||
});
|
||||
case ProjectState.SharedTeam:
|
||||
return i18n.baseText('projects.badge.tooltip.sharedTeam', {
|
||||
interpolate: {
|
||||
resourceTypeLabel: props.resourceTypeLabel,
|
||||
name: badgeText.value,
|
||||
count: numberOfMembersInHomeTeamProject.value,
|
||||
},
|
||||
});
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<N8nTooltip :disabled="!badgeTooltip" placement="top">
|
||||
<div :class="$style.wrapper" v-bind="$attrs">
|
||||
<N8nBadge
|
||||
v-if="badgeText"
|
||||
:class="[$style.badge, $style.projectBadge]"
|
||||
theme="tertiary"
|
||||
bold
|
||||
data-test-id="card-badge"
|
||||
>
|
||||
<ProjectIcon :icon="badgeIcon" :border-less="true" size="mini" />
|
||||
<span v-n8n-truncate:20>{{ badgeText }}</span>
|
||||
</N8nBadge>
|
||||
<N8nBadge
|
||||
v-if="numberOfMembersInHomeTeamProject"
|
||||
:class="[$style.badge, $style.countBadge]"
|
||||
theme="tertiary"
|
||||
bold
|
||||
>
|
||||
+ {{ numberOfMembersInHomeTeamProject }}
|
||||
</N8nBadge>
|
||||
</div>
|
||||
<template #content>
|
||||
{{ badgeTooltip }}
|
||||
</template>
|
||||
</N8nTooltip>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.wrapper {
|
||||
margin-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: var(--spacing-4xs) var(--spacing-2xs);
|
||||
background-color: var(--color-background-xlight);
|
||||
border-color: var(--color-foreground-base);
|
||||
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
height: 23px;
|
||||
:global(.n8n-text) {
|
||||
color: var(--color-text-base);
|
||||
}
|
||||
}
|
||||
|
||||
.projectBadge {
|
||||
& > span {
|
||||
display: flex;
|
||||
gap: var(--spacing-3xs);
|
||||
}
|
||||
}
|
||||
|
||||
.countBadge {
|
||||
margin-left: -5px;
|
||||
z-index: 0;
|
||||
position: relative;
|
||||
height: 23px;
|
||||
:global(.n8n-text) {
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ButtonType } from '@n8n/design-system';
|
||||
import { N8nIconButton, N8nActionToggle } from '@n8n/design-system';
|
||||
import { ref } from 'vue';
|
||||
|
||||
type Action = {
|
||||
label: string;
|
||||
value: string;
|
||||
disabled: boolean;
|
||||
};
|
||||
defineProps<{
|
||||
actions: Action[];
|
||||
disabled?: boolean;
|
||||
type?: ButtonType;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
action: [id: string];
|
||||
}>();
|
||||
|
||||
const actionToggleRef = ref<InstanceType<typeof N8nActionToggle> | null>(null);
|
||||
|
||||
defineExpose({
|
||||
openActionToggle: (isOpen: boolean) => actionToggleRef.value?.openActionToggle(isOpen),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[$style.buttonGroup]">
|
||||
<slot></slot>
|
||||
<N8nActionToggle
|
||||
ref="actionToggleRef"
|
||||
data-test-id="add-resource"
|
||||
:actions="actions"
|
||||
placement="bottom-end"
|
||||
:teleported="false"
|
||||
@action="emit('action', $event)"
|
||||
>
|
||||
<N8nIconButton
|
||||
:disabled="disabled"
|
||||
:class="[$style.buttonGroupDropdown]"
|
||||
icon="angle-down"
|
||||
:type="type ?? 'primary'"
|
||||
/>
|
||||
</N8nActionToggle>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.buttonGroup {
|
||||
display: inline-flex;
|
||||
|
||||
:global(> .button) {
|
||||
border-right: 1px solid var(--button-font-color, var(--color-button-primary-font));
|
||||
|
||||
&:not(:first-child) {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.buttonGroupDropdown {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,118 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import type { Project, ProjectListItem, ProjectSharingData } from '@/types/projects.types';
|
||||
import ProjectSharing from '@/components/Projects/ProjectSharing.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
type Props = {
|
||||
currentProject: Project | null;
|
||||
projects: ProjectListItem[];
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const visible = defineModel<boolean>();
|
||||
const emit = defineEmits<{
|
||||
confirmDelete: [value?: string];
|
||||
}>();
|
||||
|
||||
const locale = useI18n();
|
||||
|
||||
const selectedProject = ref<ProjectSharingData | null>(null);
|
||||
const operation = ref<'transfer' | 'wipe' | null>(null);
|
||||
const wipeConfirmText = ref('');
|
||||
const isValid = computed(() => {
|
||||
if (operation.value === 'transfer') {
|
||||
return !!selectedProject.value;
|
||||
}
|
||||
if (operation.value === 'wipe') {
|
||||
return (
|
||||
wipeConfirmText.value ===
|
||||
locale.baseText('projects.settings.delete.question.wipe.placeholder')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const onDelete = () => {
|
||||
if (!isValid.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation.value === 'wipe') {
|
||||
selectedProject.value = null;
|
||||
}
|
||||
|
||||
emit('confirmDelete', selectedProject.value?.id);
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="
|
||||
locale.baseText('projects.settings.delete.title', {
|
||||
interpolate: { projectName: props.currentProject?.name ?? '' },
|
||||
})
|
||||
"
|
||||
width="500"
|
||||
>
|
||||
<n8n-text color="text-base">{{ locale.baseText('projects.settings.delete.message') }}</n8n-text>
|
||||
<div class="pt-l">
|
||||
<el-radio
|
||||
:model-value="operation"
|
||||
label="transfer"
|
||||
class="mb-s"
|
||||
@update:model-value="operation = 'transfer'"
|
||||
>
|
||||
<n8n-text color="text-dark">{{
|
||||
locale.baseText('projects.settings.delete.question.transfer.label')
|
||||
}}</n8n-text>
|
||||
</el-radio>
|
||||
<div v-if="operation === 'transfer'" :class="$style.operation">
|
||||
<n8n-text color="text-dark">{{
|
||||
locale.baseText('projects.settings.delete.question.transfer.title')
|
||||
}}</n8n-text>
|
||||
<ProjectSharing
|
||||
v-model="selectedProject"
|
||||
class="pt-2xs"
|
||||
:projects="props.projects"
|
||||
:empty-options-text="locale.baseText('projects.sharing.noMatchingProjects')"
|
||||
/>
|
||||
</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"
|
||||
:placeholder="locale.baseText('projects.settings.delete.question.wipe.placeholder')"
|
||||
/>
|
||||
</n8n-input-label>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<N8nButton
|
||||
type="danger"
|
||||
native-type="button"
|
||||
:disabled="!isValid"
|
||||
data-test-id="project-settings-delete-confirm-button"
|
||||
@click.stop.prevent="onDelete"
|
||||
>{{ locale.baseText('projects.settings.danger.deleteProject') }}</N8nButton
|
||||
>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.operation {
|
||||
padding: 0 0 var(--spacing-l) var(--spacing-l);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,199 @@
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import { createTestProject } from '@/__tests__/data/projects';
|
||||
import * as router from 'vue-router';
|
||||
import type { RouteLocationNormalizedLoadedGeneric } from 'vue-router';
|
||||
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import type { Project } from '@/types/projects.types';
|
||||
import { ProjectTypes } from '@/types/projects.types';
|
||||
import { VIEWS } from '@/constants';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { waitFor, within } from '@testing-library/vue';
|
||||
|
||||
const mockPush = vi.fn();
|
||||
vi.mock('vue-router', async () => {
|
||||
const actual = await vi.importActual('vue-router');
|
||||
const params = {};
|
||||
const location = {};
|
||||
return {
|
||||
...actual,
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
useRoute: () => ({
|
||||
params,
|
||||
location,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const projectTabsSpy = vi.fn().mockReturnValue({
|
||||
render: vi.fn(),
|
||||
});
|
||||
|
||||
const renderComponent = createComponentRenderer(ProjectHeader, {
|
||||
global: {
|
||||
stubs: {
|
||||
ProjectTabs: projectTabsSpy,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let route: ReturnType<typeof router.useRoute>;
|
||||
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
|
||||
|
||||
describe('ProjectHeader', () => {
|
||||
beforeEach(() => {
|
||||
createTestingPinia();
|
||||
route = router.useRoute();
|
||||
projectsStore = mockedStore(useProjectsStore);
|
||||
|
||||
projectsStore.teamProjectsLimit = -1;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render the correct icon', async () => {
|
||||
const { container, rerender } = renderComponent();
|
||||
|
||||
expect(container.querySelector('.fa-home')).toBeVisible();
|
||||
|
||||
projectsStore.currentProject = { type: ProjectTypes.Personal } as Project;
|
||||
await rerender({});
|
||||
expect(container.querySelector('.fa-user')).toBeVisible();
|
||||
|
||||
const projectName = 'My Project';
|
||||
projectsStore.currentProject = { name: projectName } as Project;
|
||||
await rerender({});
|
||||
expect(container.querySelector('.fa-layer-group')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should render the correct title and subtitle', async () => {
|
||||
const { getByText, queryByText, rerender } = renderComponent();
|
||||
const subtitle = 'All the workflows, credentials and executions you have access to';
|
||||
|
||||
expect(getByText('Overview')).toBeVisible();
|
||||
expect(getByText(subtitle)).toBeVisible();
|
||||
|
||||
projectsStore.currentProject = { type: ProjectTypes.Personal } as Project;
|
||||
await rerender({});
|
||||
expect(getByText('Personal')).toBeVisible();
|
||||
expect(queryByText(subtitle)).not.toBeInTheDocument();
|
||||
|
||||
const projectName = 'My Project';
|
||||
projectsStore.currentProject = { name: projectName } as Project;
|
||||
await rerender({});
|
||||
expect(getByText(projectName)).toBeVisible();
|
||||
expect(queryByText(subtitle)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should overwrite default subtitle with slot', () => {
|
||||
const defaultSubtitle = 'All the workflows, credentials and executions you have access to';
|
||||
const subtitle = 'Custom subtitle';
|
||||
|
||||
const { getByText, queryByText } = renderComponent({
|
||||
slots: {
|
||||
subtitle,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByText(subtitle)).toBeVisible();
|
||||
expect(queryByText(defaultSubtitle)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render ProjectTabs Settings if project is team project and user has update scope', () => {
|
||||
route.params.projectId = '123';
|
||||
projectsStore.currentProject = createTestProject({ scopes: ['project:update'] });
|
||||
renderComponent();
|
||||
|
||||
expect(projectTabsSpy).toHaveBeenCalledWith(
|
||||
{
|
||||
'show-settings': true,
|
||||
},
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('should render ProjectTabs without Settings if no project update permission', () => {
|
||||
route.params.projectId = '123';
|
||||
projectsStore.currentProject = createTestProject({ scopes: ['project:read'] });
|
||||
renderComponent();
|
||||
|
||||
expect(projectTabsSpy).toHaveBeenCalledWith(
|
||||
{
|
||||
'show-settings': false,
|
||||
},
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('should render ProjectTabs without Settings if project is not team project', () => {
|
||||
route.params.projectId = '123';
|
||||
projectsStore.currentProject = createTestProject({
|
||||
type: ProjectTypes.Personal,
|
||||
scopes: ['project:update'],
|
||||
});
|
||||
renderComponent();
|
||||
|
||||
expect(projectTabsSpy).toHaveBeenCalledWith(
|
||||
{
|
||||
'show-settings': false,
|
||||
},
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('should create a workflow', async () => {
|
||||
const project = createTestProject({
|
||||
scopes: ['workflow:create'],
|
||||
});
|
||||
projectsStore.currentProject = project;
|
||||
|
||||
const { getByTestId } = renderComponent();
|
||||
|
||||
await userEvent.click(getByTestId('add-resource-workflow'));
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith({
|
||||
name: VIEWS.NEW_WORKFLOW,
|
||||
query: { projectId: project.id },
|
||||
});
|
||||
});
|
||||
|
||||
describe('dropdown', () => {
|
||||
it('should create a credential', async () => {
|
||||
const project = createTestProject({
|
||||
scopes: ['credential:create'],
|
||||
});
|
||||
projectsStore.currentProject = project;
|
||||
|
||||
const { getByTestId } = renderComponent();
|
||||
|
||||
await userEvent.click(within(getByTestId('add-resource')).getByRole('button'));
|
||||
|
||||
await waitFor(() => expect(getByTestId('action-credential')).toBeVisible());
|
||||
|
||||
await userEvent.click(getByTestId('action-credential'));
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith({
|
||||
name: VIEWS.PROJECTS_CREDENTIALS,
|
||||
params: {
|
||||
projectId: project.id,
|
||||
credentialId: 'create',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should not render creation button in setting page', async () => {
|
||||
projectsStore.currentProject = createTestProject({ type: ProjectTypes.Personal });
|
||||
vi.spyOn(router, 'useRoute').mockReturnValueOnce({
|
||||
name: VIEWS.PROJECT_SETTINGS,
|
||||
} as RouteLocationNormalizedLoadedGeneric);
|
||||
const { queryByTestId } = renderComponent();
|
||||
expect(queryByTestId('add-resource-buttons')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { N8nButton, N8nTooltip } from '@n8n/design-system';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { type ProjectIcon, ProjectTypes } from '@/types/projects.types';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||
import ProjectCreateResource from '@/components/Projects/ProjectCreateResource.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const i18n = useI18n();
|
||||
const projectsStore = useProjectsStore();
|
||||
const sourceControlStore = useSourceControlStore();
|
||||
|
||||
const headerIcon = computed((): ProjectIcon => {
|
||||
if (projectsStore.currentProject?.type === ProjectTypes.Personal) {
|
||||
return { type: 'icon', value: 'user' };
|
||||
} else if (projectsStore.currentProject?.name) {
|
||||
return projectsStore.currentProject.icon ?? { type: 'icon', value: 'layer-group' };
|
||||
} else {
|
||||
return { type: 'icon', value: 'home' };
|
||||
}
|
||||
});
|
||||
|
||||
const projectName = computed(() => {
|
||||
if (!projectsStore.currentProject) {
|
||||
return i18n.baseText('projects.menu.overview');
|
||||
} else if (projectsStore.currentProject.type === ProjectTypes.Personal) {
|
||||
return i18n.baseText('projects.menu.personal');
|
||||
} else {
|
||||
return projectsStore.currentProject.name;
|
||||
}
|
||||
});
|
||||
|
||||
const projectPermissions = computed(
|
||||
() => getResourcePermissions(projectsStore.currentProject?.scopes).project,
|
||||
);
|
||||
|
||||
const showSettings = computed(
|
||||
() =>
|
||||
!!route?.params?.projectId &&
|
||||
!!projectPermissions.value.update &&
|
||||
projectsStore.currentProject?.type === ProjectTypes.Team,
|
||||
);
|
||||
|
||||
const homeProject = computed(() => projectsStore.currentProject ?? projectsStore.personalProject);
|
||||
|
||||
const ACTION_TYPES = {
|
||||
WORKFLOW: 'workflow',
|
||||
CREDENTIAL: 'credential',
|
||||
} as const;
|
||||
type ActionTypes = (typeof ACTION_TYPES)[keyof typeof ACTION_TYPES];
|
||||
|
||||
const createWorkflowButton = computed(() => ({
|
||||
value: ACTION_TYPES.WORKFLOW,
|
||||
label: 'Create Workflow',
|
||||
icon: sourceControlStore.preferences.branchReadOnly ? 'lock' : undefined,
|
||||
size: 'mini' as const,
|
||||
disabled:
|
||||
sourceControlStore.preferences.branchReadOnly ||
|
||||
!getResourcePermissions(homeProject.value?.scopes).workflow.create,
|
||||
}));
|
||||
const menu = computed(() => [
|
||||
{
|
||||
value: ACTION_TYPES.CREDENTIAL,
|
||||
label: 'Create credential',
|
||||
disabled:
|
||||
sourceControlStore.preferences.branchReadOnly ||
|
||||
!getResourcePermissions(homeProject.value?.scopes).credential.create,
|
||||
},
|
||||
]);
|
||||
|
||||
const actions: Record<ActionTypes, (projectId: string) => void> = {
|
||||
[ACTION_TYPES.WORKFLOW]: (projectId: string) => {
|
||||
void router.push({
|
||||
name: VIEWS.NEW_WORKFLOW,
|
||||
query: {
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
},
|
||||
[ACTION_TYPES.CREDENTIAL]: (projectId: string) => {
|
||||
void router.push({
|
||||
name: VIEWS.PROJECTS_CREDENTIALS,
|
||||
params: {
|
||||
projectId,
|
||||
credentialId: 'create',
|
||||
},
|
||||
});
|
||||
},
|
||||
} as const;
|
||||
|
||||
const onSelect = (action: string) => {
|
||||
const executableAction = actions[action as ActionTypes];
|
||||
if (!homeProject.value) {
|
||||
return;
|
||||
}
|
||||
executableAction(homeProject.value.id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div :class="$style.projectHeader">
|
||||
<div :class="$style.projectDetails">
|
||||
<ProjectIcon :icon="headerIcon" :border-less="true" size="medium" />
|
||||
<div :class="$style.headerActions">
|
||||
<N8nHeading bold tag="h2" size="xlarge">{{ projectName }}</N8nHeading>
|
||||
<N8nText color="text-light">
|
||||
<slot name="subtitle">
|
||||
<span v-if="!projectsStore.currentProject">{{
|
||||
i18n.baseText('projects.header.subtitle')
|
||||
}}</span>
|
||||
</slot>
|
||||
</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="route.name !== VIEWS.PROJECT_SETTINGS" :class="[$style.headerActions]">
|
||||
<N8nTooltip
|
||||
:disabled="!sourceControlStore.preferences.branchReadOnly"
|
||||
:content="i18n.baseText('readOnlyEnv.cantAdd.any')"
|
||||
>
|
||||
<ProjectCreateResource
|
||||
data-test-id="add-resource-buttons"
|
||||
:actions="menu"
|
||||
:disabled="sourceControlStore.preferences.branchReadOnly"
|
||||
@action="onSelect"
|
||||
>
|
||||
<N8nButton
|
||||
data-test-id="add-resource-workflow"
|
||||
v-bind="createWorkflowButton"
|
||||
@click="onSelect(ACTION_TYPES.WORKFLOW)"
|
||||
/>
|
||||
</ProjectCreateResource>
|
||||
</N8nTooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.actions">
|
||||
<ProjectTabs :show-settings="showSettings" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.projectHeader,
|
||||
.projectDescription {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: var(--spacing-m);
|
||||
min-height: var(--spacing-3xl);
|
||||
}
|
||||
|
||||
.projectDetails {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.actions {
|
||||
padding: var(--spacing-2xs) 0 var(--spacing-l);
|
||||
}
|
||||
|
||||
@include mixins.breakpoint('xs-only') {
|
||||
.projectHeader {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import type { ProjectIcon } from '@/types/projects.types';
|
||||
|
||||
type Props = {
|
||||
icon: ProjectIcon;
|
||||
size?: 'mini' | 'small' | 'medium' | 'large';
|
||||
round?: boolean;
|
||||
borderLess?: boolean;
|
||||
color?: 'text-light' | 'text-base' | 'text-dark';
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 'medium',
|
||||
round: false,
|
||||
borderLess: false,
|
||||
color: 'text-base',
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
$style.container,
|
||||
$style[props.size],
|
||||
{ [$style.round]: props.round, [$style.borderless]: props.borderLess },
|
||||
]"
|
||||
>
|
||||
<N8nIcon
|
||||
v-if="icon.type === 'icon'"
|
||||
:icon="icon.value"
|
||||
:class="$style.icon"
|
||||
:color="color"
|
||||
></N8nIcon>
|
||||
<N8nText v-else-if="icon.type === 'emoji'" color="text-light" :class="$style.emoji">
|
||||
{{ icon.value }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: var(--border-width-base) var(--border-style-base) var(--color-foreground-light);
|
||||
border-radius: var(--border-radius-base);
|
||||
|
||||
&.round {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&.borderless {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.mini {
|
||||
width: var(--spacing-xs);
|
||||
height: var(--spacing-xs);
|
||||
|
||||
.icon {
|
||||
font-size: var(--font-size-2xs);
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-size: var(--font-size-3xs);
|
||||
}
|
||||
}
|
||||
|
||||
.small {
|
||||
min-width: var(--spacing-l);
|
||||
height: var(--spacing-l);
|
||||
|
||||
.emoji {
|
||||
font-size: var(--font-size-2xs);
|
||||
}
|
||||
}
|
||||
|
||||
.medium {
|
||||
min-width: var(--spacing-xl);
|
||||
height: var(--spacing-xl);
|
||||
|
||||
.emoji {
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
|
||||
.large {
|
||||
// Making this in line with user avatar size
|
||||
min-width: 40px;
|
||||
height: 40px;
|
||||
|
||||
.emoji {
|
||||
font-size: var(--font-size-s);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,241 @@
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { createTestWorkflow } from '@/__tests__/mocks';
|
||||
import { createProjectListItem } from '@/__tests__/data/projects';
|
||||
import { getDropdownItems, mockedStore } from '@/__tests__/utils';
|
||||
import type { MockedStore } from '@/__tests__/utils';
|
||||
import { PROJECT_MOVE_RESOURCE_MODAL } from '@/constants';
|
||||
import ProjectMoveResourceModal from '@/components/Projects/ProjectMoveResourceModal.vue';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
|
||||
const renderComponent = createComponentRenderer(ProjectMoveResourceModal, {
|
||||
pinia: createTestingPinia(),
|
||||
global: {
|
||||
stubs: {
|
||||
Modal: {
|
||||
template:
|
||||
'<div role="dialog"><slot name="header" /><slot name="content" /><slot name="footer" /></div>',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let telemetry: ReturnType<typeof useTelemetry>;
|
||||
let projectsStore: MockedStore<typeof useProjectsStore>;
|
||||
let workflowsStore: MockedStore<typeof useWorkflowsStore>;
|
||||
let credentialsStore: MockedStore<typeof useCredentialsStore>;
|
||||
|
||||
describe('ProjectMoveResourceModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
telemetry = useTelemetry();
|
||||
projectsStore = mockedStore(useProjectsStore);
|
||||
workflowsStore = mockedStore(useWorkflowsStore);
|
||||
credentialsStore = mockedStore(useCredentialsStore);
|
||||
});
|
||||
|
||||
it('should send telemetry when mounted', async () => {
|
||||
const telemetryTrackSpy = vi.spyOn(telemetry, 'track');
|
||||
|
||||
projectsStore.availableProjects = [createProjectListItem()];
|
||||
workflowsStore.fetchWorkflow.mockResolvedValueOnce(createTestWorkflow());
|
||||
|
||||
const props = {
|
||||
modalName: PROJECT_MOVE_RESOURCE_MODAL,
|
||||
data: {
|
||||
resourceType: 'workflow',
|
||||
resourceTypeLabel: 'workflow',
|
||||
resource: {
|
||||
id: '1',
|
||||
name: 'My Workflow',
|
||||
homeProject: {
|
||||
id: '2',
|
||||
name: 'My Project',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
renderComponent({ props });
|
||||
expect(telemetryTrackSpy).toHaveBeenCalledWith(
|
||||
'User clicked to move a workflow',
|
||||
expect.objectContaining({ workflow_id: '1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show no available projects message', async () => {
|
||||
projectsStore.availableProjects = [];
|
||||
workflowsStore.fetchWorkflow.mockResolvedValueOnce(createTestWorkflow());
|
||||
|
||||
const props = {
|
||||
modalName: PROJECT_MOVE_RESOURCE_MODAL,
|
||||
data: {
|
||||
resourceType: 'workflow',
|
||||
resourceTypeLabel: 'workflow',
|
||||
resource: {
|
||||
id: '1',
|
||||
name: 'My Workflow',
|
||||
homeProject: {
|
||||
id: '2',
|
||||
name: 'My Project',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { getByText } = renderComponent({ props });
|
||||
expect(getByText(/Currently there are not any projects or users available/)).toBeVisible();
|
||||
});
|
||||
|
||||
it('should not hide project select if filter has no result', async () => {
|
||||
const projects = Array.from({ length: 5 }, createProjectListItem);
|
||||
projectsStore.availableProjects = projects;
|
||||
|
||||
const props = {
|
||||
modalName: PROJECT_MOVE_RESOURCE_MODAL,
|
||||
data: {
|
||||
resourceType: 'workflow',
|
||||
resourceTypeLabel: 'Workflow',
|
||||
resource: {
|
||||
id: '1',
|
||||
name: 'My Workflow',
|
||||
homeProject: {
|
||||
id: projects[0].id,
|
||||
name: projects[0].name,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { getByTestId, getByRole } = renderComponent({ props });
|
||||
|
||||
const projectSelect = getByTestId('project-move-resource-modal-select');
|
||||
const projectSelectInput: HTMLInputElement = getByRole('combobox');
|
||||
expect(projectSelectInput).toBeVisible();
|
||||
expect(projectSelect).toBeVisible();
|
||||
|
||||
const projectSelectDropdownItems = await getDropdownItems(projectSelect);
|
||||
expect(projectSelectDropdownItems).toHaveLength(projects.length - 1);
|
||||
|
||||
await userEvent.click(projectSelectInput);
|
||||
await userEvent.type(projectSelectInput, 'non-existing project');
|
||||
|
||||
expect(projectSelect).toBeVisible();
|
||||
});
|
||||
|
||||
it('should not load workflow if the resource is a credential', async () => {
|
||||
const telemetryTrackSpy = vi.spyOn(telemetry, 'track');
|
||||
projectsStore.availableProjects = [createProjectListItem()];
|
||||
|
||||
const props = {
|
||||
modalName: PROJECT_MOVE_RESOURCE_MODAL,
|
||||
data: {
|
||||
resourceType: 'credential',
|
||||
resourceTypeLabel: 'credential',
|
||||
resource: {
|
||||
id: '1',
|
||||
name: 'My credential',
|
||||
homeProject: {
|
||||
id: '2',
|
||||
name: 'My Project',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { getByText } = renderComponent({ props });
|
||||
expect(telemetryTrackSpy).toHaveBeenCalledWith(
|
||||
'User clicked to move a credential',
|
||||
expect.objectContaining({ credential_id: '1' }),
|
||||
);
|
||||
expect(workflowsStore.fetchWorkflow).not.toHaveBeenCalled();
|
||||
expect(getByText(/Moving will remove any existing sharing for this credential/)).toBeVisible();
|
||||
});
|
||||
|
||||
it('should send credential IDs when workflow moved with used credentials and checkbox checked', async () => {
|
||||
const destinationProject = createProjectListItem();
|
||||
const currentProjectId = '123';
|
||||
const movedWorkflow = {
|
||||
...createTestWorkflow(),
|
||||
usedCredentials: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'PG Credential',
|
||||
credentialType: 'postgres',
|
||||
currentUserHasAccess: true,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Notion Credential',
|
||||
credentialType: 'notion',
|
||||
currentUserHasAccess: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
projectsStore.currentProjectId = currentProjectId;
|
||||
projectsStore.availableProjects = [destinationProject];
|
||||
workflowsStore.fetchWorkflow.mockResolvedValueOnce(movedWorkflow);
|
||||
credentialsStore.fetchAllCredentials.mockResolvedValueOnce([
|
||||
{
|
||||
id: '1',
|
||||
name: 'PG Credential',
|
||||
createdAt: '2021-01-01T00:00:00Z',
|
||||
updatedAt: '2021-01-01T00:00:00Z',
|
||||
type: 'postgres',
|
||||
scopes: ['credential:share'],
|
||||
isManaged: false,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Notion Credential',
|
||||
createdAt: '2021-01-01T00:00:00Z',
|
||||
updatedAt: '2021-01-01T00:00:00Z',
|
||||
type: 'notion',
|
||||
scopes: ['credential:share'],
|
||||
isManaged: false,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Another Credential',
|
||||
createdAt: '2021-01-01T00:00:00Z',
|
||||
updatedAt: '2021-01-01T00:00:00Z',
|
||||
type: 'another',
|
||||
scopes: ['credential:share'],
|
||||
isManaged: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const props = {
|
||||
modalName: PROJECT_MOVE_RESOURCE_MODAL,
|
||||
data: {
|
||||
resourceType: 'workflow',
|
||||
resourceTypeLabel: 'workflow',
|
||||
resource: movedWorkflow,
|
||||
},
|
||||
};
|
||||
const { getByTestId, getByText } = renderComponent({ props });
|
||||
expect(getByTestId('project-move-resource-modal-button')).toBeDisabled();
|
||||
expect(getByText(/Moving will remove any existing sharing for this workflow/)).toBeVisible();
|
||||
|
||||
const projectSelect = getByTestId('project-move-resource-modal-select');
|
||||
expect(projectSelect).toBeVisible();
|
||||
|
||||
const projectSelectDropdownItems = await getDropdownItems(projectSelect);
|
||||
await userEvent.click(projectSelectDropdownItems[0]);
|
||||
|
||||
expect(getByTestId('project-move-resource-modal-button')).toBeEnabled();
|
||||
|
||||
await userEvent.click(getByTestId('project-move-resource-modal-checkbox-all'));
|
||||
await userEvent.click(getByTestId('project-move-resource-modal-button'));
|
||||
|
||||
expect(projectsStore.moveResourceToProject).toHaveBeenCalledWith(
|
||||
'workflow',
|
||||
movedWorkflow.id,
|
||||
destinationProject.id,
|
||||
['1', '2'],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,349 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, onMounted, h } from 'vue';
|
||||
import { truncate } from '@n8n/utils/string/truncate';
|
||||
import type { ICredentialsResponse, IUsedCredential, IWorkflowDb } from '@/Interface';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import Modal from '@/components/Modal.vue';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { ResourceType, splitName } from '@/utils/projects.utils';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { ProjectTypes } from '@/types/projects.types';
|
||||
import ProjectMoveSuccessToastMessage from '@/components/Projects/ProjectMoveSuccessToastMessage.vue';
|
||||
import ProjectMoveResourceModalCredentialsList from '@/components/Projects/ProjectMoveResourceModalCredentialsList.vue';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
import { sortByProperty } from '@n8n/utils/sort/sortByProperty';
|
||||
import type { EventBus } from '@n8n/utils/event-bus';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
|
||||
const props = defineProps<{
|
||||
modalName: string;
|
||||
data: {
|
||||
resource: IWorkflowDb | ICredentialsResponse;
|
||||
resourceType: ResourceType;
|
||||
resourceTypeLabel: string;
|
||||
eventBus?: EventBus;
|
||||
};
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const uiStore = useUIStore();
|
||||
const toast = useToast();
|
||||
const projectsStore = useProjectsStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const credentialsStore = useCredentialsStore();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const filter = ref('');
|
||||
const projectId = ref<string | null>(null);
|
||||
const shareUsedCredentials = ref(false);
|
||||
const usedCredentials = ref<IUsedCredential[]>([]);
|
||||
const allCredentials = ref<ICredentialsResponse[]>([]);
|
||||
const shareableCredentials = computed(() =>
|
||||
allCredentials.value.filter(
|
||||
(credential) =>
|
||||
getResourcePermissions(credential.scopes).credential.share &&
|
||||
usedCredentials.value.find((uc) => uc.id === credential.id),
|
||||
),
|
||||
);
|
||||
const unShareableCredentials = computed(() =>
|
||||
usedCredentials.value.reduce(
|
||||
(acc, uc) => {
|
||||
const credential = credentialsStore.getCredentialById(uc.id);
|
||||
const credentialPermissions = getResourcePermissions(credential?.scopes).credential;
|
||||
if (!credentialPermissions.share) {
|
||||
if (credentialPermissions.read) {
|
||||
acc.push(credential);
|
||||
} else {
|
||||
acc.push(uc);
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as Array<IUsedCredential | ICredentialsResponse>,
|
||||
),
|
||||
);
|
||||
const homeProjectName = computed(
|
||||
() => processProjectName(props.data.resource.homeProject?.name ?? '') ?? '',
|
||||
);
|
||||
const availableProjects = computed(() =>
|
||||
sortByProperty(
|
||||
'name',
|
||||
projectsStore.availableProjects.filter(
|
||||
(p) =>
|
||||
p.id !== props.data.resource.homeProject?.id &&
|
||||
(!p.scopes || getResourcePermissions(p.scopes)[props.data.resourceType].create),
|
||||
),
|
||||
),
|
||||
);
|
||||
const filteredProjects = computed(() =>
|
||||
availableProjects.value.filter((p) => p.name?.toLowerCase().includes(filter.value.toLowerCase())),
|
||||
);
|
||||
const selectedProject = computed(() =>
|
||||
availableProjects.value.find((p) => p.id === projectId.value),
|
||||
);
|
||||
const isResourceInTeamProject = computed(() => isHomeProjectTeam(props.data.resource));
|
||||
const isResourceWorkflow = computed(() => props.data.resourceType === ResourceType.Workflow);
|
||||
const targetProjectName = computed(() => {
|
||||
const { name, email } = splitName(selectedProject.value?.name ?? '');
|
||||
return truncate(name ?? email ?? '', 25);
|
||||
});
|
||||
const resourceName = computed(() => truncate(props.data.resource.name, 25));
|
||||
|
||||
const isHomeProjectTeam = (resource: IWorkflowDb | ICredentialsResponse) =>
|
||||
resource.homeProject?.type === ProjectTypes.Team;
|
||||
|
||||
const processProjectName = (projectName: string) => {
|
||||
const { name, email } = splitName(projectName);
|
||||
return name ?? email;
|
||||
};
|
||||
|
||||
const updateProject = (value: string) => {
|
||||
projectId.value = value;
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
uiStore.closeModal(props.modalName);
|
||||
};
|
||||
|
||||
const setFilter = (query: string) => {
|
||||
filter.value = query;
|
||||
};
|
||||
|
||||
const moveResource = async () => {
|
||||
if (!selectedProject.value) return;
|
||||
try {
|
||||
await projectsStore.moveResourceToProject(
|
||||
props.data.resourceType,
|
||||
props.data.resource.id,
|
||||
selectedProject.value.id,
|
||||
shareUsedCredentials.value ? shareableCredentials.value.map((c) => c.id) : undefined,
|
||||
);
|
||||
closeModal();
|
||||
telemetry.track(`User successfully moved ${props.data.resourceType}`, {
|
||||
[`${props.data.resourceType}_id`]: props.data.resource.id,
|
||||
project_from_type: projectsStore.currentProject?.type ?? projectsStore.personalProject?.type,
|
||||
});
|
||||
toast.showToast({
|
||||
title: i18n.baseText('projects.move.resource.success.title', {
|
||||
interpolate: {
|
||||
resourceTypeLabel: props.data.resourceTypeLabel,
|
||||
resourceName: resourceName.value,
|
||||
targetProjectName: targetProjectName.value,
|
||||
},
|
||||
}),
|
||||
message: h(ProjectMoveSuccessToastMessage, {
|
||||
routeName: isResourceWorkflow.value ? VIEWS.PROJECTS_WORKFLOWS : VIEWS.PROJECTS_CREDENTIALS,
|
||||
resourceType: props.data.resourceType,
|
||||
targetProject: selectedProject.value,
|
||||
isShareCredentialsChecked: shareUsedCredentials.value,
|
||||
areAllUsedCredentialsShareable:
|
||||
shareableCredentials.value.length === usedCredentials.value.length,
|
||||
}),
|
||||
type: 'success',
|
||||
duration: 8000,
|
||||
});
|
||||
if (props.data.eventBus) {
|
||||
props.data.eventBus.emit('resource-moved', {
|
||||
resourceId: props.data.resource.id,
|
||||
resourceType: props.data.resourceType,
|
||||
targetProjectId: selectedProject.value.id,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.showError(
|
||||
error.message,
|
||||
i18n.baseText('projects.move.resource.error.title', {
|
||||
interpolate: {
|
||||
resourceTypeLabel: props.data.resourceTypeLabel,
|
||||
resourceName: resourceName.value,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
telemetry.track(`User clicked to move a ${props.data.resourceType}`, {
|
||||
[`${props.data.resourceType}_id`]: props.data.resource.id,
|
||||
project_from_type: projectsStore.currentProject?.type ?? projectsStore.personalProject?.type,
|
||||
});
|
||||
|
||||
if (isResourceWorkflow.value) {
|
||||
const [workflow, credentials] = await Promise.all([
|
||||
workflowsStore.fetchWorkflow(props.data.resource.id),
|
||||
credentialsStore.fetchAllCredentials(),
|
||||
]);
|
||||
|
||||
usedCredentials.value = workflow?.usedCredentials ?? [];
|
||||
allCredentials.value = credentials ?? [];
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Modal width="500px" :name="props.modalName" data-test-id="project-move-resource-modal">
|
||||
<template #header>
|
||||
<N8nHeading tag="h2" size="xlarge" class="mb-m pr-s">
|
||||
{{
|
||||
i18n.baseText('projects.move.resource.modal.title', {
|
||||
interpolate: { resourceTypeLabel: props.data.resourceTypeLabel },
|
||||
})
|
||||
}}
|
||||
</N8nHeading>
|
||||
<N8nText>
|
||||
<i18n-t keypath="projects.move.resource.modal.message">
|
||||
<template #resourceName
|
||||
><strong>{{ resourceName }}</strong></template
|
||||
>
|
||||
<template v-if="isResourceInTeamProject" #inTeamProject>
|
||||
<i18n-t keypath="projects.move.resource.modal.message.team">
|
||||
<template #resourceHomeProjectName
|
||||
><strong>{{ homeProjectName }}</strong></template
|
||||
>
|
||||
</i18n-t>
|
||||
</template>
|
||||
<template v-else #inPersonalProject>
|
||||
<i18n-t keypath="projects.move.resource.modal.message.personal">
|
||||
<template #resourceHomeProjectName
|
||||
><strong>{{ homeProjectName }}</strong></template
|
||||
>
|
||||
</i18n-t>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</N8nText>
|
||||
</template>
|
||||
<template #content>
|
||||
<div v-if="availableProjects.length">
|
||||
<N8nSelect
|
||||
class="mr-2xs mb-xs"
|
||||
:model-value="projectId"
|
||||
:filterable="true"
|
||||
:filter-method="setFilter"
|
||||
:placeholder="i18n.baseText('projects.move.resource.modal.selectPlaceholder')"
|
||||
data-test-id="project-move-resource-modal-select"
|
||||
@update:model-value="updateProject"
|
||||
>
|
||||
<template #prefix>
|
||||
<N8nIcon icon="search" />
|
||||
</template>
|
||||
<N8nOption
|
||||
v-for="p in filteredProjects"
|
||||
:key="p.id"
|
||||
:value="p.id"
|
||||
:label="p.name"
|
||||
></N8nOption>
|
||||
</N8nSelect>
|
||||
<N8nText>
|
||||
<i18n-t keypath="projects.move.resource.modal.message.sharingNote">
|
||||
<template #note
|
||||
><strong>{{
|
||||
i18n.baseText('projects.move.resource.modal.message.note')
|
||||
}}</strong></template
|
||||
>
|
||||
<template #resourceTypeLabel>{{ props.data.resourceTypeLabel }}</template>
|
||||
</i18n-t>
|
||||
<span
|
||||
v-if="props.data.resource.sharedWithProjects?.length ?? 0 > 0"
|
||||
:class="$style.textBlock"
|
||||
>
|
||||
{{
|
||||
i18n.baseText('projects.move.resource.modal.message.sharingInfo', {
|
||||
adjustToNumber: props.data.resource.sharedWithProjects?.length,
|
||||
interpolate: {
|
||||
numberOfProjects: props.data.resource.sharedWithProjects?.length ?? 0,
|
||||
},
|
||||
})
|
||||
}}</span
|
||||
>
|
||||
<N8nCheckbox
|
||||
v-if="shareableCredentials.length"
|
||||
v-model="shareUsedCredentials"
|
||||
:class="$style.textBlock"
|
||||
data-test-id="project-move-resource-modal-checkbox-all"
|
||||
>
|
||||
<i18n-t keypath="projects.move.resource.modal.message.usedCredentials">
|
||||
<template #usedCredentials>
|
||||
<N8nTooltip placement="top">
|
||||
<span :class="$style.tooltipText">
|
||||
{{
|
||||
i18n.baseText('projects.move.resource.modal.message.usedCredentials.number', {
|
||||
adjustToNumber: shareableCredentials.length,
|
||||
interpolate: { number: shareableCredentials.length },
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<template #content>
|
||||
<ProjectMoveResourceModalCredentialsList
|
||||
:current-project-id="projectsStore.currentProjectId"
|
||||
:credentials="shareableCredentials"
|
||||
/>
|
||||
</template>
|
||||
</N8nTooltip>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</N8nCheckbox>
|
||||
<span v-if="unShareableCredentials.length" :class="$style.textBlock">
|
||||
<i18n-t keypath="projects.move.resource.modal.message.unAccessibleCredentials.note">
|
||||
<template #credentials>
|
||||
<N8nTooltip placement="top">
|
||||
<span :class="$style.tooltipText">{{
|
||||
i18n.baseText('projects.move.resource.modal.message.unAccessibleCredentials')
|
||||
}}</span>
|
||||
<template #content>
|
||||
<ProjectMoveResourceModalCredentialsList
|
||||
:current-project-id="projectsStore.currentProjectId"
|
||||
:credentials="unShareableCredentials"
|
||||
/>
|
||||
</template>
|
||||
</N8nTooltip>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</span>
|
||||
</N8nText>
|
||||
</div>
|
||||
<N8nText v-else>{{
|
||||
i18n.baseText('projects.move.resource.modal.message.noProjects', {
|
||||
interpolate: { resourceTypeLabel: props.data.resourceTypeLabel },
|
||||
})
|
||||
}}</N8nText>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div :class="$style.buttons">
|
||||
<N8nButton type="secondary" text class="mr-2xs" @click="closeModal">
|
||||
{{ i18n.baseText('generic.cancel') }}
|
||||
</N8nButton>
|
||||
<N8nButton
|
||||
:disabled="!projectId"
|
||||
type="primary"
|
||||
data-test-id="project-move-resource-modal-button"
|
||||
@click="moveResource"
|
||||
>
|
||||
{{
|
||||
i18n.baseText('projects.move.resource.modal.button', {
|
||||
interpolate: { resourceTypeLabel: props.data.resourceTypeLabel },
|
||||
})
|
||||
}}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.textBlock {
|
||||
display: block;
|
||||
margin-top: var(--spacing-s);
|
||||
}
|
||||
|
||||
.tooltipText {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import type { RouteLocationNamedRaw } from 'vue-router';
|
||||
import type { ICredentialsResponse, IUsedCredential } from '@/Interface';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
import { VIEWS } from '@/constants';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
credentials?: Array<ICredentialsResponse | IUsedCredential>;
|
||||
currentProjectId?: string;
|
||||
}>(),
|
||||
{
|
||||
credentials: () => [],
|
||||
currentProjectId: '',
|
||||
},
|
||||
);
|
||||
|
||||
const isCredentialReadable = (credential: ICredentialsResponse | IUsedCredential) =>
|
||||
'scopes' in credential ? getResourcePermissions(credential.scopes).credential.read : false;
|
||||
|
||||
const getCredentialRouterLocation = (
|
||||
credential: ICredentialsResponse | IUsedCredential,
|
||||
): RouteLocationNamedRaw => {
|
||||
const isSharedWithCurrentProject = credential.sharedWithProjects?.find(
|
||||
(p) => p.id === props.currentProjectId,
|
||||
);
|
||||
const params: {
|
||||
projectId?: string;
|
||||
credentialId: string;
|
||||
} = { credentialId: credential.id };
|
||||
|
||||
if (isSharedWithCurrentProject ?? credential.homeProject?.id) {
|
||||
params.projectId = isSharedWithCurrentProject
|
||||
? props.currentProjectId
|
||||
: credential.homeProject?.id;
|
||||
}
|
||||
|
||||
return {
|
||||
name: isSharedWithCurrentProject ? VIEWS.PROJECTS_CREDENTIALS : VIEWS.CREDENTIALS,
|
||||
params,
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul :class="$style.credentialsList">
|
||||
<li v-for="credential in props.credentials" :key="credential.id">
|
||||
<router-link
|
||||
v-if="isCredentialReadable(credential)"
|
||||
target="_blank"
|
||||
:to="getCredentialRouterLocation(credential)"
|
||||
>
|
||||
{{ credential.name }}
|
||||
</router-link>
|
||||
<span v-else>{{ credential.name }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.credentialsList {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
padding: 0 0 var(--spacing-3xs);
|
||||
|
||||
&:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import ProjectMoveSuccessToastMessage from '@/components/Projects/ProjectMoveSuccessToastMessage.vue';
|
||||
import { ResourceType } from '@/utils/projects.utils';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { ProjectTypes } from '@/types/projects.types';
|
||||
|
||||
const renderComponent = createComponentRenderer(ProjectMoveSuccessToastMessage, {
|
||||
global: {
|
||||
stubs: {
|
||||
'router-link': {
|
||||
template: '<a href="#"><slot /></a>',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('ProjectMoveSuccessToastMessage', () => {
|
||||
it('should show credentials message if the resource is a workflow', async () => {
|
||||
const props = {
|
||||
routeName: VIEWS.PROJECTS_WORKFLOWS,
|
||||
resourceType: ResourceType.Workflow,
|
||||
targetProject: {
|
||||
id: '2',
|
||||
name: 'My Project',
|
||||
},
|
||||
isShareCredentialsChecked: false,
|
||||
areAllUsedCredentialsShareable: false,
|
||||
};
|
||||
const { getByText } = renderComponent({ props });
|
||||
expect(getByText(/The workflow's credentials were not shared/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show all credentials shared message if the resource is a workflow', async () => {
|
||||
const props = {
|
||||
routeName: VIEWS.PROJECTS_WORKFLOWS,
|
||||
resourceType: ResourceType.Workflow,
|
||||
targetProject: {
|
||||
id: '2',
|
||||
name: 'My Project',
|
||||
},
|
||||
isShareCredentialsChecked: true,
|
||||
areAllUsedCredentialsShareable: true,
|
||||
};
|
||||
const { getByText } = renderComponent({ props });
|
||||
expect(getByText(/The workflow's credentials were shared/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show not all credentials shared message if the resource is a workflow', async () => {
|
||||
const props = {
|
||||
routeName: VIEWS.PROJECTS_WORKFLOWS,
|
||||
resourceType: ResourceType.Workflow,
|
||||
targetProject: {
|
||||
id: '2',
|
||||
name: 'My Project',
|
||||
},
|
||||
isShareCredentialsChecked: true,
|
||||
areAllUsedCredentialsShareable: false,
|
||||
};
|
||||
const { getByText } = renderComponent({ props });
|
||||
expect(getByText(/Due to missing permissions/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show link if the target project type is team project', async () => {
|
||||
const props = {
|
||||
routeName: VIEWS.PROJECTS_WORKFLOWS,
|
||||
resourceType: ResourceType.Workflow,
|
||||
targetProject: {
|
||||
id: '2',
|
||||
name: 'Team Project',
|
||||
type: ProjectTypes.Team,
|
||||
},
|
||||
isShareCredentialsChecked: false,
|
||||
areAllUsedCredentialsShareable: false,
|
||||
};
|
||||
const { getByRole } = renderComponent({ props });
|
||||
expect(getByRole('link')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show only general if the resource is credential and moved to a personal project', async () => {
|
||||
const props = {
|
||||
routeName: VIEWS.PROJECTS_WORKFLOWS,
|
||||
resourceType: ResourceType.Credential,
|
||||
targetProject: {
|
||||
id: '2',
|
||||
name: 'Personal Project',
|
||||
type: ProjectTypes.Personal,
|
||||
},
|
||||
isShareCredentialsChecked: false,
|
||||
areAllUsedCredentialsShareable: false,
|
||||
};
|
||||
const { queryByText, queryByRole } = renderComponent({ props });
|
||||
expect(queryByText(/The workflow's credentials were not shared/)).not.toBeInTheDocument();
|
||||
expect(queryByRole('link')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { truncate } from '@n8n/utils/string/truncate';
|
||||
import { ResourceType, splitName } from '@/utils/projects.utils';
|
||||
import type { ProjectListItem } from '@/types/projects.types';
|
||||
import { ProjectTypes } from '@/types/projects.types';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
const props = defineProps<{
|
||||
routeName: string;
|
||||
resourceType: ResourceType;
|
||||
targetProject: ProjectListItem;
|
||||
isShareCredentialsChecked: boolean;
|
||||
areAllUsedCredentialsShareable: boolean;
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const isWorkflow = computed(() => props.resourceType === ResourceType.Workflow);
|
||||
const isTargetProjectTeam = computed(() => props.targetProject.type === ProjectTypes.Team);
|
||||
const targetProjectName = computed(() => {
|
||||
const { name, email } = splitName(props.targetProject?.name ?? '');
|
||||
return truncate(name ?? email ?? '', 25);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<N8nText v-if="isWorkflow" tag="p" class="pt-xs">
|
||||
<span v-if="props.isShareCredentialsChecked && props.areAllUsedCredentialsShareable">{{
|
||||
i18n.baseText('projects.move.resource.success.message.workflow.withAllCredentials')
|
||||
}}</span>
|
||||
<span v-else-if="props.isShareCredentialsChecked">{{
|
||||
i18n.baseText('projects.move.resource.success.message.workflow.withSomeCredentials')
|
||||
}}</span>
|
||||
<span v-else>{{ i18n.baseText('projects.move.resource.success.message.workflow') }}</span>
|
||||
</N8nText>
|
||||
<p v-if="isTargetProjectTeam" class="pt-s">
|
||||
<router-link
|
||||
:to="{
|
||||
name: props.routeName,
|
||||
params: { projectId: props.targetProject.id },
|
||||
}"
|
||||
>
|
||||
{{
|
||||
i18n.baseText('projects.move.resource.success.link', {
|
||||
interpolate: { targetProjectName },
|
||||
})
|
||||
}}
|
||||
</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,178 @@
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import { createProjectListItem } from '@/__tests__/data/projects';
|
||||
import ProjectsNavigation from '@/components/Projects/ProjectNavigation.vue';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
|
||||
vi.mock('vue-router', async () => {
|
||||
const actual = await vi.importActual('vue-router');
|
||||
const push = vi.fn();
|
||||
return {
|
||||
...actual,
|
||||
useRouter: () => ({
|
||||
push,
|
||||
}),
|
||||
RouterLink: {
|
||||
template: '<a><slot /></a>',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/composables/useToast', () => {
|
||||
const showMessage = vi.fn();
|
||||
const showError = vi.fn();
|
||||
return {
|
||||
useToast: () => ({
|
||||
showMessage,
|
||||
showError,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/composables/usePageRedirectionHelper', () => {
|
||||
const goToUpgrade = vi.fn();
|
||||
return {
|
||||
usePageRedirectionHelper: () => ({
|
||||
goToUpgrade,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('is-emoji-supported', () => ({
|
||||
isEmojiSupported: () => true,
|
||||
}));
|
||||
|
||||
const renderComponent = createComponentRenderer(ProjectsNavigation, {
|
||||
global: {
|
||||
plugins: [
|
||||
createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: { template: '<div>Home</div>' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
|
||||
|
||||
const personalProjects = Array.from({ length: 3 }, createProjectListItem);
|
||||
const teamProjects = Array.from({ length: 3 }, () => createProjectListItem('team'));
|
||||
|
||||
describe('ProjectsNavigation', () => {
|
||||
beforeEach(() => {
|
||||
createTestingPinia();
|
||||
|
||||
projectsStore = mockedStore(useProjectsStore);
|
||||
});
|
||||
|
||||
it('should not throw an error', () => {
|
||||
projectsStore.teamProjectsLimit = -1;
|
||||
expect(() => {
|
||||
renderComponent({
|
||||
props: {
|
||||
collapsed: false,
|
||||
},
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should show "Projects" title and Personal project when the feature is enabled', async () => {
|
||||
projectsStore.teamProjectsLimit = -1;
|
||||
projectsStore.myProjects = [...personalProjects, ...teamProjects];
|
||||
|
||||
const { getByRole, getAllByTestId, getByTestId } = renderComponent({
|
||||
props: {
|
||||
collapsed: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByRole('heading', { level: 3, name: 'Projects' })).toBeVisible();
|
||||
expect(getByTestId('project-personal-menu-item')).toBeVisible();
|
||||
expect(getByTestId('project-personal-menu-item').querySelector('svg')).toBeVisible();
|
||||
expect(getAllByTestId('project-menu-item')).toHaveLength(teamProjects.length);
|
||||
});
|
||||
|
||||
it('should not show "Projects" title when the menu is collapsed', async () => {
|
||||
projectsStore.teamProjectsLimit = -1;
|
||||
|
||||
const { queryByRole } = renderComponent({
|
||||
props: {
|
||||
collapsed: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(queryByRole('heading', { level: 3, name: 'Projects' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show "Projects" title when the feature is not enabled', async () => {
|
||||
projectsStore.teamProjectsLimit = 0;
|
||||
|
||||
const { queryByRole } = renderComponent({
|
||||
props: {
|
||||
collapsed: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(queryByRole('heading', { level: 3, name: 'Projects' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show project icons when the menu is collapsed', async () => {
|
||||
projectsStore.teamProjectsLimit = -1;
|
||||
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
collapsed: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByTestId('project-personal-menu-item')).toBeVisible();
|
||||
expect(getByTestId('project-personal-menu-item').querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show add first project button if there are projects already', async () => {
|
||||
projectsStore.teamProjectsLimit = -1;
|
||||
projectsStore.myProjects = [...teamProjects];
|
||||
|
||||
const { queryByTestId } = renderComponent({
|
||||
props: {
|
||||
collapsed: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(queryByTestId('add-first-project-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show project plus button and add first project button if user cannot create projects', async () => {
|
||||
projectsStore.teamProjectsLimit = 0;
|
||||
|
||||
const { queryByTestId } = renderComponent({
|
||||
props: {
|
||||
collapsed: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(queryByTestId('project-plus-button')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('add-first-project-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show project plus button and add first project button if user can create projects', async () => {
|
||||
projectsStore.teamProjectsLimit = -1;
|
||||
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
collapsed: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByTestId('project-plus-button')).toBeVisible();
|
||||
expect(getByTestId('add-first-project-button')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import type { IMenuItem } from '@n8n/design-system/types';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import type { ProjectListItem } from '@/types/projects.types';
|
||||
import { useGlobalEntityCreation } from '@/composables/useGlobalEntityCreation';
|
||||
|
||||
type Props = {
|
||||
collapsed: boolean;
|
||||
planName?: string;
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const locale = useI18n();
|
||||
const projectsStore = useProjectsStore();
|
||||
const globalEntityCreation = useGlobalEntityCreation();
|
||||
|
||||
const isCreatingProject = computed(() => globalEntityCreation.isCreatingProject.value);
|
||||
const displayProjects = computed(() => globalEntityCreation.displayProjects.value);
|
||||
|
||||
const home = computed<IMenuItem>(() => ({
|
||||
id: 'home',
|
||||
label: locale.baseText('projects.menu.overview'),
|
||||
icon: 'home',
|
||||
route: {
|
||||
to: { name: VIEWS.HOMEPAGE },
|
||||
},
|
||||
}));
|
||||
|
||||
const getProjectMenuItem = (project: ProjectListItem) => ({
|
||||
id: project.id,
|
||||
label: project.name,
|
||||
icon: project.icon,
|
||||
route: {
|
||||
to: {
|
||||
name: VIEWS.PROJECTS_WORKFLOWS,
|
||||
params: { projectId: project.id },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const personalProject = computed<IMenuItem>(() => ({
|
||||
id: projectsStore.personalProject?.id ?? '',
|
||||
label: locale.baseText('projects.menu.personal'),
|
||||
icon: 'user',
|
||||
route: {
|
||||
to: {
|
||||
name: VIEWS.PROJECTS_WORKFLOWS,
|
||||
params: { projectId: projectsStore.personalProject?.id },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const showAddFirstProject = computed(
|
||||
() => projectsStore.isTeamProjectFeatureEnabled && !displayProjects.value.length,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.projects">
|
||||
<ElMenu :collapse="props.collapsed" class="home">
|
||||
<N8nMenuItem
|
||||
:item="home"
|
||||
:compact="props.collapsed"
|
||||
:active-tab="projectsStore.projectNavActiveId"
|
||||
mode="tabs"
|
||||
data-test-id="project-home-menu-item"
|
||||
/>
|
||||
</ElMenu>
|
||||
<hr v-if="projectsStore.isTeamProjectFeatureEnabled" class="mt-m mb-m" />
|
||||
<N8nText
|
||||
v-if="!props.collapsed && projectsStore.isTeamProjectFeatureEnabled"
|
||||
:class="[$style.projectsLabel]"
|
||||
tag="h3"
|
||||
bold
|
||||
>
|
||||
<span>{{ locale.baseText('projects.menu.title') }}</span>
|
||||
<N8nButton
|
||||
v-if="projectsStore.canCreateProjects"
|
||||
icon="plus"
|
||||
text
|
||||
data-test-id="project-plus-button"
|
||||
:disabled="isCreatingProject"
|
||||
:class="$style.plusBtn"
|
||||
@click="globalEntityCreation.createProject"
|
||||
/>
|
||||
</N8nText>
|
||||
<ElMenu
|
||||
v-if="projectsStore.isTeamProjectFeatureEnabled"
|
||||
:collapse="props.collapsed"
|
||||
:class="$style.projectItems"
|
||||
>
|
||||
<N8nMenuItem
|
||||
:item="personalProject"
|
||||
:compact="props.collapsed"
|
||||
:active-tab="projectsStore.projectNavActiveId"
|
||||
mode="tabs"
|
||||
data-test-id="project-personal-menu-item"
|
||||
/>
|
||||
<N8nMenuItem
|
||||
v-for="project in displayProjects"
|
||||
:key="project.id"
|
||||
:class="{
|
||||
[$style.collapsed]: props.collapsed,
|
||||
}"
|
||||
:item="getProjectMenuItem(project)"
|
||||
:compact="props.collapsed"
|
||||
:active-tab="projectsStore.projectNavActiveId"
|
||||
mode="tabs"
|
||||
data-test-id="project-menu-item"
|
||||
/>
|
||||
</ElMenu>
|
||||
<N8nButton
|
||||
v-if="showAddFirstProject"
|
||||
:class="[
|
||||
$style.addFirstProjectBtn,
|
||||
{
|
||||
[$style.collapsed]: props.collapsed,
|
||||
},
|
||||
]"
|
||||
:disabled="isCreatingProject"
|
||||
type="secondary"
|
||||
icon="plus"
|
||||
data-test-id="add-first-project-button"
|
||||
@click="globalEntityCreation.createProject"
|
||||
>
|
||||
{{ locale.baseText('projects.menu.addFirstProject') }}
|
||||
</N8nButton>
|
||||
<hr v-if="projectsStore.isTeamProjectFeatureEnabled" class="mb-m" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.projects {
|
||||
display: grid;
|
||||
grid-auto-rows: auto;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
align-items: start;
|
||||
&:hover {
|
||||
.plusBtn {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.projectItems {
|
||||
height: 100%;
|
||||
padding: 0 var(--spacing-xs) var(--spacing-s);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.upgradeLink {
|
||||
color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.collapsed {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.projectsLabel {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 0 0 var(--spacing-s) var(--spacing-xs);
|
||||
padding: 0 var(--spacing-s);
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
color: var(--color-text-base);
|
||||
|
||||
&.collapsed {
|
||||
padding: 0;
|
||||
margin-left: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.plusBtn {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: var(--color-text-light);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.addFirstProjectBtn {
|
||||
font-size: var(--font-size-xs);
|
||||
padding: var(--spacing-3xs);
|
||||
margin: 0 var(--spacing-m) var(--spacing-m);
|
||||
|
||||
&.collapsed {
|
||||
> span:last-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.home {
|
||||
padding: 0 var(--spacing-xs);
|
||||
|
||||
:deep(.el-menu-item) {
|
||||
padding: var(--spacing-m) var(--spacing-xs) !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts" setup>
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||
|
||||
type Props = {
|
||||
limit: number;
|
||||
planName?: string;
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const visible = defineModel<boolean>();
|
||||
const pageRedirectionHelper = usePageRedirectionHelper();
|
||||
const locale = useI18n();
|
||||
|
||||
const goToUpgrade = async () => {
|
||||
await pageRedirectionHelper.goToUpgrade('rbac', 'upgrade-rbac');
|
||||
visible.value = false;
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="locale.baseText('projects.settings.role.upgrade.title')"
|
||||
width="500"
|
||||
>
|
||||
<div class="pt-l">
|
||||
<i18n-t keypath="projects.settings.role.upgrade.message">
|
||||
<template #planName>{{ props.planName }}</template>
|
||||
<template #limit>
|
||||
{{
|
||||
locale.baseText('projects.create.limit', {
|
||||
adjustToNumber: props.limit,
|
||||
interpolate: { num: String(props.limit) },
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
<template #footer>
|
||||
<N8nButton type="secondary" native-type="button" @click="visible = false">{{
|
||||
locale.baseText('generic.cancel')
|
||||
}}</N8nButton>
|
||||
<N8nButton type="primary" native-type="button" @click="goToUpgrade">{{
|
||||
locale.baseText('projects.create.limitReached.link')
|
||||
}}</N8nButton>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,165 @@
|
||||
import { within } from '@testing-library/vue';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { getDropdownItems, getSelectedDropdownValue } from '@/__tests__/utils';
|
||||
import { createProjectListItem, createProjectSharingData } from '@/__tests__/data/projects';
|
||||
import ProjectSharing from '@/components/Projects/ProjectSharing.vue';
|
||||
|
||||
const renderComponent = createComponentRenderer(ProjectSharing);
|
||||
|
||||
const personalProjects = Array.from({ length: 3 }, createProjectListItem);
|
||||
const teamProjects = Array.from({ length: 3 }, () => createProjectListItem('team'));
|
||||
const homeProject = createProjectSharingData();
|
||||
|
||||
describe('ProjectSharing', () => {
|
||||
it('should render empty select when projects is empty and no selected project existing', async () => {
|
||||
const { getByTestId, queryByTestId } = renderComponent({
|
||||
props: {
|
||||
projects: [],
|
||||
modelValue: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByTestId('project-sharing-select')).toBeInTheDocument();
|
||||
expect(queryByTestId('project-sharing-list-item')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('project-sharing-owner')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should filter, add and remove projects', async () => {
|
||||
const { getByTestId, getAllByTestId, queryByTestId, queryAllByTestId, emitted } =
|
||||
renderComponent({
|
||||
props: {
|
||||
projects: personalProjects,
|
||||
modelValue: [personalProjects[0]],
|
||||
roles: [
|
||||
{
|
||||
role: 'project:admin',
|
||||
name: 'Admin',
|
||||
},
|
||||
{
|
||||
role: 'project:editor',
|
||||
name: 'Editor',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(queryByTestId('project-sharing-owner')).not.toBeInTheDocument();
|
||||
// Check the initial state (one selected project comes from the modelValue prop)
|
||||
expect(getAllByTestId('project-sharing-list-item')).toHaveLength(1);
|
||||
|
||||
const projectSelect = getByTestId('project-sharing-select');
|
||||
const projectSelectInput = projectSelect.querySelector('input') as HTMLInputElement;
|
||||
|
||||
// Get the dropdown items
|
||||
let projectSelectDropdownItems = await getDropdownItems(projectSelect);
|
||||
expect(projectSelectDropdownItems).toHaveLength(2);
|
||||
|
||||
// Add a project (first from the dropdown list)
|
||||
await userEvent.click(projectSelectDropdownItems[0]);
|
||||
expect(emitted()['update:modelValue']).toEqual([[[expect.any(Object), expect.any(Object)]]]);
|
||||
|
||||
expect(getAllByTestId('project-sharing-list-item')).toHaveLength(2);
|
||||
expect(projectSelectInput.value).toBe('');
|
||||
projectSelectDropdownItems = await getDropdownItems(projectSelect);
|
||||
expect(projectSelectDropdownItems).toHaveLength(1);
|
||||
|
||||
let actionDropDownItems = await getDropdownItems(
|
||||
getAllByTestId('project-sharing-list-item')[0],
|
||||
);
|
||||
expect(actionDropDownItems).toHaveLength(2);
|
||||
|
||||
// Click on the remove button
|
||||
await userEvent.click(
|
||||
within(getAllByTestId('project-sharing-list-item')[0]).getByTestId('project-sharing-remove'),
|
||||
);
|
||||
expect(emitted()['update:modelValue']).toEqual([
|
||||
[[expect.any(Object), expect.any(Object)]],
|
||||
[[expect.any(Object)]],
|
||||
]);
|
||||
|
||||
// Check the state
|
||||
expect(getAllByTestId('project-sharing-list-item')).toHaveLength(1);
|
||||
projectSelectDropdownItems = await getDropdownItems(projectSelect);
|
||||
expect(projectSelectDropdownItems).toHaveLength(2);
|
||||
|
||||
actionDropDownItems = await getDropdownItems(getAllByTestId('project-sharing-list-item')[0]);
|
||||
expect(actionDropDownItems).toHaveLength(2);
|
||||
|
||||
// Remove the last selected project
|
||||
await userEvent.click(
|
||||
within(getAllByTestId('project-sharing-list-item')[0]).getByTestId('project-sharing-remove'),
|
||||
);
|
||||
expect(emitted()['update:modelValue']).toEqual([
|
||||
[[expect.any(Object), expect.any(Object)]],
|
||||
[[expect.any(Object)]],
|
||||
[[]],
|
||||
]);
|
||||
|
||||
// Check the final state
|
||||
expect(queryAllByTestId('project-sharing-list-item')).toHaveLength(0);
|
||||
projectSelectDropdownItems = await getDropdownItems(projectSelect);
|
||||
expect(projectSelectDropdownItems).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should work as a simple select when model is not an array', async () => {
|
||||
const { getByTestId, queryByTestId, emitted } = renderComponent({
|
||||
props: {
|
||||
projects: teamProjects,
|
||||
modelValue: null,
|
||||
},
|
||||
});
|
||||
expect(queryByTestId('project-sharing-owner')).not.toBeInTheDocument();
|
||||
|
||||
const projectSelect = getByTestId('project-sharing-select');
|
||||
|
||||
// Get the dropdown items
|
||||
let projectSelectDropdownItems = await getDropdownItems(projectSelect);
|
||||
expect(projectSelectDropdownItems).toHaveLength(3);
|
||||
|
||||
// Select the first project from the dropdown list
|
||||
await userEvent.click(projectSelectDropdownItems[0]);
|
||||
expect(queryByTestId('project-sharing-list-item')).not.toBeInTheDocument();
|
||||
projectSelectDropdownItems = await getDropdownItems(projectSelect);
|
||||
expect(projectSelectDropdownItems).toHaveLength(3);
|
||||
|
||||
const selectedValue = await getSelectedDropdownValue(projectSelectDropdownItems);
|
||||
expect(selectedValue).toBeTruthy();
|
||||
expect(emitted()['update:modelValue']).toEqual([
|
||||
[
|
||||
expect.objectContaining({
|
||||
name: selectedValue,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
// Select another project from the dropdown list
|
||||
await userEvent.click(projectSelectDropdownItems[1]);
|
||||
projectSelectDropdownItems = await getDropdownItems(projectSelect);
|
||||
expect(projectSelectDropdownItems).toHaveLength(3);
|
||||
const newSelectedValue = await getSelectedDropdownValue(projectSelectDropdownItems);
|
||||
expect(newSelectedValue).toBeTruthy();
|
||||
expect(emitted()['update:modelValue']).toEqual([
|
||||
expect.any(Array),
|
||||
[
|
||||
expect.objectContaining({
|
||||
name: newSelectedValue,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should render home project as owner when defined', async () => {
|
||||
const { getByTestId, queryByTestId } = renderComponent({
|
||||
props: {
|
||||
projects: personalProjects,
|
||||
modelValue: [],
|
||||
homeProject,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByTestId('project-sharing-select')).toBeInTheDocument();
|
||||
expect(queryByTestId('project-sharing-list-item')).not.toBeInTheDocument();
|
||||
expect(getByTestId('project-sharing-owner')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import type { ProjectListItem, ProjectSharingData } from '@/types/projects.types';
|
||||
import ProjectSharingInfo from '@/components/Projects/ProjectSharingInfo.vue';
|
||||
import type { RoleMap } from '@/types/roles.types';
|
||||
import { sortByProperty } from '@n8n/utils/sort/sortByProperty';
|
||||
|
||||
const locale = useI18n();
|
||||
|
||||
type Props = {
|
||||
projects: ProjectListItem[];
|
||||
homeProject?: ProjectSharingData;
|
||||
roles?: RoleMap['workflow' | 'credential' | 'project'];
|
||||
readonly?: boolean;
|
||||
static?: boolean;
|
||||
placeholder?: string;
|
||||
emptyOptionsText?: string;
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const model = defineModel<(ProjectSharingData | null) | ProjectSharingData[]>({
|
||||
required: true,
|
||||
});
|
||||
const emit = defineEmits<{
|
||||
projectAdded: [value: ProjectSharingData];
|
||||
projectRemoved: [value: ProjectSharingData];
|
||||
}>();
|
||||
|
||||
const selectedProject = ref(Array.isArray(model.value) ? '' : (model.value?.id ?? ''));
|
||||
const filter = ref('');
|
||||
const selectPlaceholder = computed(
|
||||
() => props.placeholder ?? locale.baseText('projects.sharing.select.placeholder'),
|
||||
);
|
||||
const noDataText = computed(
|
||||
() => props.emptyOptionsText ?? locale.baseText('projects.sharing.noMatchingUsers'),
|
||||
);
|
||||
const filteredProjects = computed(() =>
|
||||
sortByProperty(
|
||||
'name',
|
||||
props.projects.filter(
|
||||
(project) =>
|
||||
project.name?.toLowerCase().includes(filter.value.toLowerCase()) &&
|
||||
(Array.isArray(model.value) ? !model.value?.find((p) => p.id === project.id) : true),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const setFilter = (query: string) => {
|
||||
filter.value = query;
|
||||
};
|
||||
|
||||
const onProjectSelected = (projectId: string) => {
|
||||
const project = props.projects.find((p) => p.id === projectId);
|
||||
|
||||
if (!project) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(model.value)) {
|
||||
model.value = [...model.value, project];
|
||||
} else {
|
||||
model.value = project;
|
||||
}
|
||||
emit('projectAdded', project);
|
||||
};
|
||||
|
||||
const onRoleAction = (project: ProjectSharingData, role: string) => {
|
||||
if (!Array.isArray(model.value) || props.readonly) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = model.value?.findIndex((p) => p.id === project.id) ?? -1;
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (role === 'remove') {
|
||||
model.value = model.value.filter((p) => p.id !== project.id);
|
||||
emit('projectRemoved', project);
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => model.value,
|
||||
() => {
|
||||
if (model.value === null || Array.isArray(model.value)) {
|
||||
selectedProject.value = '';
|
||||
} else {
|
||||
selectedProject.value = model.value.id;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<N8nSelect
|
||||
v-if="!props.static"
|
||||
:model-value="selectedProject"
|
||||
data-test-id="project-sharing-select"
|
||||
:filterable="true"
|
||||
:filter-method="setFilter"
|
||||
:placeholder="selectPlaceholder"
|
||||
:default-first-option="true"
|
||||
:no-data-text="noDataText"
|
||||
size="large"
|
||||
:disabled="props.readonly"
|
||||
@update:model-value="onProjectSelected"
|
||||
>
|
||||
<template #prefix>
|
||||
<n8n-icon icon="search" />
|
||||
</template>
|
||||
<N8nOption
|
||||
v-for="project in filteredProjects"
|
||||
:key="project.id"
|
||||
:value="project.id"
|
||||
:label="project.name"
|
||||
>
|
||||
<ProjectSharingInfo :project="project" />
|
||||
</N8nOption>
|
||||
</N8nSelect>
|
||||
<ul v-if="Array.isArray(model)" :class="$style.selectedProjects">
|
||||
<li v-if="props.homeProject" :class="$style.project" data-test-id="project-sharing-owner">
|
||||
<ProjectSharingInfo :project="props.homeProject">
|
||||
<N8nBadge theme="tertiary" bold>
|
||||
{{ locale.baseText('auth.roles.owner') }}
|
||||
</N8nBadge></ProjectSharingInfo
|
||||
>
|
||||
</li>
|
||||
<li
|
||||
v-for="project in model"
|
||||
:key="project.id"
|
||||
:class="$style.project"
|
||||
data-test-id="project-sharing-list-item"
|
||||
>
|
||||
<ProjectSharingInfo :project="project" />
|
||||
<N8nSelect
|
||||
v-if="props.roles?.length && !props.static"
|
||||
:class="$style.projectRoleSelect"
|
||||
:model-value="props.roles[0]"
|
||||
:disabled="props.readonly"
|
||||
size="small"
|
||||
@update:model-value="onRoleAction(project, $event)"
|
||||
>
|
||||
<N8nOption v-for="role in roles" :key="role.role" :value="role.role" :label="role.name" />
|
||||
</N8nSelect>
|
||||
<N8nButton
|
||||
v-if="!props.static"
|
||||
type="tertiary"
|
||||
native-type="button"
|
||||
square
|
||||
icon="trash"
|
||||
:disabled="props.readonly"
|
||||
data-test-id="project-sharing-remove"
|
||||
@click="onRoleAction(project, 'remove')"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.project {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
padding: var(--spacing-2xs) 0;
|
||||
gap: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
.selectedProjects {
|
||||
li {
|
||||
padding: 0;
|
||||
border-bottom: var(--border-base);
|
||||
|
||||
&:first-child {
|
||||
padding-top: var(--spacing-m);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.projectRoleSelect {
|
||||
width: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import type { ProjectListItem, ProjectSharingData } from '@/types/projects.types';
|
||||
import { splitName } from '@/utils/projects.utils';
|
||||
|
||||
type Props = {
|
||||
project: ProjectListItem | ProjectSharingData;
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const processedName = computed(() => {
|
||||
const { name, email } = splitName(props.project.name ?? '');
|
||||
const nameArray = name?.split(' ');
|
||||
const lastName = nameArray?.pop() ?? '';
|
||||
return {
|
||||
firstName: nameArray?.join(' ') ?? '',
|
||||
lastName,
|
||||
email,
|
||||
};
|
||||
});
|
||||
|
||||
const projectIcon = computed(() => {
|
||||
if (props.project.icon) {
|
||||
return props.project.icon;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div :class="$style.projectInfo" data-test-id="project-sharing-info">
|
||||
<div>
|
||||
<ProjectIcon v-if="projectIcon" :icon="projectIcon" size="large" :round="true" />
|
||||
<N8nAvatar v-else :first-name="processedName.firstName" :last-name="processedName.lastName" />
|
||||
<div :class="$style.text">
|
||||
<p v-if="processedName.firstName || processedName.lastName">
|
||||
{{ processedName.firstName }} {{ processedName.lastName }}
|
||||
</p>
|
||||
<small>{{ processedName.email }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.projectInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: var(--spacing-2xs) 0;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: var(--font-size-s);
|
||||
color: var(--color-text-dark);
|
||||
font-weight: var(--font-weight-bold);
|
||||
line-height: var(--font-line-height-loose);
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-light);
|
||||
line-height: var(--font-line-height-loose);
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
||||
|
||||
vi.mock('vue-router', async () => {
|
||||
const actual = await vi.importActual('vue-router');
|
||||
const params = {};
|
||||
return {
|
||||
...actual,
|
||||
useRoute: () => ({
|
||||
params,
|
||||
}),
|
||||
};
|
||||
});
|
||||
const renderComponent = createComponentRenderer(ProjectTabs, {
|
||||
global: {
|
||||
stubs: {
|
||||
'router-link': {
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('ProjectTabs', () => {
|
||||
it('should render home tabs', async () => {
|
||||
const { getByText, queryByText } = renderComponent();
|
||||
|
||||
expect(getByText('Workflows')).toBeInTheDocument();
|
||||
expect(getByText('Credentials')).toBeInTheDocument();
|
||||
expect(getByText('Executions')).toBeInTheDocument();
|
||||
expect(queryByText('Project settings')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render project tab Settings', () => {
|
||||
const { getByText } = renderComponent({ props: { showSettings: true } });
|
||||
|
||||
expect(getByText('Workflows')).toBeInTheDocument();
|
||||
expect(getByText('Credentials')).toBeInTheDocument();
|
||||
expect(getByText('Executions')).toBeInTheDocument();
|
||||
expect(getByText('Project settings')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import type { RouteRecordName } from 'vue-router';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
const props = defineProps<{
|
||||
showSettings?: boolean;
|
||||
}>();
|
||||
|
||||
const locale = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const selectedTab = ref<RouteRecordName | null | undefined>('');
|
||||
const options = computed(() => {
|
||||
const projectId = route?.params?.projectId;
|
||||
const to = projectId
|
||||
? {
|
||||
workflows: {
|
||||
name: VIEWS.PROJECTS_WORKFLOWS,
|
||||
params: { projectId },
|
||||
},
|
||||
credentials: {
|
||||
name: VIEWS.PROJECTS_CREDENTIALS,
|
||||
params: { projectId },
|
||||
},
|
||||
executions: {
|
||||
name: VIEWS.PROJECTS_EXECUTIONS,
|
||||
params: { projectId },
|
||||
},
|
||||
}
|
||||
: {
|
||||
workflows: {
|
||||
name: VIEWS.WORKFLOWS,
|
||||
},
|
||||
credentials: {
|
||||
name: VIEWS.CREDENTIALS,
|
||||
},
|
||||
executions: {
|
||||
name: VIEWS.EXECUTIONS,
|
||||
},
|
||||
};
|
||||
const tabs = [
|
||||
{
|
||||
label: locale.baseText('mainSidebar.workflows'),
|
||||
value: to.workflows.name,
|
||||
to: to.workflows,
|
||||
},
|
||||
{
|
||||
label: locale.baseText('mainSidebar.credentials'),
|
||||
value: to.credentials.name,
|
||||
to: to.credentials,
|
||||
},
|
||||
{
|
||||
label: locale.baseText('mainSidebar.executions'),
|
||||
value: to.executions.name,
|
||||
to: to.executions,
|
||||
},
|
||||
];
|
||||
|
||||
if (props.showSettings) {
|
||||
tabs.push({
|
||||
label: locale.baseText('projects.settings'),
|
||||
value: VIEWS.PROJECT_SETTINGS,
|
||||
to: { name: VIEWS.PROJECT_SETTINGS, params: { projectId } },
|
||||
});
|
||||
}
|
||||
|
||||
return tabs;
|
||||
});
|
||||
watch(
|
||||
() => route?.name,
|
||||
() => {
|
||||
selectedTab.value = route?.name;
|
||||
// Select workflows tab if folders tab is selected
|
||||
selectedTab.value =
|
||||
route.name === VIEWS.PROJECTS_FOLDERS ? VIEWS.PROJECTS_WORKFLOWS : route.name;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nTabs v-model="selectedTab" :options="options" data-test-id="project-tabs" />
|
||||
</template>
|
||||
Reference in New Issue
Block a user