mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
fix(editor): Project related frontend fixes (no-changelog) (#9482)
This commit is contained in:
parent
62ee796895
commit
8f55bb1457
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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',
|
||||
{
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 '';
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -26,3 +26,9 @@ export const splitName = (
|
|||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const ProjectTypes = {
|
||||
Personal: 'personal',
|
||||
Team: 'team',
|
||||
Public: 'public',
|
||||
} as const;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in a new issue