fix(editor): UX Improvements to RBAC feature set (#9683)

This commit is contained in:
Csaba Tuncsik 2024-07-18 14:17:27 +02:00 committed by GitHub
parent 5b440a7679
commit 028a8a2c75
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 337 additions and 112 deletions

View file

@ -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');

View file

@ -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);

View file

@ -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;

View file

@ -1,4 +1,4 @@
import { getInitials } from '../labelUtil'; import { getInitials } from './labelUtil';
describe('labelUtil.getInitials', () => { describe('labelUtil.getInitials', () => {
it.each([ it.each([

View file

@ -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"

View file

@ -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() {

View file

@ -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',
}, },

View file

@ -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>

View file

@ -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 });

View file

@ -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: {

View file

@ -34,6 +34,7 @@ describe('ProjectMoveResourceModal', () => {
id: '1', id: '1',
}, },
projectId: '1', projectId: '1',
projectName: 'My Project',
}, },
}; };
renderComponent({ props }); renderComponent({ props });

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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"

View file

@ -48,7 +48,7 @@ const renderComponent = createComponentRenderer(PersonalizationModal, {
global: { global: {
mocks: { mocks: {
$route: { $route: {
name: VIEWS.NEW_WORKFLOW, name: VIEWS.HOMEPAGE,
}, },
}, },
}, },

View file

@ -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 {

View file

@ -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);
});
});

View file

@ -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>

View file

@ -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",

View file

@ -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[] = [
{ {

View file

@ -26,3 +26,8 @@ export const splitName = (
} }
} }
}; };
export const enum ResourceType {
Credential = 'credential',
Workflow = 'workflow',
}

View file

@ -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>

View file

@ -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';

View file

@ -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

View file

@ -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 });
} }

View file

@ -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'));
} }

View file

@ -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;

View file

@ -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"
> >

View file

@ -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);
}); });

View file

@ -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 } = {};