refactor(editor): Move editor-ui and design-system to frontend dir (no-changelog) (#13564)

This commit is contained in:
Alex Grozav
2025-02-28 14:28:30 +02:00
committed by GitHub
parent 684353436d
commit f5743176e5
1635 changed files with 805 additions and 1079 deletions

View File

@@ -0,0 +1,276 @@
import type { ICredentialMap, ICredentialTypeMap } from '@/Interface';
export const TEST_CREDENTIALS: ICredentialMap = {
// OpenAI credential in personal
1: {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
id: '1',
name: 'OpenAi account',
data: 'test123',
type: 'openAiApi',
isManaged: false,
homeProject: {
id: '1',
type: 'personal',
name: 'Kobi Dog <kobi@n8n.io>',
icon: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
sharedWithProjects: [
{
id: '2',
type: 'team',
name: 'Test Project',
icon: { type: 'icon', value: 'exchange-alt' },
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
scopes: [
'credential:create',
'credential:delete',
'credential:list',
'credential:move',
'credential:read',
'credential:share',
'credential:update',
],
},
// Supabase credential in another project
2: {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
id: '2',
name: 'Supabase account',
data: 'test123',
type: 'supabaseApi',
isManaged: false,
homeProject: {
id: '2',
type: 'team',
name: 'Test Project',
icon: { type: 'icon', value: 'exchange-alt' },
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
sharedWithProjects: [],
scopes: [
'credential:create',
'credential:delete',
'credential:list',
'credential:move',
'credential:read',
'credential:share',
'credential:update',
],
},
// Slack account in personal
3: {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
id: '3',
name: 'Slack account',
data: 'test123',
type: 'slackOAuth2Api',
isManaged: false,
homeProject: {
id: '1',
type: 'personal',
name: 'Kobi Dog <kobi@n8n.io>',
icon: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
sharedWithProjects: [],
scopes: [
'credential:create',
'credential:delete',
'credential:list',
'credential:move',
'credential:read',
'credential:share',
'credential:update',
],
},
// OpenAI credential in another project
4: {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
id: '4',
name: '[PROJECT] OpenAI Account',
data: 'test123',
type: 'openAiApi',
isManaged: false,
homeProject: {
id: '2',
type: 'team',
name: 'Test Project',
icon: { type: 'icon', value: 'exchange-alt' },
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
sharedWithProjects: [],
scopes: [
'credential:create',
'credential:delete',
'credential:list',
'credential:move',
'credential:read',
'credential:share',
'credential:update',
],
},
};
export const TEST_CREDENTIAL_TYPES: ICredentialTypeMap = {
openAiApi: {
name: 'openAiApi',
displayName: 'OpenAi',
documentationUrl: 'openAi',
properties: [
{
displayName: 'API Key',
name: 'apiKey',
type: 'string',
typeOptions: {
password: true,
},
required: true,
default: '',
},
{
displayName: 'Base URL',
name: 'url',
type: 'string',
default: 'https://api.openai.com/v1',
description: 'Override the base URL for the API',
},
],
authenticate: {
type: 'generic',
properties: {
headers: {
Authorization: '=Bearer {{$credentials.apiKey}}',
},
},
},
test: {
request: {
baseURL: '={{$credentials?.url}}',
url: '/models',
},
},
iconUrl: {
light: 'icons/n8n-nodes-base/dist/nodes/OpenAi/openai.svg',
dark: 'icons/n8n-nodes-base/dist/nodes/OpenAi/openai.dark.svg',
},
supportedNodes: [
'n8n-nodes-base.openAi',
'@n8n/n8n-nodes-langchain.embeddingsOpenAi',
'@n8n/n8n-nodes-langchain.lmChatOpenAi',
'@n8n/n8n-nodes-langchain.lmOpenAi',
],
},
supabaseApi: {
name: 'supabaseApi',
displayName: 'Supabase API',
documentationUrl: 'supabase',
properties: [
{
displayName: 'Host',
name: 'host',
type: 'string',
placeholder: 'https://your_account.supabase.co',
default: '',
},
{
displayName: 'Service Role Secret',
name: 'serviceRole',
type: 'string',
default: '',
typeOptions: {
password: true,
},
},
],
authenticate: {
type: 'generic',
properties: {
headers: {
apikey: '={{$credentials.serviceRole}}',
Authorization: '=Bearer {{$credentials.serviceRole}}',
},
},
},
test: {
request: {
baseURL: '={{$credentials.host}}/rest/v1',
headers: {
Prefer: 'return=representation',
},
url: '/',
},
},
iconUrl: 'icons/n8n-nodes-base/dist/nodes/Supabase/supabase.svg',
supportedNodes: ['n8n-nodes-base.supabase'],
},
slackOAuth2Api: {
name: 'slackOAuth2Api',
extends: ['oAuth2Api'],
displayName: 'Slack OAuth2 API',
documentationUrl: 'slack',
properties: [
{
displayName: 'Grant Type',
name: 'grantType',
type: 'hidden',
default: 'authorizationCode',
},
{
displayName: 'Authorization URL',
name: 'authUrl',
type: 'hidden',
default: 'https://slack.com/oauth/v2/authorize',
},
{
displayName: 'Access Token URL',
name: 'accessTokenUrl',
type: 'hidden',
default: 'https://slack.com/api/oauth.v2.access',
},
{
displayName: 'Scope',
name: 'scope',
type: 'hidden',
default: 'chat:write',
},
{
displayName: 'Auth URI Query Parameters',
name: 'authQueryParameters',
type: 'hidden',
default:
'user_scope=channels:read channels:write chat:write files:read files:write groups:read im:read mpim:read reactions:read reactions:write stars:read stars:write usergroups:write usergroups:read users.profile:read users.profile:write users:read',
},
{
displayName: 'Authentication',
name: 'authentication',
type: 'hidden',
default: 'body',
},
{
displayName:
'If you get an Invalid Scopes error, make sure you add the correct one <a target="_blank" href="https://docs.n8n.io/integrations/builtin/credentials/slack/#using-oauth">here</a> to your Slack integration',
name: 'notice',
type: 'notice',
default: '',
},
],
iconUrl: 'icons/n8n-nodes-base/dist/nodes/Slack/slack.svg',
supportedNodes: ['n8n-nodes-base.slack'],
},
};
export const PERSONAL_OPENAI_CREDENTIAL = TEST_CREDENTIALS[1];
export const PROJECT_OPENAI_CREDENTIAL = TEST_CREDENTIALS[4];

View File

@@ -0,0 +1,78 @@
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils';
import { useCredentialsStore } from '@/stores/credentials.store';
import { createTestingPinia } from '@pinia/testing';
import CredentialPicker from './CredentialPicker.vue';
import {
PERSONAL_OPENAI_CREDENTIAL,
PROJECT_OPENAI_CREDENTIAL,
TEST_CREDENTIAL_TYPES,
TEST_CREDENTIALS,
} from './CredentialPicker.test.constants';
import userEvent from '@testing-library/user-event';
import { screen } from '@testing-library/vue';
vi.mock('vue-router', () => {
const push = vi.fn();
const resolve = vi.fn().mockReturnValue({ href: 'https://test.com' });
return {
useRouter: () => ({
push,
resolve,
}),
useRoute: () => ({}),
RouterLink: vi.fn(),
};
});
let credentialsStore: ReturnType<typeof mockedStore<typeof useCredentialsStore>>;
const renderComponent = createComponentRenderer(CredentialPicker);
describe('CredentialPicker', () => {
beforeEach(() => {
createTestingPinia();
credentialsStore = mockedStore(useCredentialsStore);
credentialsStore.state.credentials = TEST_CREDENTIALS;
credentialsStore.state.credentialTypes = TEST_CREDENTIAL_TYPES;
});
it('should render', () => {
expect(() =>
renderComponent({
props: {
appName: 'OpenAI',
credentialType: 'openAiApi',
selectedCredentialId: null,
},
}),
).not.toThrowError();
});
it('should only render personal credentials of the specified type', async () => {
const TEST_APP_NAME = 'OpenAI';
const TEST_CREDENTIAL_TYPE = 'openAiApi';
const { getByTestId } = renderComponent({
props: {
appName: TEST_APP_NAME,
credentialType: TEST_CREDENTIAL_TYPE,
selectedCredentialId: null,
},
});
expect(getByTestId('credential-dropdown')).toBeInTheDocument();
expect(getByTestId('credential-dropdown')).toHaveAttribute(
'credential-type',
TEST_CREDENTIAL_TYPE,
);
// Open the dropdown
await userEvent.click(getByTestId('credential-dropdown'));
// Personal openAI credential should be in the dropdown
expect(
screen.getByTestId(`node-credentials-select-item-${PERSONAL_OPENAI_CREDENTIAL.id}`),
).toBeInTheDocument();
// OpenAI credential that belong to other project should not be in the dropdown
expect(
screen.queryByTestId(`node-credentials-select-item-${PROJECT_OPENAI_CREDENTIAL.id}`),
).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,148 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { listenForModalChanges, useUIStore } from '@/stores/ui.store';
import { listenForCredentialChanges, useCredentialsStore } from '@/stores/credentials.store';
import { assert } from '@n8n/utils/assert';
import CredentialsDropdown from './CredentialsDropdown.vue';
import { useI18n } from '@/composables/useI18n';
import { CREDENTIAL_EDIT_MODAL_KEY } from '@/constants';
const props = defineProps<{
appName: string;
credentialType: string;
selectedCredentialId: string | null;
}>();
const emit = defineEmits<{
credentialSelected: [credentialId: string];
credentialDeselected: [];
credentialModalOpened: [];
}>();
const uiStore = useUIStore();
const credentialsStore = useCredentialsStore();
const i18n = useI18n();
const wasModalOpenedFromHere = ref(false);
const availableCredentials = computed(() => {
const credByType = credentialsStore.getCredentialsByType(props.credentialType);
// Only show personal credentials since templates are created in personal by default
// Here, we don't care about sharing because credentials cannot be shared with personal project
return credByType.filter((credential) => credential.homeProject?.type === 'personal');
});
const credentialOptions = computed(() => {
return availableCredentials.value.map((credential) => ({
id: credential.id,
name: credential.name,
typeDisplayName: credentialsStore.getCredentialTypeByName(credential.type)?.displayName,
}));
});
const onCredentialSelected = (credentialId: string) => {
emit('credentialSelected', credentialId);
};
const createNewCredential = () => {
uiStore.openNewCredential(props.credentialType, true);
wasModalOpenedFromHere.value = true;
emit('credentialModalOpened');
};
const editCredential = () => {
assert(props.selectedCredentialId);
uiStore.openExistingCredential(props.selectedCredentialId);
wasModalOpenedFromHere.value = true;
emit('credentialModalOpened');
};
listenForCredentialChanges({
store: credentialsStore,
onCredentialCreated: (credential) => {
if (!wasModalOpenedFromHere.value) {
return;
}
emit('credentialSelected', credential.id);
},
onCredentialDeleted: (deletedCredentialId) => {
if (!wasModalOpenedFromHere.value) {
return;
}
if (deletedCredentialId !== props.selectedCredentialId) {
return;
}
const optionsWoDeleted = credentialOptions.value
.map((credential) => credential.id)
.filter((id) => id !== deletedCredentialId);
if (optionsWoDeleted.length > 0) {
emit('credentialSelected', optionsWoDeleted[0]);
} else {
emit('credentialDeselected');
}
},
});
listenForModalChanges({
store: uiStore,
onModalClosed(modalName) {
if (modalName === CREDENTIAL_EDIT_MODAL_KEY && wasModalOpenedFromHere.value) {
wasModalOpenedFromHere.value = false;
}
},
});
</script>
<template>
<div>
<div v-if="credentialOptions.length > 0" :class="$style.dropdown">
<CredentialsDropdown
:credential-type="props.credentialType"
:credential-options="credentialOptions"
:selected-credential-id="props.selectedCredentialId"
data-test-id="credential-dropdown"
@credential-selected="onCredentialSelected"
@new-credential="createNewCredential"
/>
<n8n-icon-button
icon="pen"
type="secondary"
:class="{
[$style.edit]: true,
[$style.invisible]: !props.selectedCredentialId,
}"
:title="i18n.baseText('nodeCredentials.updateCredential')"
data-test-id="credential-edit-button"
@click="editCredential()"
/>
</div>
<n8n-button
v-else
:label="`Create new ${props.appName} credential`"
data-test-id="create-credential"
@click="createNewCredential"
/>
</div>
</template>
<style lang="scss" module>
.dropdown {
display: flex;
}
.edit {
display: flex;
justify-content: center;
align-items: center;
min-width: 20px;
margin-left: var(--spacing-2xs);
font-size: var(--font-size-s);
}
.invisible {
visibility: hidden;
}
</style>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
export type CredentialOption = {
id: string;
name: string;
typeDisplayName: string | undefined;
};
const props = defineProps<{
credentialOptions: CredentialOption[];
selectedCredentialId: string | null;
}>();
const emit = defineEmits<{
credentialSelected: [credentialId: string];
newCredential: [];
}>();
const i18n = useI18n();
const NEW_CREDENTIALS_TEXT = `- ${i18n.baseText('nodeCredentials.createNew')} -`;
const onCredentialSelected = (credentialId: string) => {
if (credentialId === NEW_CREDENTIALS_TEXT) {
emit('newCredential');
} else {
emit('credentialSelected', credentialId);
}
};
</script>
<template>
<n8n-select
size="small"
:model-value="props.selectedCredentialId"
@update:model-value="onCredentialSelected"
>
<n8n-option
v-for="item in props.credentialOptions"
:key="item.id"
:data-test-id="`node-credentials-select-item-${item.id}`"
:label="item.name"
:value="item.id"
>
<div :class="[$style.credentialOption, 'mt-2xs mb-2xs']">
<n8n-text bold>{{ item.name }}</n8n-text>
<n8n-text size="small">{{ item.typeDisplayName }}</n8n-text>
</div>
</n8n-option>
<n8n-option
:key="NEW_CREDENTIALS_TEXT"
data-test-id="node-credentials-select-item-new"
:value="NEW_CREDENTIALS_TEXT"
:label="NEW_CREDENTIALS_TEXT"
>
</n8n-option>
</n8n-select>
</template>
<style lang="scss" module>
.credentialOption {
display: flex;
flex-direction: column;
}
</style>