feat(editor): Add route for create / edit / share credentials (#11134)

Co-authored-by: Csaba Tuncsik <csaba.tuncsik@gmail.com>
This commit is contained in:
Raúl Gómez Morales 2024-10-08 11:56:48 +02:00 committed by GitHub
parent 51606cb279
commit 5697de4429
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 167 additions and 81 deletions

View file

@ -21,6 +21,10 @@ const CREDENTIAL_LIST_ITEM_ACTIONS = {
MOVE: 'move',
};
const emit = defineEmits<{
click: [credentialId: string];
}>();
const props = withDefaults(
defineProps<{
data: ICredentialsResponse;
@ -83,7 +87,7 @@ const formattedCreatedAtDate = computed(() => {
});
function onClick() {
uiStore.openExistingCredential(props.data.id);
emit('click', props.data.id);
}
async function onAction(action: string) {
@ -131,7 +135,7 @@ function moveResource() {
</script>
<template>
<n8n-card :class="$style.cardLink" @click="onClick">
<n8n-card :class="$style.cardLink" @click.stop="onClick">
<template #prepend>
<CredentialIcon :credential-type-name="credentialType?.name ?? ''" />
</template>

View file

@ -18,7 +18,8 @@ const commonChildRoutes: RouteRecordRaw[] = [
},
},
{
path: 'credentials',
path: 'credentials/:credentialId?',
props: true,
components: {
default: CredentialsView,
sidebar: MainSidebar,

View file

@ -1,84 +1,138 @@
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import type { Scope } from '@n8n/permissions';
import { useCredentialsStore } from '@/stores/credentials.store';
import type { ProjectSharingData } from '@/types/projects.types';
import CredentialsView from '@/views/CredentialsView.vue';
import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue';
import { useUIStore } from '@/stores/ui.store';
import { mockedStore } from '@/__tests__/utils';
import { waitFor, within, fireEvent } from '@testing-library/vue';
import { CREDENTIAL_SELECT_MODAL_KEY, STORES } from '@/constants';
import userEvent from '@testing-library/user-event';
import { useProjectsStore } from '@/stores/projects.store';
import type { Project } from '@/types/projects.types';
import { useRouter } from 'vue-router';
vi.mock('@/components/layouts/ResourcesListLayout.vue', async (importOriginal) => {
const original = await importOriginal<typeof ResourcesListLayout>();
vi.mock('vue-router', async () => {
const actual = await vi.importActual('vue-router');
const push = vi.fn();
const replace = vi.fn();
return {
default: {
...original.default,
render: vi.fn(),
setup: vi.fn(),
},
...actual,
// your mocked methods
useRouter: () => ({
push,
replace,
}),
};
});
const initialState = {
[STORES.SETTINGS]: {
settings: { enterprise: { variables: true, projects: { team: { limit: -1 } } } },
},
};
const renderComponent = createComponentRenderer(CredentialsView);
let router: ReturnType<typeof useRouter>;
describe('CredentialsView', () => {
describe('with fake stores', () => {
let credentialsStore: ReturnType<typeof useCredentialsStore>;
beforeEach(() => {
createTestingPinia({ initialState });
router = useRouter();
});
beforeEach(() => {
createTestingPinia();
credentialsStore = useCredentialsStore();
});
afterEach(() => {
vi.clearAllMocks();
});
afterAll(() => {
vi.resetAllMocks();
});
it('should have ResourcesListLayout render with necessary keys from credential object', () => {
const homeProject: ProjectSharingData = {
it('should render credentials', () => {
const credentialsStore = mockedStore(useCredentialsStore);
credentialsStore.allCredentials = [
{
id: '1',
name: 'test',
type: 'personal',
type: 'test',
createdAt: '2021-05-05T00:00:00Z',
updatedAt: '2021-05-05T00:00:00Z',
};
const scopes: Scope[] = ['credential:move', 'credential:delete'];
const sharedWithProjects: ProjectSharingData[] = [
{
id: '2',
name: 'test 2',
type: 'personal',
createdAt: '2021-05-05T00:00:00Z',
updatedAt: '2021-05-05T00:00:00Z',
},
];
vi.spyOn(credentialsStore, 'allCredentials', 'get').mockReturnValue([
},
];
const projectsStore = mockedStore(useProjectsStore);
projectsStore.isProjectHome = false;
const { getByTestId } = renderComponent();
expect(getByTestId('resources-list-item')).toBeVisible();
});
it('should disable cards based on permissions', () => {
const credentialsStore = mockedStore(useCredentialsStore);
credentialsStore.allCredentials = [
{
id: '1',
name: 'test',
type: 'test',
createdAt: '2021-05-05T00:00:00Z',
updatedAt: '2021-05-05T00:00:00Z',
scopes: ['credential:update'],
},
{
id: '2',
name: 'test2',
type: 'test2',
createdAt: '2021-05-05T00:00:00Z',
updatedAt: '2021-05-05T00:00:00Z',
},
];
const projectsStore = mockedStore(useProjectsStore);
projectsStore.isProjectHome = false;
const { getAllByTestId } = renderComponent();
const items = getAllByTestId('resources-list-item');
expect(items.length).toBe(2);
expect(within(items[1]).getByText('Read only')).toBeInTheDocument();
});
describe('create credential', () => {
it('should show modal based on route param', async () => {
const uiStore = mockedStore(useUIStore);
renderComponent({ props: { credentialId: 'create' } });
expect(uiStore.openModal).toHaveBeenCalledWith(CREDENTIAL_SELECT_MODAL_KEY);
});
it('should update credentialId route param to create', async () => {
const projectsStore = mockedStore(useProjectsStore);
projectsStore.isProjectHome = false;
projectsStore.currentProject = { scopes: ['credential:create'] } as Project;
const credentialsStore = mockedStore(useCredentialsStore);
credentialsStore.allCredentials = [
{
id: '1',
name: 'test',
type: 'test',
createdAt: '2021-05-05T00:00:00Z',
updatedAt: '2021-05-05T00:00:00Z',
homeProject,
scopes,
sharedWithProjects,
},
]);
renderComponent();
expect(ResourcesListLayout.setup).toHaveBeenCalledWith(
expect.objectContaining({
resources: [
expect.objectContaining({
type: 'test',
homeProject,
scopes,
sharedWithProjects,
}),
],
}),
null,
];
const { getByTestId } = renderComponent();
await userEvent.click(getByTestId('resources-list-add'));
await waitFor(() =>
expect(router.replace).toHaveBeenCalledWith({ params: { credentialId: 'create' } }),
);
});
});
it('should disable cards based on permissions', () => {
vi.spyOn(credentialsStore, 'allCredentials', 'get').mockReturnValue([
describe('open existing credential', () => {
it('should show modal based on route param', async () => {
const uiStore = mockedStore(useUIStore);
renderComponent({ props: { credentialId: 'credential-id' } });
expect(uiStore.openExistingCredential).toHaveBeenCalledWith('credential-id');
});
it('should update credentialId route param when opened', async () => {
const projectsStore = mockedStore(useProjectsStore);
projectsStore.isProjectHome = false;
projectsStore.currentProject = { scopes: ['credential:read'] } as Project;
const credentialsStore = mockedStore(useCredentialsStore);
credentialsStore.allCredentials = [
{
id: '1',
name: 'test',
@ -87,28 +141,15 @@ describe('CredentialsView', () => {
updatedAt: '2021-05-05T00:00:00Z',
scopes: ['credential:update'],
},
{
id: '2',
name: 'test2',
type: 'test2',
createdAt: '2021-05-05T00:00:00Z',
updatedAt: '2021-05-05T00:00:00Z',
},
]);
];
const { getByTestId } = renderComponent();
renderComponent();
expect(ResourcesListLayout.setup).toHaveBeenCalledWith(
expect.objectContaining({
resources: [
expect.objectContaining({
readOnly: false,
}),
expect.objectContaining({
readOnly: true,
}),
],
}),
null,
/**
* userEvent DOES NOT work here
*/
await fireEvent.click(getByTestId('resources-list-item'));
await waitFor(() =>
expect(router.replace).toHaveBeenCalledWith({ params: { credentialId: '1' } }),
);
});
});

View file

@ -1,13 +1,17 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import type { ICredentialsResponse, ICredentialTypeMap } from '@/Interface';
import type { IResource } from '@/components/layouts/ResourcesListLayout.vue';
import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue';
import CredentialCard from '@/components/CredentialCard.vue';
import type { ICredentialType } from 'n8n-workflow';
import { CREDENTIAL_SELECT_MODAL_KEY, EnterpriseEditionFeature } from '@/constants';
import { useUIStore } from '@/stores/ui.store';
import {
CREDENTIAL_SELECT_MODAL_KEY,
CREDENTIAL_EDIT_MODAL_KEY,
EnterpriseEditionFeature,
} from '@/constants';
import { useUIStore, listenForModalChanges } from '@/stores/ui.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useCredentialsStore } from '@/stores/credentials.store';
import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store';
@ -22,6 +26,10 @@ import { useTelemetry } from '@/composables/useTelemetry';
import { useI18n } from '@/composables/useI18n';
import { N8nButton, N8nInputLabel, N8nSelect, N8nOption } from 'n8n-design-system';
const props = defineProps<{
credentialId?: string;
}>();
const credentialsStore = useCredentialsStore();
const nodeTypesStore = useNodeTypesStore();
const uiStore = useUIStore();
@ -31,6 +39,7 @@ const projectsStore = useProjectsStore();
const documentTitle = useDocumentTitle();
const route = useRoute();
const router = useRouter();
const telemetry = useTelemetry();
const i18n = useI18n();
@ -77,13 +86,43 @@ const projectPermissions = computed(() =>
),
);
const setRouteCredentialId = (credentialId?: string) => {
void router.replace({ params: { credentialId } });
};
const addCredential = () => {
uiStore.openModal(CREDENTIAL_SELECT_MODAL_KEY);
setRouteCredentialId('create');
telemetry.track('User clicked add cred button', {
source: 'Creds list',
});
};
listenForModalChanges({
store: uiStore,
onModalClosed(modalName) {
if ([CREDENTIAL_SELECT_MODAL_KEY, CREDENTIAL_EDIT_MODAL_KEY].includes(modalName as string)) {
void router.replace({ params: { credentialId: '' } });
}
},
});
watch(
() => props.credentialId,
(id) => {
if (!id) return;
if (id === 'create') {
uiStore.openModal(CREDENTIAL_SELECT_MODAL_KEY);
return;
}
uiStore.openExistingCredential(id);
},
{
immediate: true,
},
);
const onFilter = (
resource: ICredentialsResponse,
filtersToApply: { type: string[]; search: string },
@ -172,6 +211,7 @@ onMounted(() => {
class="mb-2xs"
:data="data"
:read-only="data.readOnly"
@click="setRouteCredentialId"
/>
</template>
<template #filters="{ setKeyValue }">