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().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');

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -49,6 +49,7 @@ describe('ProjectMoveResourceConfirmModal', () => {
id: '1',
},
projectId: '1',
projectName: 'My Project',
},
};
const { getByRole, getAllByRole } = renderComponent({ props });

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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