mirror of
https://github.com/n8n-io/n8n.git
synced 2024-09-19 22:37:31 -07: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().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 getResourceMoveConfirmModal = () =>
|
||||
cy.getByTestId('project-move-resource-confirm-modal');
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import Avatar from 'vue-boring-avatars';
|
||||
import { getInitials } from '../../utils/labelUtil';
|
||||
|
||||
interface AvatarProps {
|
||||
firstName: string;
|
||||
|
@ -37,11 +38,7 @@ const props = withDefaults(defineProps<AvatarProps>(), {
|
|||
],
|
||||
});
|
||||
|
||||
const initials = computed(
|
||||
() =>
|
||||
(props.firstName ? props.firstName.charAt(0) : '') +
|
||||
(props.lastName ? props.lastName.charAt(0) : ''),
|
||||
);
|
||||
const initials = computed(() => getInitials(`${props.firstName} ${props.lastName}`));
|
||||
|
||||
const getColors = (colors: string[]): string[] => {
|
||||
const style = getComputedStyle(document.body);
|
||||
|
|
|
@ -97,7 +97,7 @@ import N8nIcon from '../N8nIcon';
|
|||
import ConditionalRouterLink from '../ConditionalRouterLink';
|
||||
import type { IMenuItem } from '../../types';
|
||||
import { doesMenuItemMatchCurrentRoute } from './routerUtil';
|
||||
import { getInitials } from './labelUtil';
|
||||
import { getInitials } from '../../utils/labelUtil';
|
||||
|
||||
interface MenuItemProps {
|
||||
item: IMenuItem;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { getInitials } from '../labelUtil';
|
||||
import { getInitials } from './labelUtil';
|
||||
|
||||
describe('labelUtil.getInitials', () => {
|
||||
it.each([
|
|
@ -13,6 +13,7 @@ import type { ProjectSharingData } from '@/types/projects.types';
|
|||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { ResourceType } from '@/utils/projects.utils';
|
||||
|
||||
const CREDENTIAL_LIST_ITEM_ACTIONS = {
|
||||
OPEN: 'open',
|
||||
|
@ -45,6 +46,7 @@ const uiStore = useUIStore();
|
|||
const credentialsStore = useCredentialsStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
|
||||
const resourceTypeLabel = computed(() => locale.baseText('generic.credential').toLowerCase());
|
||||
const credentialType = computed(() => credentialsStore.getCredentialTypeByName(props.data.type));
|
||||
const credentialPermissions = computed(() => getCredentialPermissions(props.data));
|
||||
const actions = computed(() => {
|
||||
|
@ -76,7 +78,7 @@ const formattedCreatedAtDate = computed(() => {
|
|||
|
||||
return dateformat(
|
||||
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,
|
||||
data: {
|
||||
resource: props.data,
|
||||
resourceType: locale.baseText('generic.credential').toLocaleLowerCase(),
|
||||
resourceType: ResourceType.Credential,
|
||||
resourceTypeLabel: resourceTypeLabel.value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -150,7 +153,12 @@ function moveResource() {
|
|||
</div>
|
||||
<template #append>
|
||||
<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
|
||||
data-test-id="credential-card-actions"
|
||||
:actions="actions"
|
||||
|
|
|
@ -674,10 +674,10 @@ export default defineComponent({
|
|||
methods: {
|
||||
closeDialog() {
|
||||
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
|
||||
if (this.$route.name !== VIEWS.NEW_WORKFLOW) {
|
||||
void this.$router.replace({ name: VIEWS.NEW_WORKFLOW });
|
||||
if (this.$route.name !== VIEWS.HOMEPAGE) {
|
||||
void this.$router.replace({ name: VIEWS.HOMEPAGE });
|
||||
}
|
||||
},
|
||||
async loadDomainBlocklist() {
|
||||
|
|
|
@ -23,6 +23,7 @@ describe('ProjectCardBadge', () => {
|
|||
id: '1',
|
||||
},
|
||||
},
|
||||
resourceType: 'workflow',
|
||||
personalProject: {
|
||||
id: '1',
|
||||
},
|
||||
|
@ -49,6 +50,7 @@ describe('ProjectCardBadge', () => {
|
|||
name,
|
||||
},
|
||||
},
|
||||
resourceType: 'workflow',
|
||||
personalProject: {
|
||||
id: '2',
|
||||
},
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import type { ResourceType } from '@/utils/projects.utils';
|
||||
import { splitName } from '@/utils/projects.utils';
|
||||
import type { ICredentialsResponse, IWorkflowDb } from '@/Interface';
|
||||
import type { Project } from '@/types/projects.types';
|
||||
|
@ -8,45 +9,111 @@ import { ProjectTypes } from '@/types/projects.types';
|
|||
|
||||
type Props = {
|
||||
resource: IWorkflowDb | ICredentialsResponse;
|
||||
resourceType: ResourceType;
|
||||
resourceTypeLabel: string;
|
||||
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 locale = useI18n();
|
||||
const i18n = useI18n();
|
||||
|
||||
const badgeText = computed(() => {
|
||||
const projectState = computed(() => {
|
||||
if (
|
||||
(props.resource.homeProject &&
|
||||
props.personalProject &&
|
||||
props.resource.homeProject.id === props.personalProject.id) ||
|
||||
!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 {
|
||||
const { firstName, lastName, email } = splitName(props.resource.homeProject?.name ?? '');
|
||||
return !firstName ? email : `${firstName}${lastName ? ' ' + lastName : ''}`;
|
||||
return (!firstName ? email : `${firstName}${lastName ? ' ' + lastName : ''}`) ?? '';
|
||||
}
|
||||
});
|
||||
|
||||
const badgeIcon = computed(() => {
|
||||
if (
|
||||
props.resource.sharedWithProjects?.length &&
|
||||
props.resource.homeProject?.type !== ProjectTypes.Team
|
||||
) {
|
||||
return 'user-friends';
|
||||
} else if (props.resource.homeProject?.type === ProjectTypes.Team) {
|
||||
return 'archive';
|
||||
} else {
|
||||
return '';
|
||||
switch (projectState.value) {
|
||||
case ProjectState.SharedPersonal:
|
||||
case ProjectState.SharedOwned:
|
||||
return 'user-friends';
|
||||
case ProjectState.Team:
|
||||
return 'archive';
|
||||
default:
|
||||
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>
|
||||
<template>
|
||||
<n8n-badge v-if="badgeText" class="mr-xs" theme="tertiary" bold data-test-id="card-badge">
|
||||
{{ badgeText }}
|
||||
<n8n-icon v-if="badgeIcon" :icon="badgeIcon" size="small" class="ml-5xs" />
|
||||
</n8n-badge>
|
||||
<N8nTooltip :disabled="!badgeTooltip" placement="top">
|
||||
<N8nBadge v-if="badgeText" class="mr-xs" theme="tertiary" bold data-test-id="card-badge">
|
||||
{{ badgeText }}
|
||||
<N8nIcon v-if="badgeIcon" :icon="badgeIcon" size="small" class="ml-5xs" />
|
||||
</N8nBadge>
|
||||
<template #content>
|
||||
{{ badgeTooltip }}
|
||||
</template>
|
||||
</N8nTooltip>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module></style>
|
||||
|
|
|
@ -49,6 +49,7 @@ describe('ProjectMoveResourceConfirmModal', () => {
|
|||
id: '1',
|
||||
},
|
||||
projectId: '1',
|
||||
projectName: 'My Project',
|
||||
},
|
||||
};
|
||||
const { getByRole, getAllByRole } = renderComponent({ props });
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, h } from 'vue';
|
||||
import type { ICredentialsResponse, IWorkflowDb } from '@/Interface';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
|
@ -8,13 +8,18 @@ import Modal from '@/components/Modal.vue';
|
|||
import { N8nCheckbox, N8nText } from 'n8n-design-system';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
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<{
|
||||
modalName: string;
|
||||
data: {
|
||||
resource: IWorkflowDb | ICredentialsResponse;
|
||||
resourceType: 'workflow' | 'credential';
|
||||
resourceType: ResourceType;
|
||||
resourceTypeLabel: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
};
|
||||
}>();
|
||||
|
||||
|
@ -28,7 +33,7 @@ const checks = ref([false, false]);
|
|||
const allChecked = computed(() => checks.value.every(Boolean));
|
||||
|
||||
const moveResourceLabel = computed(() =>
|
||||
props.data.resourceType === 'workflow'
|
||||
props.data.resourceType === ResourceType.Workflow
|
||||
? i18n.baseText('projects.move.workflow.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,
|
||||
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) {
|
||||
toast.showError(
|
||||
error.message,
|
||||
i18n.baseText('projects.move.resource.error.title', {
|
||||
interpolate: {
|
||||
resourceType: props.data.resourceType,
|
||||
resourceTypeLabel: props.data.resourceTypeLabel,
|
||||
resourceName: props.data.resource.name,
|
||||
},
|
||||
}),
|
||||
|
@ -74,7 +97,7 @@ const confirm = async () => {
|
|||
<N8nCheckbox v-model="checks[1]">
|
||||
<N8nText>
|
||||
<i18n-t keypath="projects.move.resource.confirm.modal.label">
|
||||
<template #resourceType>{{ props.data.resourceType }}</template>
|
||||
<template #resourceTypeLabel>{{ props.data.resourceTypeLabel }}</template>
|
||||
<template #numberOfUsers>{{
|
||||
i18n.baseText('projects.move.resource.confirm.modal.numberOfUsers', {
|
||||
interpolate: {
|
||||
|
|
|
@ -34,6 +34,7 @@ describe('ProjectMoveResourceModal', () => {
|
|||
id: '1',
|
||||
},
|
||||
projectId: '1',
|
||||
projectName: 'My Project',
|
||||
},
|
||||
};
|
||||
renderComponent({ props });
|
||||
|
|
|
@ -6,6 +6,7 @@ import { useUIStore } from '@/stores/ui.store';
|
|||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import Modal from '@/components/Modal.vue';
|
||||
import { PROJECT_MOVE_RESOURCE_CONFIRM_MODAL } from '@/constants';
|
||||
import type { ResourceType } from '@/utils/projects.utils';
|
||||
import { splitName } from '@/utils/projects.utils';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
|
||||
|
@ -13,7 +14,8 @@ const props = defineProps<{
|
|||
modalName: string;
|
||||
data: {
|
||||
resource: IWorkflowDb | ICredentialsResponse;
|
||||
resourceType: 'workflow' | 'credential';
|
||||
resourceType: ResourceType;
|
||||
resourceTypeLabel: string;
|
||||
};
|
||||
}>();
|
||||
|
||||
|
@ -46,7 +48,9 @@ const next = () => {
|
|||
data: {
|
||||
resource: props.data.resource,
|
||||
resourceType: props.data.resourceType,
|
||||
resourceTypeLabel: props.data.resourceTypeLabel,
|
||||
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">
|
||||
{{
|
||||
i18n.baseText('projects.move.resource.modal.title', {
|
||||
interpolate: { resourceType: props.data.resourceType },
|
||||
interpolate: { resourceTypeLabel: props.data.resourceTypeLabel },
|
||||
})
|
||||
}}
|
||||
</N8nHeading>
|
||||
|
@ -74,7 +78,7 @@ onMounted(() => {
|
|||
><strong>{{ props.data.resource.name }}</strong></template
|
||||
>
|
||||
<template #resourceHomeProjectName>{{ processedName }}</template>
|
||||
<template #resourceType>{{ props.data.resourceType }}</template>
|
||||
<template #resourceTypeLabel>{{ props.data.resourceTypeLabel }}</template>
|
||||
</i18n-t>
|
||||
</N8nText>
|
||||
</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
|
||||
v-for="project in displayProjects"
|
||||
:key="project.id"
|
||||
:class="{
|
||||
[$style.collapsed]: props.collapsed,
|
||||
}"
|
||||
:item="getProjectMenuItem(project)"
|
||||
:compact="props.collapsed"
|
||||
:handle-select="projectClicked"
|
||||
|
@ -194,6 +197,10 @@ onMounted(async () => {
|
|||
color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.collapsed {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -24,6 +24,7 @@ import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue';
|
|||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { ResourceType } from '@/utils/projects.utils';
|
||||
|
||||
const WORKFLOW_LIST_ITEM_ACTIONS = {
|
||||
OPEN: 'open',
|
||||
|
@ -72,6 +73,7 @@ const usersStore = useUsersStore();
|
|||
const workflowsStore = useWorkflowsStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
|
||||
const resourceTypeLabel = computed(() => locale.baseText('generic.workflow').toLowerCase());
|
||||
const currentUser = computed(() => usersStore.currentUser ?? ({} as IUser));
|
||||
const workflowPermissions = computed(() => getWorkflowPermissions(props.data));
|
||||
const actions = computed(() => {
|
||||
|
@ -222,7 +224,8 @@ function moveResource() {
|
|||
name: PROJECT_MOVE_RESOURCE_MODAL,
|
||||
data: {
|
||||
resource: props.data,
|
||||
resourceType: locale.baseText('generic.workflow').toLocaleLowerCase(),
|
||||
resourceType: ResourceType.Workflow,
|
||||
resourceTypeLabel: resourceTypeLabel.value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -261,7 +264,12 @@ function moveResource() {
|
|||
</div>
|
||||
<template #append>
|
||||
<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
|
||||
class="mr-s"
|
||||
:workflow-active="data.active"
|
||||
|
|
|
@ -48,7 +48,7 @@ const renderComponent = createComponentRenderer(PersonalizationModal, {
|
|||
global: {
|
||||
mocks: {
|
||||
$route: {
|
||||
name: VIEWS.NEW_WORKFLOW,
|
||||
name: VIEWS.HOMEPAGE,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -29,7 +29,6 @@
|
|||
/>
|
||||
<ProjectSharing
|
||||
v-model="selectedProject"
|
||||
class="pt-2xs"
|
||||
:projects="projectsStore.projects"
|
||||
:placeholder="$locale.baseText('forms.resourceFiltersDropdown.owner.placeholder')"
|
||||
:empty-options-text="$locale.baseText('projects.sharing.noMatchingProjects')"
|
||||
|
@ -150,7 +149,6 @@ export default defineComponent({
|
|||
|
||||
.filters-dropdown {
|
||||
width: 280px;
|
||||
padding-bottom: var(--spacing-s);
|
||||
}
|
||||
|
||||
.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>
|
||||
<PageViewLayout>
|
||||
<template #header> <slot name="header" /> </template>
|
||||
<div v-if="loading">
|
||||
<n8n-loading :class="[$style['header-loading'], 'mb-l']" variant="custom" />
|
||||
<n8n-loading :class="[$style['card-loading'], 'mb-2xs']" variant="custom" />
|
||||
<n8n-loading :class="$style['card-loading']" variant="custom" />
|
||||
<div v-if="loading" class="resource-list-loading">
|
||||
<n8n-loading :rows="25" :shrink-last="false" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="resources.length === 0">
|
||||
|
@ -245,6 +243,11 @@ export default defineComponent({
|
|||
itemSize: 80,
|
||||
}),
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
emits: ['update:filters', 'click:add', 'sort'],
|
||||
setup(props, { emit }) {
|
||||
|
@ -254,7 +257,6 @@ export default defineComponent({
|
|||
const usersStore = useUsersStore();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const loading = ref(true);
|
||||
const sortBy = ref(props.sortOptions[0]);
|
||||
const hasFilters = ref(false);
|
||||
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(
|
||||
() => filtersModel.value.search,
|
||||
() => callDebounced(sendFiltersTelemetry, { debounceTime: 1000, trailing: true }, 'search'),
|
||||
|
@ -456,7 +472,6 @@ export default defineComponent({
|
|||
|
||||
onMounted(async () => {
|
||||
await props.initialize();
|
||||
loading.value = false;
|
||||
await nextTick();
|
||||
|
||||
focusSearchInput();
|
||||
|
@ -467,7 +482,6 @@ export default defineComponent({
|
|||
});
|
||||
|
||||
return {
|
||||
loading,
|
||||
i18n,
|
||||
search,
|
||||
usersStore,
|
||||
|
@ -525,15 +539,48 @@ export default defineComponent({
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-loading {
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.card-loading {
|
||||
height: 69px;
|
||||
}
|
||||
|
||||
.datatable {
|
||||
padding-bottom: var(--spacing-s);
|
||||
}
|
||||
</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.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.move.resource.modal.title": "Choose a project 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 {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 {resourceTypeLabel} to?",
|
||||
"projects.move.resource.confirm.modal.title": "Please confirm the following",
|
||||
"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.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.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.invalidCode": "Two-factor code failed. Please try again.",
|
||||
"mfa.code.modal.title": "Two-factor authentication",
|
||||
|
|
|
@ -4,7 +4,7 @@ import { VIEWS } from '@/constants';
|
|||
const MainSidebar = async () => await import('@/components/MainSidebar.vue');
|
||||
const WorkflowsView = async () => await import('@/views/WorkflowsView.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[] = [
|
||||
{
|
||||
|
|
|
@ -26,3 +26,8 @@ export const splitName = (
|
|||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const enum ResourceType {
|
||||
Credential = 'credential',
|
||||
Workflow = 'workflow',
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
:filters="filters"
|
||||
:additional-filters-handler="onFilter"
|
||||
:type-props="{ itemSize: 77 }"
|
||||
:loading="loading"
|
||||
@click:add="addCredential"
|
||||
@update:filters="filters = $event"
|
||||
>
|
||||
|
@ -79,8 +80,6 @@ import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
|||
import useEnvironmentsStore from '@/stores/environments.ee.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
|
||||
type IResourcesListLayoutInstance = InstanceType<typeof ResourcesListLayout>;
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CredentialsView',
|
||||
components: {
|
||||
|
@ -96,6 +95,7 @@ export default defineComponent({
|
|||
type: '',
|
||||
},
|
||||
sourceControlStoreUnsubscribe: () => {},
|
||||
loading: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
@ -133,9 +133,6 @@ export default defineComponent({
|
|||
},
|
||||
},
|
||||
watch: {
|
||||
'filters.type'() {
|
||||
this.sendFiltersTelemetry('type');
|
||||
},
|
||||
'$route.params.projectId'() {
|
||||
void this.initialize();
|
||||
},
|
||||
|
@ -161,6 +158,7 @@ export default defineComponent({
|
|||
});
|
||||
},
|
||||
async initialize() {
|
||||
this.loading = true;
|
||||
const isVarsEnabled = useSettingsStore().isEnterpriseFeatureEnabled(
|
||||
EnterpriseEditionFeature.Variables,
|
||||
);
|
||||
|
@ -176,6 +174,7 @@ export default defineComponent({
|
|||
];
|
||||
|
||||
await Promise.all(loadPromises);
|
||||
this.loading = false;
|
||||
},
|
||||
onFilter(
|
||||
resource: ICredentialsResponse,
|
||||
|
@ -199,9 +198,6 @@ export default defineComponent({
|
|||
|
||||
return matches;
|
||||
},
|
||||
sendFiltersTelemetry(source: string) {
|
||||
(this.$refs.layout as IResourcesListLayoutInstance).sendFiltersTelemetry(source);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event';
|
|||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { getDropdownItems } from '@/__tests__/utils';
|
||||
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 { VIEWS } from '@/constants';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
|
@ -1,7 +1,8 @@
|
|||
<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 { deepCopy } from 'n8n-workflow';
|
||||
import { N8nFormInput } from 'n8n-design-system';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import type { IUser } from '@/Interface';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
@ -36,6 +37,7 @@ const dialogVisible = ref(false);
|
|||
const upgradeDialogVisible = ref(false);
|
||||
|
||||
const isDirty = ref(false);
|
||||
const isValid = ref(false);
|
||||
const formData = ref<Pick<Project, 'name' | 'relations'>>({
|
||||
name: '',
|
||||
relations: [],
|
||||
|
@ -44,7 +46,7 @@ const projectRoleTranslations = ref<{ [key: string]: string }>({
|
|||
'project:editor': locale.baseText('projects.settings.role.editor'),
|
||||
'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(() =>
|
||||
usersStore.allUsers.filter((user: IUser) => {
|
||||
|
@ -221,12 +223,9 @@ const onConfirmDelete = async (transferId?: string) => {
|
|||
};
|
||||
|
||||
const selectProjectNameIfMatchesDefault = () => {
|
||||
if (
|
||||
nameInput.value &&
|
||||
formData.value.name === locale.baseText('projects.settings.newProjectName')
|
||||
) {
|
||||
nameInput.value.focus();
|
||||
nameInput.value.select();
|
||||
if (formData.value.name === locale.baseText('projects.settings.newProjectName')) {
|
||||
nameInput.value?.inputRef?.focus();
|
||||
nameInput.value?.inputRef?.select();
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -246,6 +245,10 @@ watch(
|
|||
onBeforeMount(async () => {
|
||||
await usersStore.fetchUsers();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
selectProjectNameIfMatchesDefault();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -256,14 +259,17 @@ onBeforeMount(async () => {
|
|||
<form @submit.prevent="onSubmit">
|
||||
<fieldset>
|
||||
<label for="projectName">{{ locale.baseText('projects.settings.name') }}</label>
|
||||
<N8nInput
|
||||
<N8nFormInput
|
||||
id="projectName"
|
||||
ref="nameInput"
|
||||
v-model="formData.name"
|
||||
label=""
|
||||
type="text"
|
||||
name="name"
|
||||
required
|
||||
data-test-id="project-settings-name-input"
|
||||
@input="onNameInput"
|
||||
@validate="isValid = $event"
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
|
@ -343,7 +349,7 @@ onBeforeMount(async () => {
|
|||
>
|
||||
</div>
|
||||
<N8nButton
|
||||
:disabled="!isDirty"
|
||||
:disabled="!isDirty || !isValid"
|
||||
type="primary"
|
||||
data-test-id="project-settings-save-button"
|
||||
>{{ locale.baseText('projects.settings.button.save') }}</N8nButton
|
|
@ -109,7 +109,7 @@ export default defineComponent({
|
|||
}
|
||||
|
||||
if (forceRedirectedHere) {
|
||||
await this.$router.push({ name: VIEWS.NEW_WORKFLOW });
|
||||
await this.$router.push({ name: VIEWS.HOMEPAGE });
|
||||
} else {
|
||||
await this.$router.push({ name: VIEWS.USERS_SETTINGS });
|
||||
}
|
||||
|
|
|
@ -130,7 +130,7 @@ async function onSubmit(values: { [key: string]: string | boolean }) {
|
|||
} catch {}
|
||||
}
|
||||
|
||||
await router.push({ name: VIEWS.NEW_WORKFLOW });
|
||||
await router.push({ name: VIEWS.HOMEPAGE });
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('auth.signup.setupYourAccountError'));
|
||||
}
|
||||
|
|
|
@ -38,12 +38,6 @@ describe('VariablesView', () => {
|
|||
server.shutdown();
|
||||
});
|
||||
|
||||
it('should render loading state', () => {
|
||||
const wrapper = renderComponent({ pinia });
|
||||
|
||||
expect(wrapper.container.querySelectorAll('.n8n-loading')).toHaveLength(3);
|
||||
});
|
||||
|
||||
describe('should render empty state', () => {
|
||||
it('when feature is disabled and logged in user is not owner', async () => {
|
||||
settingsStore.settings.enterprise[EnterpriseEditionFeature.Variables] = false;
|
|
@ -38,7 +38,7 @@ const TEMPORARY_VARIABLE_UID_BASE = '@tmpvar';
|
|||
|
||||
const allVariables = ref<EnvironmentVariable[]>([]);
|
||||
const editMode = ref<Record<string, boolean>>({});
|
||||
|
||||
const loading = ref(false);
|
||||
const permissions = getVariablesPermissions(usersStore.currentUser);
|
||||
|
||||
const isFeatureEnabled = computed(() =>
|
||||
|
@ -132,9 +132,11 @@ const environmentVariableToResource = (data: EnvironmentVariable): IResource =>
|
|||
|
||||
async function initialize() {
|
||||
if (!isFeatureEnabled.value) return;
|
||||
loading.value = true;
|
||||
await environmentsStore.fetchAllVariables();
|
||||
|
||||
allVariables.value = [...environmentsStore.variables];
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
function addTemporaryVariable() {
|
||||
|
@ -260,6 +262,7 @@ onBeforeUnmount(() => {
|
|||
:show-filters-dropdown="false"
|
||||
type="datatable"
|
||||
:type-props="{ columns: datatableColumns }"
|
||||
:loading="loading"
|
||||
@sort="resetNewVariablesList"
|
||||
@click:add="addTemporaryVariable"
|
||||
>
|
||||
|
|
|
@ -73,15 +73,13 @@ describe('WorkflowsView', () => {
|
|||
});
|
||||
|
||||
it('should filter workflows by tags', async () => {
|
||||
const { container, getByTestId, getAllByTestId, queryByTestId } = renderComponent({
|
||||
const { getByTestId, getAllByTestId, queryByTestId } = renderComponent({
|
||||
pinia,
|
||||
});
|
||||
|
||||
expect(container.querySelectorAll('.n8n-loading')).toHaveLength(3);
|
||||
expect(queryByTestId('resources-list')).not.toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelectorAll('.n8n-loading')).toHaveLength(0);
|
||||
// There are 5 workflows defined in server fixtures
|
||||
expect(getAllByTestId('resources-list-item')).toHaveLength(5);
|
||||
});
|
||||
|
|
|
@ -9,11 +9,12 @@
|
|||
:shareable="isShareable"
|
||||
:initialize="initialize"
|
||||
:disabled="readOnlyEnv"
|
||||
:loading="loading"
|
||||
@click:add="addWorkflow"
|
||||
@update:filters="onFiltersUpdated"
|
||||
>
|
||||
<template #header>
|
||||
<ProjectTabs v-if="showProjectTabs" />
|
||||
<ProjectTabs />
|
||||
</template>
|
||||
<template #add-button="{ disabled }">
|
||||
<n8n-tooltip :disabled="!readOnlyEnv">
|
||||
|
@ -162,8 +163,6 @@ import { useProjectsStore } from '@/stores/projects.store';
|
|||
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
||||
import { useTemplatesStore } from '@/stores/templates.store';
|
||||
|
||||
type IResourcesListLayoutInstance = InstanceType<typeof ResourcesListLayout>;
|
||||
|
||||
interface Filters {
|
||||
search: string;
|
||||
homeProject: string;
|
||||
|
@ -194,6 +193,7 @@ const WorkflowsView = defineComponent({
|
|||
tags: [],
|
||||
} as Filters,
|
||||
sourceControlStoreUnsubscribe: () => {},
|
||||
loading: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
@ -255,13 +255,6 @@ const WorkflowsView = defineComponent({
|
|||
}
|
||||
return ['Sales', 'sales-and-marketing'].includes(this.userRole);
|
||||
},
|
||||
showProjectTabs() {
|
||||
return (
|
||||
!!this.$route.params.projectId ||
|
||||
!!this.allWorkflows.length ||
|
||||
this.projectsStore.myProjects.length > 1
|
||||
);
|
||||
},
|
||||
addWorkflowButtonText() {
|
||||
return this.projectsStore.currentProject
|
||||
? this.$locale.baseText('workflows.project.add')
|
||||
|
@ -275,9 +268,6 @@ const WorkflowsView = defineComponent({
|
|||
this.saveFiltersOnQueryString();
|
||||
},
|
||||
},
|
||||
'filters.tags'() {
|
||||
this.sendFiltersTelemetry('tags');
|
||||
},
|
||||
'$route.params.projectId'() {
|
||||
void this.initialize();
|
||||
},
|
||||
|
@ -323,11 +313,13 @@ const WorkflowsView = defineComponent({
|
|||
});
|
||||
},
|
||||
async initialize() {
|
||||
this.loading = true;
|
||||
await Promise.all([
|
||||
this.usersStore.fetchUsers(),
|
||||
this.workflowsStore.fetchAllWorkflows(this.$route?.params?.projectId as string | undefined),
|
||||
this.workflowsStore.fetchActiveWorkflows(),
|
||||
]);
|
||||
this.loading = false;
|
||||
},
|
||||
onClickTag(tagId: string) {
|
||||
if (!this.filters.tags.includes(tagId)) {
|
||||
|
@ -357,9 +349,6 @@ const WorkflowsView = defineComponent({
|
|||
|
||||
return matches;
|
||||
},
|
||||
sendFiltersTelemetry(source: string) {
|
||||
(this.$refs.layout as IResourcesListLayoutInstance).sendFiltersTelemetry(source);
|
||||
},
|
||||
saveFiltersOnQueryString() {
|
||||
const query: { [key: string]: string } = {};
|
||||
|
||||
|
|
Loading…
Reference in a new issue