fix(editor): Project related frontend fixes (no-changelog) (#9482)

This commit is contained in:
Csaba Tuncsik 2024-05-22 15:54:55 +02:00 committed by GitHub
parent 62ee796895
commit 8f55bb1457
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 170 additions and 41 deletions

View file

@ -1,22 +1,30 @@
import { INSTANCE_ADMIN, INSTANCE_MEMBERS } from '../constants';
import { WorkflowsPage, WorkflowPage, CredentialsModal, CredentialsPage } from '../pages';
import {
WorkflowsPage,
WorkflowPage,
CredentialsModal,
CredentialsPage,
WorkflowExecutionsTab,
} from '../pages';
import * as projects from '../composables/projects';
const workflowsPage = new WorkflowsPage();
const workflowPage = new WorkflowPage();
const credentialsPage = new CredentialsPage();
const credentialsModal = new CredentialsModal();
const executionsTab = new WorkflowExecutionsTab();
describe('Projects', () => {
beforeEach(() => {
cy.resetDatabase();
cy.enableFeature('sharing');
cy.enableFeature('advancedPermissions');
cy.enableFeature('projectRole:admin');
cy.enableFeature('projectRole:editor');
cy.changeQuota('maxTeamProjects', -1);
});
it('should handle workflows and credentials', () => {
it('should handle workflows and credentials and menu items', () => {
cy.signin(INSTANCE_ADMIN);
cy.visit(workflowsPage.url);
workflowsPage.getters.workflowCards().should('not.have.length');
@ -147,5 +155,68 @@ describe('Projects', () => {
cy.wait('@credentialsList').then((interception) => {
expect(interception.request.url).not.to.contain('filter');
});
let menuItems = cy.getByTestId('menu-item');
menuItems.filter('[class*=active_]').should('have.length', 1);
menuItems.filter(':contains("Home")[class*=active_]').should('exist');
projects.getMenuItems().first().click();
menuItems = cy.getByTestId('menu-item');
menuItems.filter('[class*=active_]').should('have.length', 1);
menuItems.filter(':contains("Development")[class*=active_]').should('exist');
cy.intercept('GET', '/rest/workflows/*').as('loadWorkflow');
workflowsPage.getters.workflowCards().first().click();
cy.wait('@loadWorkflow');
menuItems = cy.getByTestId('menu-item');
menuItems.filter('[class*=active_]').should('have.length', 1);
menuItems.filter(':contains("Development")[class*=active_]').should('exist');
cy.intercept('GET', '/rest/executions*').as('loadExecutions');
executionsTab.actions.switchToExecutionsTab();
cy.wait('@loadExecutions');
menuItems = cy.getByTestId('menu-item');
menuItems.filter('[class*=active_]').should('have.length', 1);
menuItems.filter(':contains("Development")[class*=active_]').should('exist');
executionsTab.actions.switchToEditorTab();
menuItems = cy.getByTestId('menu-item');
menuItems.filter('[class*=active_]').should('have.length', 1);
menuItems.filter(':contains("Development")[class*=active_]').should('exist');
cy.getByTestId('menu-item').filter(':contains("Variables")').click();
cy.getByTestId('unavailable-resources-list').should('be.visible');
menuItems = cy.getByTestId('menu-item');
menuItems.filter('[class*=active_]').should('have.length', 1);
menuItems.filter(':contains("Variables")[class*=active_]').should('exist');
projects.getHomeButton().click();
menuItems = cy.getByTestId('menu-item');
menuItems.filter('[class*=active_]').should('have.length', 1);
menuItems.filter(':contains("Home")[class*=active_]').should('exist');
workflowsPage.getters.workflowCards().should('have.length', 2).first().click();
cy.wait('@loadWorkflow');
cy.getByTestId('execute-workflow-button').should('be.visible');
menuItems = cy.getByTestId('menu-item');
menuItems.filter(':contains("Home")[class*=active_]').should('not.exist');
menuItems = cy.getByTestId('menu-item');
menuItems.filter('[class*=active_]').should('have.length', 1);
menuItems.filter(':contains("Development")[class*=active_]').should('exist');
});
});

View file

@ -4,11 +4,14 @@ import type {
ProjectSharingData,
ProjectType,
} from '@/features/projects/projects.types';
import { ProjectTypes } from '@/features/projects/projects.utils';
export const createProjectSharingData = (projectType?: ProjectType): ProjectSharingData => ({
id: faker.string.uuid(),
name: faker.lorem.words({ min: 1, max: 3 }),
type: projectType || 'personal',
type: projectType ?? ProjectTypes.Personal,
createdAt: faker.date.past().toISOString(),
updatedAt: faker.date.recent().toISOString(),
});
export const createProjectListItem = (projectType?: ProjectType): ProjectListItem => {

View file

@ -96,6 +96,7 @@ import type { CredentialScope } from '@n8n/permissions';
import type { EventBus } from 'n8n-design-system/utils';
import { useRolesStore } from '@/stores/roles.store';
import type { RoleMap } from '@/types/roles.types';
import { ProjectTypes } from '@/features/projects/projects.utils';
export default defineComponent({
name: 'CredentialSharing',
@ -178,7 +179,7 @@ export default defineComponent({
);
},
isHomeTeamProject(): boolean {
return this.homeProject?.type === 'team';
return this.homeProject?.type === ProjectTypes.Team;
},
numberOfMembersInHomeTeamProject(): number {
return this.teamProject?.relations.length ?? 0;

View file

@ -384,6 +384,7 @@ import type { WorkflowScope } from '@n8n/permissions';
import { getWorkflowPermissions } from '@/permissions';
import { useExternalHooks } from '@/composables/useExternalHooks';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { ProjectTypes } from '@/features/projects/projects.utils';
export default defineComponent({
name: 'WorkflowSettings',
@ -604,7 +605,7 @@ export default defineComponent({
{
key: 'workflowsFromSameOwner',
value: this.$locale.baseText(
this.workflow.homeProject?.type === 'personal'
this.workflow.homeProject?.type === ProjectTypes.Personal
? 'workflowSettings.callerPolicy.options.workflowsFromPersonalProject'
: 'workflowSettings.callerPolicy.options.workflowsFromTeamProject',
{

View file

@ -152,6 +152,7 @@ import type {
} from '@/features/projects/projects.types';
import { useRolesStore } from '@/stores/roles.store';
import type { RoleMap } from '@/types/roles.types';
import { ProjectTypes } from '@/features/projects/projects.utils';
export default defineComponent({
name: 'WorkflowShareModal',
@ -238,7 +239,7 @@ export default defineComponent({
);
},
isHomeTeamProject(): boolean {
return this.workflow.homeProject?.type === 'team';
return this.workflow.homeProject?.type === ProjectTypes.Team;
},
numberOfMembersInHomeTeamProject(): number {
return this.teamProject?.relations.length ?? 0;

View file

@ -32,6 +32,7 @@
class="pt-2xs"
:projects="projectsStore.projects"
:placeholder="$locale.baseText('forms.resourceFiltersDropdown.owner.placeholder')"
:empty-options-text="$locale.baseText('projects.sharing.noMatchingProjects')"
@update:model-value="setKeyValue('homeProject', ($event as ProjectSharingData).id)"
/>
</enterprise-edition>

View file

@ -1,7 +1,7 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { useI18n } from '@/composables/useI18n';
import { splitName } from '@/features/projects/projects.utils';
import { ProjectTypes, splitName } from '@/features/projects/projects.utils';
import type { ICredentialsResponse, IWorkflowDb } from '@/Interface';
import type { Project } from '@/features/projects/projects.types';
@ -29,9 +29,12 @@ const badgeText = computed(() => {
});
const badgeIcon = computed(() => {
if (props.resource.sharedWithProjects?.length && props.resource.homeProject?.type !== 'team') {
if (
props.resource.sharedWithProjects?.length &&
props.resource.homeProject?.type !== ProjectTypes.Team
) {
return 'user-friends';
} else if (props.resource.homeProject?.type === 'team') {
} else if (props.resource.homeProject?.type === ProjectTypes.Team) {
return 'archive';
} else {
return '';

View file

@ -75,7 +75,12 @@ const onDelete = () => {
<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" />
<ProjectSharing
v-model="selectedProject"
class="pt-2xs"
:projects="props.projects"
:empty-options-text="locale.baseText('projects.sharing.noMatchingProjects')"
/>
</div>
<el-radio

View file

@ -1,6 +1,6 @@
<script lang="ts" setup>
import { ref, computed, onMounted, nextTick } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useRouter } from 'vue-router';
import type { IMenuItem } from 'n8n-design-system/types';
import { useI18n } from '@/composables/useI18n';
import { VIEWS } from '@/constants';
@ -16,7 +16,6 @@ type Props = {
const props = defineProps<Props>();
const route = useRoute();
const router = useRouter();
const locale = useI18n();
const toast = useToast();
@ -42,24 +41,6 @@ const addProject = computed<IMenuItem>(() => ({
isLoading: isCreatingProject.value,
}));
const activeTab = computed(() => {
let routes = [VIEWS.HOMEPAGE, VIEWS.WORKFLOWS, VIEWS.CREDENTIALS];
if (projectsStore.currentProjectId === undefined) {
routes = [
...routes,
VIEWS.NEW_WORKFLOW,
VIEWS.WORKFLOW_HISTORY,
VIEWS.WORKFLOW,
VIEWS.EXECUTION_HOME,
];
}
return routes.includes(route.name as VIEWS) ? 'home' : undefined;
});
const isActiveProject = (projectId: string) =>
route?.params?.projectId === projectId || projectsStore.currentProjectId === projectId
? projectId
: undefined;
const getProjectMenuItem = (project: ProjectListItem) => ({
id: project.id,
label: project.name,
@ -127,7 +108,7 @@ onMounted(async () => {
:item="home"
:compact="props.collapsed"
:handle-select="homeClicked"
:active-tab="activeTab"
:active-tab="projectsStore.projectNavActiveId"
mode="tabs"
data-test-id="project-home-menu-item"
/>
@ -146,7 +127,7 @@ onMounted(async () => {
:item="getProjectMenuItem(project)"
:compact="props.collapsed"
:handle-select="projectClicked"
:active-tab="isActiveProject(project.id)"
:active-tab="projectsStore.projectNavActiveId"
mode="tabs"
data-test-id="project-menu-item"
/>

View file

@ -14,6 +14,7 @@ type Props = {
readonly?: boolean;
static?: boolean;
placeholder?: string;
emptyOptionsText?: string;
};
const props = defineProps<Props>();
@ -34,6 +35,9 @@ const selectPlaceholder = computed(
? locale.baseText('projects.sharing.placeholder')
: locale.baseText('projects.sharing.placeholder.single')),
);
const noDataText = computed(
() => props.emptyOptionsText ?? locale.baseText('projects.sharing.noMatchingUsers'),
);
const filteredProjects = computed(() =>
props.projects
.filter(
@ -101,7 +105,7 @@ watch(
:filter-method="setFilter"
:placeholder="selectPlaceholder"
:default-first-option="true"
:no-data-text="locale.baseText('projects.sharing.noMatchingProjects')"
:no-data-text="noDataText"
size="large"
:disabled="props.readonly"
@update:model-value="onProjectSelected"

View file

@ -15,7 +15,7 @@ const processedName = computed(() => splitName(props.project.name ?? ''));
<div :class="$style.projectInfo" data-test-id="project-sharing-info">
<div>
<N8nAvatar :first-name="processedName.firstName" :last-name="processedName.lastName" />
<div class="flex flex-col">
<div :class="$style.text">
<p v-if="processedName.firstName || processedName.lastName">
{{ processedName.firstName }} {{ processedName.lastName }}
</p>
@ -54,4 +54,9 @@ const processedName = computed(() => splitName(props.project.name ?? ''));
line-height: var(--font-line-height-loose);
}
}
.text {
display: flex;
flex-direction: column;
}
</style>

View file

@ -12,6 +12,8 @@ import type {
} from '@/features/projects/projects.types';
import { useSettingsStore } from '@/stores/settings.store';
import { hasPermission } from '@/rbac/permissions';
import { ProjectTypes } from './projects.utils';
import type { IWorkflowDb } from '@/Interface';
export const useProjectsStore = defineStore('projects', () => {
const route = useRoute();
@ -27,16 +29,19 @@ export const useProjectsStore = defineStore('projects', () => {
team: 0,
public: 0,
});
const projectNavActiveIdState = ref<string | string[] | null>(null);
const currentProjectId = computed(
() =>
(route.params?.projectId as string | undefined) ||
(route.query?.projectId as string | undefined) ||
(route.params?.projectId as string | undefined) ??
(route.query?.projectId as string | undefined) ??
currentProject.value?.id,
);
const isProjectHome = computed(() => route.path.includes('home'));
const personalProjects = computed(() => projects.value.filter((p) => p.type === 'personal'));
const teamProjects = computed(() => projects.value.filter((p) => p.type === 'team'));
const personalProjects = computed(() =>
projects.value.filter((p) => p.type === ProjectTypes.Personal),
);
const teamProjects = computed(() => projects.value.filter((p) => p.type === ProjectTypes.Team));
const teamProjectsLimit = computed(() => settingsStore.settings.enterprise.projects.team.limit);
const teamProjectsAvailable = computed<boolean>(
() => settingsStore.settings.enterprise.projects.team.limit !== 0,
@ -56,6 +61,13 @@ export const useProjectsStore = defineStore('projects', () => {
hasPermission(['rbac'], { rbac: { scope: 'project:create' } }),
);
const projectNavActiveId = computed<string | string[] | null>({
get: () => route?.params?.projectId ?? projectNavActiveIdState.value,
set: (value: string | string[] | null) => {
projectNavActiveIdState.value = value;
},
});
const setCurrentProject = (project: Project | null) => {
currentProject.value = project;
};
@ -110,13 +122,37 @@ export const useProjectsStore = defineStore('projects', () => {
projectsCount.value = await projectsApi.getProjectsCount(rootStore.getRestApiContext);
};
const setProjectNavActiveIdByWorkflowHomeProject = async (
homeProject?: IWorkflowDb['homeProject'],
) => {
if (homeProject?.type === ProjectTypes.Personal) {
projectNavActiveId.value = 'home';
} else {
projectNavActiveId.value = homeProject?.id ?? null;
if (homeProject?.id && !currentProjectId.value) {
await getProject(homeProject?.id);
}
}
};
watch(
route,
async (newRoute) => {
projectNavActiveId.value = null;
if (newRoute?.path?.includes('home')) {
projectNavActiveId.value = 'home';
setCurrentProject(null);
}
if (newRoute?.path?.includes('workflow/')) {
if (currentProjectId.value) {
projectNavActiveId.value = currentProjectId.value;
} else {
projectNavActiveId.value = 'home';
}
}
if (!newRoute?.params?.projectId) {
return;
}
@ -140,6 +176,7 @@ export const useProjectsStore = defineStore('projects', () => {
canCreateProjects,
hasPermissionToCreateProjects,
teamProjectsAvailable,
projectNavActiveId,
setCurrentProject,
getAllProjects,
getMyProjects,
@ -150,5 +187,6 @@ export const useProjectsStore = defineStore('projects', () => {
updateProject,
deleteProject,
getProjectsCount,
setProjectNavActiveIdByWorkflowHomeProject,
};
});

View file

@ -1,8 +1,11 @@
import type { Scope } from '@n8n/permissions';
import type { IUserResponse } from '@/Interface';
import type { ProjectRole } from '@/types/roles.types';
import type { ProjectTypes } from '@/features/projects/projects.utils';
export type ProjectType = 'personal' | 'team' | 'public';
type ProjectTypeKeys = typeof ProjectTypes;
export type ProjectType = ProjectTypeKeys[keyof ProjectTypeKeys];
export type ProjectRelation = Pick<IUserResponse, 'id' | 'email' | 'firstName' | 'lastName'> & {
role: ProjectRole;
};

View file

@ -26,3 +26,9 @@ export const splitName = (
}
}
};
export const ProjectTypes = {
Personal: 'personal',
Team: 'team',
Public: 'public',
} as const;

View file

@ -593,7 +593,7 @@
"credentials.noResults.withSearch.switchToShared.preamble": "some credentials may be",
"credentials.noResults.withSearch.switchToShared.link": "hidden",
"credentials.create.personal.toast.title": "Credential successfully created",
"credentials.create.personal.toast.text": "This credential is currently private to you. View sharing options.",
"credentials.create.personal.toast.text": "This credential is currently private to you.",
"credentials.create.project.toast.title": "Credential successfully created in {projectName}",
"credentials.create.project.toast.text": "All members from {projectName} will have access to this credential.",
"credentials.shareModal.info.members": "This credential is owned by the {projectName} project which currently has {members} with access to this credential.",
@ -2472,6 +2472,7 @@
"projects.settings.role.upgrade.title": "Upgrade to unlock additional roles",
"projects.settings.role.upgrade.message": "You're currently limited to {limit} on the {planName} plan and can only assign the admin role to users within this project. To create more projects and unlock additional roles, upgrade your plan.",
"projects.sharing.noMatchingProjects": "There are no available projects",
"projects.sharing.noMatchingUsers": "No matching users",
"projects.sharing.placeholder": "Add projects...",
"projects.sharing.placeholder.single": "Select project",
"projects.error.title": "Project error",

View file

@ -395,6 +395,7 @@ import type { ProjectSharingData } from '@/features/projects/projects.types';
import { useAIStore } from '@/stores/ai.store';
import { useStorage } from '@/composables/useStorage';
import { isJSPlumbEndpointElement } from '@/utils/typeGuards';
import { ProjectTypes } from '@/features/projects/projects.utils';
interface AddNodeOptions {
position?: XYPosition;
@ -3726,6 +3727,10 @@ export default defineComponent({
await this.openWorkflow(workflow);
await this.checkAndInitDebugMode();
await this.projectsStore.setProjectNavActiveIdByWorkflowHomeProject(
workflow.homeProject,
);
if (workflow.meta?.onboardingId) {
this.$telemetry.track(
`User opened workflow from onboarding template with ID ${workflow.meta.onboardingId}`,
@ -4670,7 +4675,7 @@ export default defineComponent({
async loadCredentials(): Promise<void> {
const workflow = this.workflowsStore.getWorkflowById(this.currentWorkflow);
const projectId =
workflow?.homeProject?.type === 'personal'
workflow?.homeProject?.type === ProjectTypes.Personal
? this.projectsStore.personalProject?.id
: workflow?.homeProject?.id;
await this.credentialsStore.fetchAllCredentials(projectId);