mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
fix(editor): UX Improvements to RBAC feature set (#9683)
This commit is contained in:
parent
5b440a7679
commit
028a8a2c75
|
@ -21,7 +21,7 @@ export const addProjectMember = (email: string) => {
|
||||||
getProjectMembersSelect().click();
|
getProjectMembersSelect().click();
|
||||||
getProjectMembersSelect().get('.el-select-dropdown__item').contains(email.toLowerCase()).click();
|
getProjectMembersSelect().get('.el-select-dropdown__item').contains(email.toLowerCase()).click();
|
||||||
};
|
};
|
||||||
export const getProjectNameInput = () => cy.get('#projectName');
|
export const getProjectNameInput = () => cy.get('#projectName').find('input');
|
||||||
export const getResourceMoveModal = () => cy.getByTestId('project-move-resource-modal');
|
export const getResourceMoveModal = () => cy.getByTestId('project-move-resource-modal');
|
||||||
export const getResourceMoveConfirmModal = () =>
|
export const getResourceMoveConfirmModal = () =>
|
||||||
cy.getByTestId('project-move-resource-confirm-modal');
|
cy.getByTestId('project-move-resource-confirm-modal');
|
||||||
|
|
|
@ -15,6 +15,7 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import Avatar from 'vue-boring-avatars';
|
import Avatar from 'vue-boring-avatars';
|
||||||
|
import { getInitials } from '../../utils/labelUtil';
|
||||||
|
|
||||||
interface AvatarProps {
|
interface AvatarProps {
|
||||||
firstName: string;
|
firstName: string;
|
||||||
|
@ -37,11 +38,7 @@ const props = withDefaults(defineProps<AvatarProps>(), {
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const initials = computed(
|
const initials = computed(() => getInitials(`${props.firstName} ${props.lastName}`));
|
||||||
() =>
|
|
||||||
(props.firstName ? props.firstName.charAt(0) : '') +
|
|
||||||
(props.lastName ? props.lastName.charAt(0) : ''),
|
|
||||||
);
|
|
||||||
|
|
||||||
const getColors = (colors: string[]): string[] => {
|
const getColors = (colors: string[]): string[] => {
|
||||||
const style = getComputedStyle(document.body);
|
const style = getComputedStyle(document.body);
|
||||||
|
|
|
@ -97,7 +97,7 @@ import N8nIcon from '../N8nIcon';
|
||||||
import ConditionalRouterLink from '../ConditionalRouterLink';
|
import ConditionalRouterLink from '../ConditionalRouterLink';
|
||||||
import type { IMenuItem } from '../../types';
|
import type { IMenuItem } from '../../types';
|
||||||
import { doesMenuItemMatchCurrentRoute } from './routerUtil';
|
import { doesMenuItemMatchCurrentRoute } from './routerUtil';
|
||||||
import { getInitials } from './labelUtil';
|
import { getInitials } from '../../utils/labelUtil';
|
||||||
|
|
||||||
interface MenuItemProps {
|
interface MenuItemProps {
|
||||||
item: IMenuItem;
|
item: IMenuItem;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { getInitials } from '../labelUtil';
|
import { getInitials } from './labelUtil';
|
||||||
|
|
||||||
describe('labelUtil.getInitials', () => {
|
describe('labelUtil.getInitials', () => {
|
||||||
it.each([
|
it.each([
|
|
@ -13,6 +13,7 @@ import type { ProjectSharingData } from '@/types/projects.types';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue';
|
import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { ResourceType } from '@/utils/projects.utils';
|
||||||
|
|
||||||
const CREDENTIAL_LIST_ITEM_ACTIONS = {
|
const CREDENTIAL_LIST_ITEM_ACTIONS = {
|
||||||
OPEN: 'open',
|
OPEN: 'open',
|
||||||
|
@ -45,6 +46,7 @@ const uiStore = useUIStore();
|
||||||
const credentialsStore = useCredentialsStore();
|
const credentialsStore = useCredentialsStore();
|
||||||
const projectsStore = useProjectsStore();
|
const projectsStore = useProjectsStore();
|
||||||
|
|
||||||
|
const resourceTypeLabel = computed(() => locale.baseText('generic.credential').toLowerCase());
|
||||||
const credentialType = computed(() => credentialsStore.getCredentialTypeByName(props.data.type));
|
const credentialType = computed(() => credentialsStore.getCredentialTypeByName(props.data.type));
|
||||||
const credentialPermissions = computed(() => getCredentialPermissions(props.data));
|
const credentialPermissions = computed(() => getCredentialPermissions(props.data));
|
||||||
const actions = computed(() => {
|
const actions = computed(() => {
|
||||||
|
@ -76,7 +78,7 @@ const formattedCreatedAtDate = computed(() => {
|
||||||
|
|
||||||
return dateformat(
|
return dateformat(
|
||||||
props.data.createdAt,
|
props.data.createdAt,
|
||||||
`d mmmm${props.data.createdAt.startsWith(currentYear) ? '' : ', yyyy'}`,
|
`d mmmm${String(props.data.createdAt).startsWith(currentYear) ? '' : ', yyyy'}`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -121,7 +123,8 @@ function moveResource() {
|
||||||
name: PROJECT_MOVE_RESOURCE_MODAL,
|
name: PROJECT_MOVE_RESOURCE_MODAL,
|
||||||
data: {
|
data: {
|
||||||
resource: props.data,
|
resource: props.data,
|
||||||
resourceType: locale.baseText('generic.credential').toLocaleLowerCase(),
|
resourceType: ResourceType.Credential,
|
||||||
|
resourceTypeLabel: resourceTypeLabel.value,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -150,7 +153,12 @@ function moveResource() {
|
||||||
</div>
|
</div>
|
||||||
<template #append>
|
<template #append>
|
||||||
<div :class="$style.cardActions" @click.stop>
|
<div :class="$style.cardActions" @click.stop>
|
||||||
<ProjectCardBadge :resource="data" :personal-project="projectsStore.personalProject" />
|
<ProjectCardBadge
|
||||||
|
:resource="data"
|
||||||
|
:resource-type="ResourceType.Credential"
|
||||||
|
:resource-type-label="resourceTypeLabel"
|
||||||
|
:personal-project="projectsStore.personalProject"
|
||||||
|
/>
|
||||||
<n8n-action-toggle
|
<n8n-action-toggle
|
||||||
data-test-id="credential-card-actions"
|
data-test-id="credential-card-actions"
|
||||||
:actions="actions"
|
:actions="actions"
|
||||||
|
|
|
@ -674,10 +674,10 @@ export default defineComponent({
|
||||||
methods: {
|
methods: {
|
||||||
closeDialog() {
|
closeDialog() {
|
||||||
this.modalBus.emit('close');
|
this.modalBus.emit('close');
|
||||||
// In case the redirect to canvas for new users didn't happen
|
// In case the redirect to homepage for new users didn't happen
|
||||||
// we try again after closing the modal
|
// we try again after closing the modal
|
||||||
if (this.$route.name !== VIEWS.NEW_WORKFLOW) {
|
if (this.$route.name !== VIEWS.HOMEPAGE) {
|
||||||
void this.$router.replace({ name: VIEWS.NEW_WORKFLOW });
|
void this.$router.replace({ name: VIEWS.HOMEPAGE });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async loadDomainBlocklist() {
|
async loadDomainBlocklist() {
|
||||||
|
|
|
@ -23,6 +23,7 @@ describe('ProjectCardBadge', () => {
|
||||||
id: '1',
|
id: '1',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
resourceType: 'workflow',
|
||||||
personalProject: {
|
personalProject: {
|
||||||
id: '1',
|
id: '1',
|
||||||
},
|
},
|
||||||
|
@ -49,6 +50,7 @@ describe('ProjectCardBadge', () => {
|
||||||
name,
|
name,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
resourceType: 'workflow',
|
||||||
personalProject: {
|
personalProject: {
|
||||||
id: '2',
|
id: '2',
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import type { ResourceType } from '@/utils/projects.utils';
|
||||||
import { splitName } from '@/utils/projects.utils';
|
import { splitName } from '@/utils/projects.utils';
|
||||||
import type { ICredentialsResponse, IWorkflowDb } from '@/Interface';
|
import type { ICredentialsResponse, IWorkflowDb } from '@/Interface';
|
||||||
import type { Project } from '@/types/projects.types';
|
import type { Project } from '@/types/projects.types';
|
||||||
|
@ -8,45 +9,111 @@ import { ProjectTypes } from '@/types/projects.types';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
resource: IWorkflowDb | ICredentialsResponse;
|
resource: IWorkflowDb | ICredentialsResponse;
|
||||||
|
resourceType: ResourceType;
|
||||||
|
resourceTypeLabel: string;
|
||||||
personalProject: Project | null;
|
personalProject: Project | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const enum ProjectState {
|
||||||
|
SharedPersonal = 'shared-personal',
|
||||||
|
SharedOwned = 'shared-owned',
|
||||||
|
Owned = 'owned',
|
||||||
|
Personal = 'personal',
|
||||||
|
Team = 'team',
|
||||||
|
Unknown = 'unknown',
|
||||||
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
const locale = useI18n();
|
const i18n = useI18n();
|
||||||
|
|
||||||
const badgeText = computed(() => {
|
const projectState = computed(() => {
|
||||||
if (
|
if (
|
||||||
(props.resource.homeProject &&
|
(props.resource.homeProject &&
|
||||||
props.personalProject &&
|
props.personalProject &&
|
||||||
props.resource.homeProject.id === props.personalProject.id) ||
|
props.resource.homeProject.id === props.personalProject.id) ||
|
||||||
!props.resource.homeProject
|
!props.resource.homeProject
|
||||||
) {
|
) {
|
||||||
return locale.baseText('generic.ownedByMe');
|
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) {
|
||||||
|
return ProjectState.Team;
|
||||||
|
}
|
||||||
|
return ProjectState.Unknown;
|
||||||
|
});
|
||||||
|
const badgeText = computed(() => {
|
||||||
|
if (
|
||||||
|
projectState.value === ProjectState.Owned ||
|
||||||
|
projectState.value === ProjectState.SharedOwned
|
||||||
|
) {
|
||||||
|
return i18n.baseText('generic.ownedByMe');
|
||||||
} else {
|
} else {
|
||||||
const { firstName, lastName, email } = splitName(props.resource.homeProject?.name ?? '');
|
const { firstName, lastName, email } = splitName(props.resource.homeProject?.name ?? '');
|
||||||
return !firstName ? email : `${firstName}${lastName ? ' ' + lastName : ''}`;
|
return (!firstName ? email : `${firstName}${lastName ? ' ' + lastName : ''}`) ?? '';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const badgeIcon = computed(() => {
|
const badgeIcon = computed(() => {
|
||||||
if (
|
switch (projectState.value) {
|
||||||
props.resource.sharedWithProjects?.length &&
|
case ProjectState.SharedPersonal:
|
||||||
props.resource.homeProject?.type !== ProjectTypes.Team
|
case ProjectState.SharedOwned:
|
||||||
) {
|
return 'user-friends';
|
||||||
return 'user-friends';
|
case ProjectState.Team:
|
||||||
} else if (props.resource.homeProject?.type === ProjectTypes.Team) {
|
return 'archive';
|
||||||
return 'archive';
|
default:
|
||||||
} else {
|
return '';
|
||||||
return '';
|
}
|
||||||
|
});
|
||||||
|
const badgeTooltip = computed(() => {
|
||||||
|
switch (projectState.value) {
|
||||||
|
case ProjectState.SharedOwned:
|
||||||
|
return i18n.baseText('projects.badge.tooltip.sharedOwned', {
|
||||||
|
interpolate: {
|
||||||
|
resourceTypeLabel: props.resourceTypeLabel,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
case ProjectState.SharedPersonal:
|
||||||
|
return i18n.baseText('projects.badge.tooltip.sharedPersonal', {
|
||||||
|
interpolate: {
|
||||||
|
resourceTypeLabel: props.resourceTypeLabel,
|
||||||
|
name: badgeText.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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<n8n-badge v-if="badgeText" class="mr-xs" theme="tertiary" bold data-test-id="card-badge">
|
<N8nTooltip :disabled="!badgeTooltip" placement="top">
|
||||||
{{ badgeText }}
|
<N8nBadge v-if="badgeText" class="mr-xs" theme="tertiary" bold data-test-id="card-badge">
|
||||||
<n8n-icon v-if="badgeIcon" :icon="badgeIcon" size="small" class="ml-5xs" />
|
{{ badgeText }}
|
||||||
</n8n-badge>
|
<N8nIcon v-if="badgeIcon" :icon="badgeIcon" size="small" class="ml-5xs" />
|
||||||
|
</N8nBadge>
|
||||||
|
<template #content>
|
||||||
|
{{ badgeTooltip }}
|
||||||
|
</template>
|
||||||
|
</N8nTooltip>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" module></style>
|
<style lang="scss" module></style>
|
||||||
|
|
|
@ -49,6 +49,7 @@ describe('ProjectMoveResourceConfirmModal', () => {
|
||||||
id: '1',
|
id: '1',
|
||||||
},
|
},
|
||||||
projectId: '1',
|
projectId: '1',
|
||||||
|
projectName: 'My Project',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const { getByRole, getAllByRole } = renderComponent({ props });
|
const { getByRole, getAllByRole } = renderComponent({ props });
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed, h } from 'vue';
|
||||||
import type { ICredentialsResponse, IWorkflowDb } from '@/Interface';
|
import type { ICredentialsResponse, IWorkflowDb } from '@/Interface';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
@ -8,13 +8,18 @@ import Modal from '@/components/Modal.vue';
|
||||||
import { N8nCheckbox, N8nText } from 'n8n-design-system';
|
import { N8nCheckbox, N8nText } from 'n8n-design-system';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
|
import { VIEWS } from '@/constants';
|
||||||
|
import ProjectMoveSuccessToastMessage from '@/components/Projects/ProjectMoveSuccessToastMessage.vue';
|
||||||
|
import { ResourceType } from '@/utils/projects.utils';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modalName: string;
|
modalName: string;
|
||||||
data: {
|
data: {
|
||||||
resource: IWorkflowDb | ICredentialsResponse;
|
resource: IWorkflowDb | ICredentialsResponse;
|
||||||
resourceType: 'workflow' | 'credential';
|
resourceType: ResourceType;
|
||||||
|
resourceTypeLabel: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
projectName: string;
|
||||||
};
|
};
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
@ -28,7 +33,7 @@ const checks = ref([false, false]);
|
||||||
const allChecked = computed(() => checks.value.every(Boolean));
|
const allChecked = computed(() => checks.value.every(Boolean));
|
||||||
|
|
||||||
const moveResourceLabel = computed(() =>
|
const moveResourceLabel = computed(() =>
|
||||||
props.data.resourceType === 'workflow'
|
props.data.resourceType === ResourceType.Workflow
|
||||||
? i18n.baseText('projects.move.workflow.confirm.modal.label')
|
? i18n.baseText('projects.move.workflow.confirm.modal.label')
|
||||||
: i18n.baseText('projects.move.credential.confirm.modal.label'),
|
: i18n.baseText('projects.move.credential.confirm.modal.label'),
|
||||||
);
|
);
|
||||||
|
@ -49,12 +54,30 @@ const confirm = async () => {
|
||||||
[`${props.data.resourceType}_id`]: props.data.resource.id,
|
[`${props.data.resourceType}_id`]: props.data.resource.id,
|
||||||
project_from_type: projectsStore.currentProject?.type ?? projectsStore.personalProject?.type,
|
project_from_type: projectsStore.currentProject?.type ?? projectsStore.personalProject?.type,
|
||||||
});
|
});
|
||||||
|
toast.showToast({
|
||||||
|
title: i18n.baseText('projects.move.resource.success.title', {
|
||||||
|
interpolate: {
|
||||||
|
resourceTypeLabel: props.data.resourceTypeLabel,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
message: h(ProjectMoveSuccessToastMessage, {
|
||||||
|
routeName:
|
||||||
|
props.data.resourceType === ResourceType.Workflow
|
||||||
|
? VIEWS.PROJECTS_WORKFLOWS
|
||||||
|
: VIEWS.PROJECTS_CREDENTIALS,
|
||||||
|
resource: props.data.resource,
|
||||||
|
resourceTypeLabel: props.data.resourceTypeLabel,
|
||||||
|
projectId: props.data.projectId,
|
||||||
|
projectName: props.data.projectName,
|
||||||
|
}),
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.showError(
|
toast.showError(
|
||||||
error.message,
|
error.message,
|
||||||
i18n.baseText('projects.move.resource.error.title', {
|
i18n.baseText('projects.move.resource.error.title', {
|
||||||
interpolate: {
|
interpolate: {
|
||||||
resourceType: props.data.resourceType,
|
resourceTypeLabel: props.data.resourceTypeLabel,
|
||||||
resourceName: props.data.resource.name,
|
resourceName: props.data.resource.name,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
@ -74,7 +97,7 @@ const confirm = async () => {
|
||||||
<N8nCheckbox v-model="checks[1]">
|
<N8nCheckbox v-model="checks[1]">
|
||||||
<N8nText>
|
<N8nText>
|
||||||
<i18n-t keypath="projects.move.resource.confirm.modal.label">
|
<i18n-t keypath="projects.move.resource.confirm.modal.label">
|
||||||
<template #resourceType>{{ props.data.resourceType }}</template>
|
<template #resourceTypeLabel>{{ props.data.resourceTypeLabel }}</template>
|
||||||
<template #numberOfUsers>{{
|
<template #numberOfUsers>{{
|
||||||
i18n.baseText('projects.move.resource.confirm.modal.numberOfUsers', {
|
i18n.baseText('projects.move.resource.confirm.modal.numberOfUsers', {
|
||||||
interpolate: {
|
interpolate: {
|
||||||
|
|
|
@ -34,6 +34,7 @@ describe('ProjectMoveResourceModal', () => {
|
||||||
id: '1',
|
id: '1',
|
||||||
},
|
},
|
||||||
projectId: '1',
|
projectId: '1',
|
||||||
|
projectName: 'My Project',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
renderComponent({ props });
|
renderComponent({ props });
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import Modal from '@/components/Modal.vue';
|
import Modal from '@/components/Modal.vue';
|
||||||
import { PROJECT_MOVE_RESOURCE_CONFIRM_MODAL } from '@/constants';
|
import { PROJECT_MOVE_RESOURCE_CONFIRM_MODAL } from '@/constants';
|
||||||
|
import type { ResourceType } from '@/utils/projects.utils';
|
||||||
import { splitName } from '@/utils/projects.utils';
|
import { splitName } from '@/utils/projects.utils';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
|
|
||||||
|
@ -13,7 +14,8 @@ const props = defineProps<{
|
||||||
modalName: string;
|
modalName: string;
|
||||||
data: {
|
data: {
|
||||||
resource: IWorkflowDb | ICredentialsResponse;
|
resource: IWorkflowDb | ICredentialsResponse;
|
||||||
resourceType: 'workflow' | 'credential';
|
resourceType: ResourceType;
|
||||||
|
resourceTypeLabel: string;
|
||||||
};
|
};
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
@ -46,7 +48,9 @@ const next = () => {
|
||||||
data: {
|
data: {
|
||||||
resource: props.data.resource,
|
resource: props.data.resource,
|
||||||
resourceType: props.data.resourceType,
|
resourceType: props.data.resourceType,
|
||||||
|
resourceTypeLabel: props.data.resourceTypeLabel,
|
||||||
projectId: projectId.value,
|
projectId: projectId.value,
|
||||||
|
projectName: availableProjects.value.find((p) => p.id === projectId.value)?.name ?? '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -64,7 +68,7 @@ onMounted(() => {
|
||||||
<N8nHeading tag="h2" size="xlarge" class="mb-m">
|
<N8nHeading tag="h2" size="xlarge" class="mb-m">
|
||||||
{{
|
{{
|
||||||
i18n.baseText('projects.move.resource.modal.title', {
|
i18n.baseText('projects.move.resource.modal.title', {
|
||||||
interpolate: { resourceType: props.data.resourceType },
|
interpolate: { resourceTypeLabel: props.data.resourceTypeLabel },
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
</N8nHeading>
|
</N8nHeading>
|
||||||
|
@ -74,7 +78,7 @@ onMounted(() => {
|
||||||
><strong>{{ props.data.resource.name }}</strong></template
|
><strong>{{ props.data.resource.name }}</strong></template
|
||||||
>
|
>
|
||||||
<template #resourceHomeProjectName>{{ processedName }}</template>
|
<template #resourceHomeProjectName>{{ processedName }}</template>
|
||||||
<template #resourceType>{{ props.data.resourceType }}</template>
|
<template #resourceTypeLabel>{{ props.data.resourceTypeLabel }}</template>
|
||||||
</i18n-t>
|
</i18n-t>
|
||||||
</N8nText>
|
</N8nText>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { ICredentialsResponse, IWorkflowDb } from '@/Interface';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
routeName: string;
|
||||||
|
resource: IWorkflowDb | ICredentialsResponse;
|
||||||
|
resourceTypeLabel: string;
|
||||||
|
projectId: string;
|
||||||
|
projectName: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<i18n-t keypath="projects.move.resource.success.message">
|
||||||
|
<template #resourceTypeLabel>{{ props.resourceTypeLabel }}</template>
|
||||||
|
<template #resourceName>{{ props.resource.name }}</template>
|
||||||
|
<template #targetProjectName>{{ props.projectName }}</template>
|
||||||
|
<template #link>
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: props.routeName,
|
||||||
|
params: { projectId: props.projectId },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<p class="pt-s">
|
||||||
|
<i18n-t keypath="projects.move.resource.success.link">
|
||||||
|
<template #targetProjectName>{{ props.projectName }}</template>
|
||||||
|
</i18n-t>
|
||||||
|
</p>
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
|
</template>
|
|
@ -124,6 +124,9 @@ onMounted(async () => {
|
||||||
<N8nMenuItem
|
<N8nMenuItem
|
||||||
v-for="project in displayProjects"
|
v-for="project in displayProjects"
|
||||||
:key="project.id"
|
:key="project.id"
|
||||||
|
:class="{
|
||||||
|
[$style.collapsed]: props.collapsed,
|
||||||
|
}"
|
||||||
:item="getProjectMenuItem(project)"
|
:item="getProjectMenuItem(project)"
|
||||||
:compact="props.collapsed"
|
:compact="props.collapsed"
|
||||||
:handle-select="projectClicked"
|
:handle-select="projectClicked"
|
||||||
|
@ -194,6 +197,10 @@ onMounted(async () => {
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.collapsed {
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|
|
@ -24,6 +24,7 @@ import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
|
import { ResourceType } from '@/utils/projects.utils';
|
||||||
|
|
||||||
const WORKFLOW_LIST_ITEM_ACTIONS = {
|
const WORKFLOW_LIST_ITEM_ACTIONS = {
|
||||||
OPEN: 'open',
|
OPEN: 'open',
|
||||||
|
@ -72,6 +73,7 @@ const usersStore = useUsersStore();
|
||||||
const workflowsStore = useWorkflowsStore();
|
const workflowsStore = useWorkflowsStore();
|
||||||
const projectsStore = useProjectsStore();
|
const projectsStore = useProjectsStore();
|
||||||
|
|
||||||
|
const resourceTypeLabel = computed(() => locale.baseText('generic.workflow').toLowerCase());
|
||||||
const currentUser = computed(() => usersStore.currentUser ?? ({} as IUser));
|
const currentUser = computed(() => usersStore.currentUser ?? ({} as IUser));
|
||||||
const workflowPermissions = computed(() => getWorkflowPermissions(props.data));
|
const workflowPermissions = computed(() => getWorkflowPermissions(props.data));
|
||||||
const actions = computed(() => {
|
const actions = computed(() => {
|
||||||
|
@ -222,7 +224,8 @@ function moveResource() {
|
||||||
name: PROJECT_MOVE_RESOURCE_MODAL,
|
name: PROJECT_MOVE_RESOURCE_MODAL,
|
||||||
data: {
|
data: {
|
||||||
resource: props.data,
|
resource: props.data,
|
||||||
resourceType: locale.baseText('generic.workflow').toLocaleLowerCase(),
|
resourceType: ResourceType.Workflow,
|
||||||
|
resourceTypeLabel: resourceTypeLabel.value,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -261,7 +264,12 @@ function moveResource() {
|
||||||
</div>
|
</div>
|
||||||
<template #append>
|
<template #append>
|
||||||
<div :class="$style.cardActions" @click.stop>
|
<div :class="$style.cardActions" @click.stop>
|
||||||
<ProjectCardBadge :resource="data" :personal-project="projectsStore.personalProject" />
|
<ProjectCardBadge
|
||||||
|
:resource="data"
|
||||||
|
:resource-type="ResourceType.Workflow"
|
||||||
|
:resource-type-label="resourceTypeLabel"
|
||||||
|
:personal-project="projectsStore.personalProject"
|
||||||
|
/>
|
||||||
<WorkflowActivator
|
<WorkflowActivator
|
||||||
class="mr-s"
|
class="mr-s"
|
||||||
:workflow-active="data.active"
|
:workflow-active="data.active"
|
||||||
|
|
|
@ -48,7 +48,7 @@ const renderComponent = createComponentRenderer(PersonalizationModal, {
|
||||||
global: {
|
global: {
|
||||||
mocks: {
|
mocks: {
|
||||||
$route: {
|
$route: {
|
||||||
name: VIEWS.NEW_WORKFLOW,
|
name: VIEWS.HOMEPAGE,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -29,7 +29,6 @@
|
||||||
/>
|
/>
|
||||||
<ProjectSharing
|
<ProjectSharing
|
||||||
v-model="selectedProject"
|
v-model="selectedProject"
|
||||||
class="pt-2xs"
|
|
||||||
:projects="projectsStore.projects"
|
:projects="projectsStore.projects"
|
||||||
:placeholder="$locale.baseText('forms.resourceFiltersDropdown.owner.placeholder')"
|
:placeholder="$locale.baseText('forms.resourceFiltersDropdown.owner.placeholder')"
|
||||||
:empty-options-text="$locale.baseText('projects.sharing.noMatchingProjects')"
|
:empty-options-text="$locale.baseText('projects.sharing.noMatchingProjects')"
|
||||||
|
@ -150,7 +149,6 @@ export default defineComponent({
|
||||||
|
|
||||||
.filters-dropdown {
|
.filters-dropdown {
|
||||||
width: 280px;
|
width: 280px;
|
||||||
padding-bottom: var(--spacing-s);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filters-dropdown-footer {
|
.filters-dropdown-footer {
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { setActivePinia, createPinia } from 'pinia';
|
||||||
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
|
import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue';
|
||||||
|
import type router from 'vue-router';
|
||||||
|
|
||||||
|
vi.mock('vue-router', async (importOriginal) => {
|
||||||
|
const { RouterLink } = await importOriginal<typeof router>();
|
||||||
|
return {
|
||||||
|
RouterLink,
|
||||||
|
useRoute: () => ({
|
||||||
|
params: {},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderComponent = createComponentRenderer(ResourcesListLayout);
|
||||||
|
|
||||||
|
describe('ResourcesListLayout', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render loading skeleton', () => {
|
||||||
|
const { container } = renderComponent({
|
||||||
|
props: {
|
||||||
|
loading: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelectorAll('.el-skeleton__p')).toHaveLength(25);
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,10 +1,8 @@
|
||||||
<template>
|
<template>
|
||||||
<PageViewLayout>
|
<PageViewLayout>
|
||||||
<template #header> <slot name="header" /> </template>
|
<template #header> <slot name="header" /> </template>
|
||||||
<div v-if="loading">
|
<div v-if="loading" class="resource-list-loading">
|
||||||
<n8n-loading :class="[$style['header-loading'], 'mb-l']" variant="custom" />
|
<n8n-loading :rows="25" :shrink-last="false" />
|
||||||
<n8n-loading :class="[$style['card-loading'], 'mb-2xs']" variant="custom" />
|
|
||||||
<n8n-loading :class="$style['card-loading']" variant="custom" />
|
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div v-if="resources.length === 0">
|
<div v-if="resources.length === 0">
|
||||||
|
@ -245,6 +243,11 @@ export default defineComponent({
|
||||||
itemSize: 80,
|
itemSize: 80,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
emits: ['update:filters', 'click:add', 'sort'],
|
emits: ['update:filters', 'click:add', 'sort'],
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
|
@ -254,7 +257,6 @@ export default defineComponent({
|
||||||
const usersStore = useUsersStore();
|
const usersStore = useUsersStore();
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
const loading = ref(true);
|
|
||||||
const sortBy = ref(props.sortOptions[0]);
|
const sortBy = ref(props.sortOptions[0]);
|
||||||
const hasFilters = ref(false);
|
const hasFilters = ref(false);
|
||||||
const filtersModel = ref(props.filters);
|
const filtersModel = ref(props.filters);
|
||||||
|
@ -434,6 +436,20 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => filtersModel.value.tags,
|
||||||
|
() => {
|
||||||
|
sendFiltersTelemetry('tags');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => filtersModel.value.type,
|
||||||
|
() => {
|
||||||
|
sendFiltersTelemetry('type');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => filtersModel.value.search,
|
() => filtersModel.value.search,
|
||||||
() => callDebounced(sendFiltersTelemetry, { debounceTime: 1000, trailing: true }, 'search'),
|
() => callDebounced(sendFiltersTelemetry, { debounceTime: 1000, trailing: true }, 'search'),
|
||||||
|
@ -456,7 +472,6 @@ export default defineComponent({
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await props.initialize();
|
await props.initialize();
|
||||||
loading.value = false;
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
focusSearchInput();
|
focusSearchInput();
|
||||||
|
@ -467,7 +482,6 @@ export default defineComponent({
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loading,
|
|
||||||
i18n,
|
i18n,
|
||||||
search,
|
search,
|
||||||
usersStore,
|
usersStore,
|
||||||
|
@ -525,15 +539,48 @@ export default defineComponent({
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-loading {
|
|
||||||
height: 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-loading {
|
|
||||||
height: 69px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.datatable {
|
.datatable {
|
||||||
padding-bottom: var(--spacing-s);
|
padding-bottom: var(--spacing-s);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.resource-list-loading {
|
||||||
|
position: relative;
|
||||||
|
height: 0;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
/*
|
||||||
|
Show the loading skeleton only if the loading takes longer than 300ms
|
||||||
|
*/
|
||||||
|
animation: 0.01s linear 0.3s forwards changeVisibility;
|
||||||
|
:deep(.el-skeleton) {
|
||||||
|
position: absolute;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
> div:first-child {
|
||||||
|
.el-skeleton__item {
|
||||||
|
height: 42px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-skeleton__item {
|
||||||
|
height: 69px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes changeVisibility {
|
||||||
|
from {
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -2473,15 +2473,22 @@
|
||||||
"projects.create.limit": "{num} project | {num} projects",
|
"projects.create.limit": "{num} project | {num} projects",
|
||||||
"projects.create.limitReached": "You have reached the {planName} plan limit of {limit}. Upgrade your plan to unlock more projects. {link}",
|
"projects.create.limitReached": "You have reached the {planName} plan limit of {limit}. Upgrade your plan to unlock more projects. {link}",
|
||||||
"projects.create.limitReached.link": "View plans",
|
"projects.create.limitReached.link": "View plans",
|
||||||
"projects.move.resource.modal.title": "Choose a project to move this {resourceType} to",
|
"projects.move.resource.modal.title": "Choose a project to move this {resourceTypeLabel} to",
|
||||||
"projects.move.resource.modal.message": "\"{resourceName}\" is currently in the \"{resourceHomeProjectName}\" project. Which project would you like to move this {resourceType} to?",
|
"projects.move.resource.modal.message": "\"{resourceName}\" is currently in the \"{resourceHomeProjectName}\" project. Which project would you like to move this {resourceTypeLabel} to?",
|
||||||
"projects.move.resource.confirm.modal.title": "Please confirm the following",
|
"projects.move.resource.confirm.modal.title": "Please confirm the following",
|
||||||
"projects.move.resource.confirm.modal.button.confirm": "Confirm move to new project",
|
"projects.move.resource.confirm.modal.button.confirm": "Confirm move to new project",
|
||||||
"projects.move.workflow.confirm.modal.label": "This workflow may stop working if the credentials used with it do not exist in the project its being moved to",
|
"projects.move.workflow.confirm.modal.label": "This workflow may stop working if the credentials used with it do not exist in the project its being moved to",
|
||||||
"projects.move.credential.confirm.modal.label": "Any workflows currently using this credential will stop working once this credential has been moved",
|
"projects.move.credential.confirm.modal.label": "Any workflows currently using this credential will stop working once this credential has been moved",
|
||||||
"projects.move.resource.confirm.modal.label": "Any individual sharing currently associated with this {resourceType} will be removed. (currently shared with {numberOfUsers})",
|
"projects.move.resource.confirm.modal.label": "Any individual sharing currently associated with this {resourceTypeLabel} will be removed. (currently shared with {numberOfUsers})",
|
||||||
"projects.move.resource.confirm.modal.numberOfUsers": "{numberOfUsers} user | {numberOfUsers} users",
|
"projects.move.resource.confirm.modal.numberOfUsers": "{numberOfUsers} user | {numberOfUsers} users",
|
||||||
"projects.move.resource.error.title": "Error moving {resourceName} {resourceType}",
|
"projects.move.resource.error.title": "Error moving {resourceName} {resourceTypeLabel}",
|
||||||
|
"projects.move.resource.success.title": "Successfully moved {resourceTypeLabel}",
|
||||||
|
"projects.move.resource.success.message": "{resourceName} {resourceTypeLabel} was moved to {targetProjectName} {link}",
|
||||||
|
"projects.move.resource.success.link": "View {targetProjectName}",
|
||||||
|
"projects.badge.tooltip.sharedOwned": "This {resourceTypeLabel} is owned by you and shared with one or more other users",
|
||||||
|
"projects.badge.tooltip.sharedPersonal": "This {resourceTypeLabel} is owned by {name} and shared with one or more other users",
|
||||||
|
"projects.badge.tooltip.personal": "This {resourceTypeLabel} is owned by {name}",
|
||||||
|
"projects.badge.tooltip.team": "This {resourceTypeLabel} is owned by the {name} project. All users in this project have access to this.",
|
||||||
"mfa.setup.invalidAuthenticatorCode": "{code} is not a valid number",
|
"mfa.setup.invalidAuthenticatorCode": "{code} is not a valid number",
|
||||||
"mfa.setup.invalidCode": "Two-factor code failed. Please try again.",
|
"mfa.setup.invalidCode": "Two-factor code failed. Please try again.",
|
||||||
"mfa.code.modal.title": "Two-factor authentication",
|
"mfa.code.modal.title": "Two-factor authentication",
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { VIEWS } from '@/constants';
|
||||||
const MainSidebar = async () => await import('@/components/MainSidebar.vue');
|
const MainSidebar = async () => await import('@/components/MainSidebar.vue');
|
||||||
const WorkflowsView = async () => await import('@/views/WorkflowsView.vue');
|
const WorkflowsView = async () => await import('@/views/WorkflowsView.vue');
|
||||||
const CredentialsView = async () => await import('@/views/CredentialsView.vue');
|
const CredentialsView = async () => await import('@/views/CredentialsView.vue');
|
||||||
const ProjectSettings = async () => await import('@/components/Projects/ProjectSettings.vue');
|
const ProjectSettings = async () => await import('@/views/ProjectSettings.vue');
|
||||||
|
|
||||||
const commonChildRoutes: RouteRecordRaw[] = [
|
const commonChildRoutes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
|
|
|
@ -26,3 +26,8 @@ export const splitName = (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const enum ResourceType {
|
||||||
|
Credential = 'credential',
|
||||||
|
Workflow = 'workflow',
|
||||||
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
:filters="filters"
|
:filters="filters"
|
||||||
:additional-filters-handler="onFilter"
|
:additional-filters-handler="onFilter"
|
||||||
:type-props="{ itemSize: 77 }"
|
:type-props="{ itemSize: 77 }"
|
||||||
|
:loading="loading"
|
||||||
@click:add="addCredential"
|
@click:add="addCredential"
|
||||||
@update:filters="filters = $event"
|
@update:filters="filters = $event"
|
||||||
>
|
>
|
||||||
|
@ -79,8 +80,6 @@ import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
||||||
import useEnvironmentsStore from '@/stores/environments.ee.store';
|
import useEnvironmentsStore from '@/stores/environments.ee.store';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
|
||||||
type IResourcesListLayoutInstance = InstanceType<typeof ResourcesListLayout>;
|
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'CredentialsView',
|
name: 'CredentialsView',
|
||||||
components: {
|
components: {
|
||||||
|
@ -96,6 +95,7 @@ export default defineComponent({
|
||||||
type: '',
|
type: '',
|
||||||
},
|
},
|
||||||
sourceControlStoreUnsubscribe: () => {},
|
sourceControlStoreUnsubscribe: () => {},
|
||||||
|
loading: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
@ -133,9 +133,6 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
'filters.type'() {
|
|
||||||
this.sendFiltersTelemetry('type');
|
|
||||||
},
|
|
||||||
'$route.params.projectId'() {
|
'$route.params.projectId'() {
|
||||||
void this.initialize();
|
void this.initialize();
|
||||||
},
|
},
|
||||||
|
@ -161,6 +158,7 @@ export default defineComponent({
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
async initialize() {
|
async initialize() {
|
||||||
|
this.loading = true;
|
||||||
const isVarsEnabled = useSettingsStore().isEnterpriseFeatureEnabled(
|
const isVarsEnabled = useSettingsStore().isEnterpriseFeatureEnabled(
|
||||||
EnterpriseEditionFeature.Variables,
|
EnterpriseEditionFeature.Variables,
|
||||||
);
|
);
|
||||||
|
@ -176,6 +174,7 @@ export default defineComponent({
|
||||||
];
|
];
|
||||||
|
|
||||||
await Promise.all(loadPromises);
|
await Promise.all(loadPromises);
|
||||||
|
this.loading = false;
|
||||||
},
|
},
|
||||||
onFilter(
|
onFilter(
|
||||||
resource: ICredentialsResponse,
|
resource: ICredentialsResponse,
|
||||||
|
@ -199,9 +198,6 @@ export default defineComponent({
|
||||||
|
|
||||||
return matches;
|
return matches;
|
||||||
},
|
},
|
||||||
sendFiltersTelemetry(source: string) {
|
|
||||||
(this.$refs.layout as IResourcesListLayoutInstance).sendFiltersTelemetry(source);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event';
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
import { getDropdownItems } from '@/__tests__/utils';
|
import { getDropdownItems } from '@/__tests__/utils';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import ProjectSettings from '@/components/Projects/ProjectSettings.vue';
|
import ProjectSettings from '@/views/ProjectSettings.vue';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import { VIEWS } from '@/constants';
|
import { VIEWS } from '@/constants';
|
||||||
import { useUsersStore } from '@/stores/users.store';
|
import { useUsersStore } from '@/stores/users.store';
|
|
@ -1,7 +1,8 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, ref, watch, onBeforeMount, nextTick } from 'vue';
|
import { computed, ref, watch, onBeforeMount, onMounted, nextTick } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { deepCopy } from 'n8n-workflow';
|
import { deepCopy } from 'n8n-workflow';
|
||||||
|
import { N8nFormInput } from 'n8n-design-system';
|
||||||
import { useUsersStore } from '@/stores/users.store';
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
import type { IUser } from '@/Interface';
|
import type { IUser } from '@/Interface';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
@ -36,6 +37,7 @@ const dialogVisible = ref(false);
|
||||||
const upgradeDialogVisible = ref(false);
|
const upgradeDialogVisible = ref(false);
|
||||||
|
|
||||||
const isDirty = ref(false);
|
const isDirty = ref(false);
|
||||||
|
const isValid = ref(false);
|
||||||
const formData = ref<Pick<Project, 'name' | 'relations'>>({
|
const formData = ref<Pick<Project, 'name' | 'relations'>>({
|
||||||
name: '',
|
name: '',
|
||||||
relations: [],
|
relations: [],
|
||||||
|
@ -44,7 +46,7 @@ const projectRoleTranslations = ref<{ [key: string]: string }>({
|
||||||
'project:editor': locale.baseText('projects.settings.role.editor'),
|
'project:editor': locale.baseText('projects.settings.role.editor'),
|
||||||
'project:admin': locale.baseText('projects.settings.role.admin'),
|
'project:admin': locale.baseText('projects.settings.role.admin'),
|
||||||
});
|
});
|
||||||
const nameInput = ref<HTMLInputElement | null>(null);
|
const nameInput = ref<InstanceType<typeof N8nFormInput> | null>(null);
|
||||||
|
|
||||||
const usersList = computed(() =>
|
const usersList = computed(() =>
|
||||||
usersStore.allUsers.filter((user: IUser) => {
|
usersStore.allUsers.filter((user: IUser) => {
|
||||||
|
@ -221,12 +223,9 @@ const onConfirmDelete = async (transferId?: string) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectProjectNameIfMatchesDefault = () => {
|
const selectProjectNameIfMatchesDefault = () => {
|
||||||
if (
|
if (formData.value.name === locale.baseText('projects.settings.newProjectName')) {
|
||||||
nameInput.value &&
|
nameInput.value?.inputRef?.focus();
|
||||||
formData.value.name === locale.baseText('projects.settings.newProjectName')
|
nameInput.value?.inputRef?.select();
|
||||||
) {
|
|
||||||
nameInput.value.focus();
|
|
||||||
nameInput.value.select();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -246,6 +245,10 @@ watch(
|
||||||
onBeforeMount(async () => {
|
onBeforeMount(async () => {
|
||||||
await usersStore.fetchUsers();
|
await usersStore.fetchUsers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
selectProjectNameIfMatchesDefault();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -256,14 +259,17 @@ onBeforeMount(async () => {
|
||||||
<form @submit.prevent="onSubmit">
|
<form @submit.prevent="onSubmit">
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<label for="projectName">{{ locale.baseText('projects.settings.name') }}</label>
|
<label for="projectName">{{ locale.baseText('projects.settings.name') }}</label>
|
||||||
<N8nInput
|
<N8nFormInput
|
||||||
id="projectName"
|
id="projectName"
|
||||||
ref="nameInput"
|
ref="nameInput"
|
||||||
v-model="formData.name"
|
v-model="formData.name"
|
||||||
|
label=""
|
||||||
type="text"
|
type="text"
|
||||||
name="name"
|
name="name"
|
||||||
|
required
|
||||||
data-test-id="project-settings-name-input"
|
data-test-id="project-settings-name-input"
|
||||||
@input="onNameInput"
|
@input="onNameInput"
|
||||||
|
@validate="isValid = $event"
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
|
@ -343,7 +349,7 @@ onBeforeMount(async () => {
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<N8nButton
|
<N8nButton
|
||||||
:disabled="!isDirty"
|
:disabled="!isDirty || !isValid"
|
||||||
type="primary"
|
type="primary"
|
||||||
data-test-id="project-settings-save-button"
|
data-test-id="project-settings-save-button"
|
||||||
>{{ locale.baseText('projects.settings.button.save') }}</N8nButton
|
>{{ locale.baseText('projects.settings.button.save') }}</N8nButton
|
|
@ -109,7 +109,7 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (forceRedirectedHere) {
|
if (forceRedirectedHere) {
|
||||||
await this.$router.push({ name: VIEWS.NEW_WORKFLOW });
|
await this.$router.push({ name: VIEWS.HOMEPAGE });
|
||||||
} else {
|
} else {
|
||||||
await this.$router.push({ name: VIEWS.USERS_SETTINGS });
|
await this.$router.push({ name: VIEWS.USERS_SETTINGS });
|
||||||
}
|
}
|
||||||
|
|
|
@ -130,7 +130,7 @@ async function onSubmit(values: { [key: string]: string | boolean }) {
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
await router.push({ name: VIEWS.NEW_WORKFLOW });
|
await router.push({ name: VIEWS.HOMEPAGE });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.showError(error, i18n.baseText('auth.signup.setupYourAccountError'));
|
toast.showError(error, i18n.baseText('auth.signup.setupYourAccountError'));
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,12 +38,6 @@ describe('VariablesView', () => {
|
||||||
server.shutdown();
|
server.shutdown();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render loading state', () => {
|
|
||||||
const wrapper = renderComponent({ pinia });
|
|
||||||
|
|
||||||
expect(wrapper.container.querySelectorAll('.n8n-loading')).toHaveLength(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('should render empty state', () => {
|
describe('should render empty state', () => {
|
||||||
it('when feature is disabled and logged in user is not owner', async () => {
|
it('when feature is disabled and logged in user is not owner', async () => {
|
||||||
settingsStore.settings.enterprise[EnterpriseEditionFeature.Variables] = false;
|
settingsStore.settings.enterprise[EnterpriseEditionFeature.Variables] = false;
|
|
@ -38,7 +38,7 @@ const TEMPORARY_VARIABLE_UID_BASE = '@tmpvar';
|
||||||
|
|
||||||
const allVariables = ref<EnvironmentVariable[]>([]);
|
const allVariables = ref<EnvironmentVariable[]>([]);
|
||||||
const editMode = ref<Record<string, boolean>>({});
|
const editMode = ref<Record<string, boolean>>({});
|
||||||
|
const loading = ref(false);
|
||||||
const permissions = getVariablesPermissions(usersStore.currentUser);
|
const permissions = getVariablesPermissions(usersStore.currentUser);
|
||||||
|
|
||||||
const isFeatureEnabled = computed(() =>
|
const isFeatureEnabled = computed(() =>
|
||||||
|
@ -132,9 +132,11 @@ const environmentVariableToResource = (data: EnvironmentVariable): IResource =>
|
||||||
|
|
||||||
async function initialize() {
|
async function initialize() {
|
||||||
if (!isFeatureEnabled.value) return;
|
if (!isFeatureEnabled.value) return;
|
||||||
|
loading.value = true;
|
||||||
await environmentsStore.fetchAllVariables();
|
await environmentsStore.fetchAllVariables();
|
||||||
|
|
||||||
allVariables.value = [...environmentsStore.variables];
|
allVariables.value = [...environmentsStore.variables];
|
||||||
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addTemporaryVariable() {
|
function addTemporaryVariable() {
|
||||||
|
@ -260,6 +262,7 @@ onBeforeUnmount(() => {
|
||||||
:show-filters-dropdown="false"
|
:show-filters-dropdown="false"
|
||||||
type="datatable"
|
type="datatable"
|
||||||
:type-props="{ columns: datatableColumns }"
|
:type-props="{ columns: datatableColumns }"
|
||||||
|
:loading="loading"
|
||||||
@sort="resetNewVariablesList"
|
@sort="resetNewVariablesList"
|
||||||
@click:add="addTemporaryVariable"
|
@click:add="addTemporaryVariable"
|
||||||
>
|
>
|
||||||
|
|
|
@ -73,15 +73,13 @@ describe('WorkflowsView', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should filter workflows by tags', async () => {
|
it('should filter workflows by tags', async () => {
|
||||||
const { container, getByTestId, getAllByTestId, queryByTestId } = renderComponent({
|
const { getByTestId, getAllByTestId, queryByTestId } = renderComponent({
|
||||||
pinia,
|
pinia,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(container.querySelectorAll('.n8n-loading')).toHaveLength(3);
|
|
||||||
expect(queryByTestId('resources-list')).not.toBeInTheDocument();
|
expect(queryByTestId('resources-list')).not.toBeInTheDocument();
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(container.querySelectorAll('.n8n-loading')).toHaveLength(0);
|
|
||||||
// There are 5 workflows defined in server fixtures
|
// There are 5 workflows defined in server fixtures
|
||||||
expect(getAllByTestId('resources-list-item')).toHaveLength(5);
|
expect(getAllByTestId('resources-list-item')).toHaveLength(5);
|
||||||
});
|
});
|
||||||
|
|
|
@ -9,11 +9,12 @@
|
||||||
:shareable="isShareable"
|
:shareable="isShareable"
|
||||||
:initialize="initialize"
|
:initialize="initialize"
|
||||||
:disabled="readOnlyEnv"
|
:disabled="readOnlyEnv"
|
||||||
|
:loading="loading"
|
||||||
@click:add="addWorkflow"
|
@click:add="addWorkflow"
|
||||||
@update:filters="onFiltersUpdated"
|
@update:filters="onFiltersUpdated"
|
||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<ProjectTabs v-if="showProjectTabs" />
|
<ProjectTabs />
|
||||||
</template>
|
</template>
|
||||||
<template #add-button="{ disabled }">
|
<template #add-button="{ disabled }">
|
||||||
<n8n-tooltip :disabled="!readOnlyEnv">
|
<n8n-tooltip :disabled="!readOnlyEnv">
|
||||||
|
@ -162,8 +163,6 @@ import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
||||||
import { useTemplatesStore } from '@/stores/templates.store';
|
import { useTemplatesStore } from '@/stores/templates.store';
|
||||||
|
|
||||||
type IResourcesListLayoutInstance = InstanceType<typeof ResourcesListLayout>;
|
|
||||||
|
|
||||||
interface Filters {
|
interface Filters {
|
||||||
search: string;
|
search: string;
|
||||||
homeProject: string;
|
homeProject: string;
|
||||||
|
@ -194,6 +193,7 @@ const WorkflowsView = defineComponent({
|
||||||
tags: [],
|
tags: [],
|
||||||
} as Filters,
|
} as Filters,
|
||||||
sourceControlStoreUnsubscribe: () => {},
|
sourceControlStoreUnsubscribe: () => {},
|
||||||
|
loading: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
@ -255,13 +255,6 @@ const WorkflowsView = defineComponent({
|
||||||
}
|
}
|
||||||
return ['Sales', 'sales-and-marketing'].includes(this.userRole);
|
return ['Sales', 'sales-and-marketing'].includes(this.userRole);
|
||||||
},
|
},
|
||||||
showProjectTabs() {
|
|
||||||
return (
|
|
||||||
!!this.$route.params.projectId ||
|
|
||||||
!!this.allWorkflows.length ||
|
|
||||||
this.projectsStore.myProjects.length > 1
|
|
||||||
);
|
|
||||||
},
|
|
||||||
addWorkflowButtonText() {
|
addWorkflowButtonText() {
|
||||||
return this.projectsStore.currentProject
|
return this.projectsStore.currentProject
|
||||||
? this.$locale.baseText('workflows.project.add')
|
? this.$locale.baseText('workflows.project.add')
|
||||||
|
@ -275,9 +268,6 @@ const WorkflowsView = defineComponent({
|
||||||
this.saveFiltersOnQueryString();
|
this.saveFiltersOnQueryString();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'filters.tags'() {
|
|
||||||
this.sendFiltersTelemetry('tags');
|
|
||||||
},
|
|
||||||
'$route.params.projectId'() {
|
'$route.params.projectId'() {
|
||||||
void this.initialize();
|
void this.initialize();
|
||||||
},
|
},
|
||||||
|
@ -323,11 +313,13 @@ const WorkflowsView = defineComponent({
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
async initialize() {
|
async initialize() {
|
||||||
|
this.loading = true;
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.usersStore.fetchUsers(),
|
this.usersStore.fetchUsers(),
|
||||||
this.workflowsStore.fetchAllWorkflows(this.$route?.params?.projectId as string | undefined),
|
this.workflowsStore.fetchAllWorkflows(this.$route?.params?.projectId as string | undefined),
|
||||||
this.workflowsStore.fetchActiveWorkflows(),
|
this.workflowsStore.fetchActiveWorkflows(),
|
||||||
]);
|
]);
|
||||||
|
this.loading = false;
|
||||||
},
|
},
|
||||||
onClickTag(tagId: string) {
|
onClickTag(tagId: string) {
|
||||||
if (!this.filters.tags.includes(tagId)) {
|
if (!this.filters.tags.includes(tagId)) {
|
||||||
|
@ -357,9 +349,6 @@ const WorkflowsView = defineComponent({
|
||||||
|
|
||||||
return matches;
|
return matches;
|
||||||
},
|
},
|
||||||
sendFiltersTelemetry(source: string) {
|
|
||||||
(this.$refs.layout as IResourcesListLayoutInstance).sendFiltersTelemetry(source);
|
|
||||||
},
|
|
||||||
saveFiltersOnQueryString() {
|
saveFiltersOnQueryString() {
|
||||||
const query: { [key: string]: string } = {};
|
const query: { [key: string]: string } = {};
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue