refactor(editor): Migrate components to composition API (#11497)

This commit is contained in:
Mutasem Aldmour 2024-11-04 23:00:06 +01:00 committed by GitHub
parent 3eb05e6df9
commit 611967decc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1005 additions and 1088 deletions

View file

@ -1,65 +1,59 @@
<script lang="ts">
<script lang="ts" setup>
import { useUIStore } from '@/stores/ui.store';
import type { PublicInstalledPackage } from 'n8n-workflow';
import { mapStores } from 'pinia';
import { defineComponent } from 'vue';
import { NPM_PACKAGE_DOCS_BASE_URL, COMMUNITY_PACKAGE_MANAGE_ACTIONS } from '@/constants';
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
export default defineComponent({
name: 'CommunityPackageCard',
props: {
communityPackage: {
type: Object as () => PublicInstalledPackage | null,
required: false,
default: null,
},
loading: {
type: Boolean,
default: false,
},
},
data() {
return {
packageActions: [
{
label: this.$locale.baseText('settings.communityNodes.viewDocsAction.label'),
value: COMMUNITY_PACKAGE_MANAGE_ACTIONS.VIEW_DOCS,
type: 'external-link',
},
{
label: this.$locale.baseText('settings.communityNodes.uninstallAction.label'),
value: COMMUNITY_PACKAGE_MANAGE_ACTIONS.UNINSTALL,
},
],
};
},
computed: {
...mapStores(useUIStore),
},
methods: {
async onAction(value: string) {
if (!this.communityPackage) return;
switch (value) {
case COMMUNITY_PACKAGE_MANAGE_ACTIONS.VIEW_DOCS:
this.$telemetry.track('user clicked to browse the cnr package documentation', {
package_name: this.communityPackage.packageName,
package_version: this.communityPackage.installedVersion,
});
window.open(`${NPM_PACKAGE_DOCS_BASE_URL}${this.communityPackage.packageName}`, '_blank');
break;
case COMMUNITY_PACKAGE_MANAGE_ACTIONS.UNINSTALL:
this.uiStore.openCommunityPackageUninstallConfirmModal(this.communityPackage.packageName);
break;
default:
break;
}
},
onUpdateClick() {
if (!this.communityPackage) return;
this.uiStore.openCommunityPackageUpdateConfirmModal(this.communityPackage.packageName);
},
},
interface Props {
communityPackage?: PublicInstalledPackage | null;
loading?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
communityPackage: null,
loading: false,
});
const { openCommunityPackageUpdateConfirmModal, openCommunityPackageUninstallConfirmModal } =
useUIStore();
const i18n = useI18n();
const telemetry = useTelemetry();
const packageActions = [
{
label: i18n.baseText('settings.communityNodes.viewDocsAction.label'),
value: COMMUNITY_PACKAGE_MANAGE_ACTIONS.VIEW_DOCS,
type: 'external-link',
},
{
label: i18n.baseText('settings.communityNodes.uninstallAction.label'),
value: COMMUNITY_PACKAGE_MANAGE_ACTIONS.UNINSTALL,
},
];
async function onAction(value: string) {
if (!props.communityPackage) return;
switch (value) {
case COMMUNITY_PACKAGE_MANAGE_ACTIONS.VIEW_DOCS:
telemetry.track('user clicked to browse the cnr package documentation', {
package_name: props.communityPackage.packageName,
package_version: props.communityPackage.installedVersion,
});
window.open(`${NPM_PACKAGE_DOCS_BASE_URL}${props.communityPackage.packageName}`, '_blank');
break;
case COMMUNITY_PACKAGE_MANAGE_ACTIONS.UNINSTALL:
openCommunityPackageUninstallConfirmModal(props.communityPackage.packageName);
break;
default:
break;
}
}
function onUpdateClick() {
if (!props.communityPackage) return;
openCommunityPackageUpdateConfirmModal(props.communityPackage.packageName);
}
</script>
<template>
@ -76,7 +70,7 @@ export default defineComponent({
<div :class="$style.cardSubtitle">
<n8n-text :bold="true" size="small" color="text-light">
{{
$locale.baseText('settings.communityNodes.packageNodes.label', {
i18n.baseText('settings.communityNodes.packageNodes.label', {
adjustToNumber: communityPackage.installedNodes.length,
})
}}:&nbsp;
@ -96,7 +90,7 @@ export default defineComponent({
<n8n-tooltip v-if="communityPackage.failedLoading === true" placement="top">
<template #content>
<div>
{{ $locale.baseText('settings.communityNodes.failedToLoad.tooltip') }}
{{ i18n.baseText('settings.communityNodes.failedToLoad.tooltip') }}
</div>
</template>
<n8n-icon icon="exclamation-triangle" color="danger" size="large" />
@ -104,7 +98,7 @@ export default defineComponent({
<n8n-tooltip v-else-if="communityPackage.updateAvailable" placement="top">
<template #content>
<div>
{{ $locale.baseText('settings.communityNodes.updateAvailable.tooltip') }}
{{ i18n.baseText('settings.communityNodes.updateAvailable.tooltip') }}
</div>
</template>
<n8n-button outline label="Update" @click="onUpdateClick" />
@ -112,7 +106,7 @@ export default defineComponent({
<n8n-tooltip v-else placement="top">
<template #content>
<div>
{{ $locale.baseText('settings.communityNodes.upToDate.tooltip') }}
{{ i18n.baseText('settings.communityNodes.upToDate.tooltip') }}
</div>
</template>
<n8n-icon icon="check-circle" color="text-light" size="large" />

View file

@ -51,6 +51,7 @@ export default defineComponent({
},
pushRef: {
type: String,
required: true,
},
readOnly: {
type: Boolean,

View file

@ -1,6 +1,5 @@
<script lang="ts">
import { defineComponent } from 'vue';
import { mapStores } from 'pinia';
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { useToast } from '@/composables/useToast';
import Modal from './Modal.vue';
import type { IFormInputs, IInviteResponse, IUser, InvitableRoleName } from '@/Interface';
@ -12,13 +11,256 @@ import {
} from '@/constants';
import { useUsersStore } from '@/stores/users.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useUIStore } from '@/stores/ui.store';
import { createFormEventBus, createEventBus } from 'n8n-design-system/utils';
import { useClipboard } from '@/composables/useClipboard';
import { useI18n } from '@/composables/useI18n';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
const NAME_EMAIL_FORMAT_REGEX = /^.* <(.*)>$/;
const usersStore = useUsersStore();
const settingsStore = useSettingsStore();
const clipboard = useClipboard();
const { showMessage, showError } = useToast();
const i18n = useI18n();
const { goToUpgrade } = usePageRedirectionHelper();
const formBus = createFormEventBus();
const modalBus = createEventBus();
const config = ref<IFormInputs | null>();
const emails = ref('');
const role = ref<InvitableRoleName>(ROLE.Member);
const showInviteUrls = ref<IInviteResponse[] | null>(null);
const loading = ref(false);
onMounted(() => {
config.value = [
{
name: 'emails',
properties: {
label: i18n.baseText('settings.users.newEmailsToInvite'),
required: true,
validationRules: [{ name: 'VALID_EMAILS' }],
validators: {
VALID_EMAILS: {
validate: validateEmails,
},
},
placeholder: 'name1@email.com, name2@email.com, ...',
capitalize: true,
focusInitially: true,
},
},
{
name: 'role',
initialValue: ROLE.Member,
properties: {
label: i18n.baseText('auth.role'),
required: true,
type: 'select',
options: [
{
value: ROLE.Member,
label: i18n.baseText('auth.roles.member'),
},
{
value: ROLE.Admin,
label: i18n.baseText('auth.roles.admin'),
disabled: !isAdvancedPermissionsEnabled.value,
},
],
capitalize: true,
},
},
];
});
const emailsCount = computed((): number => {
return emails.value.split(',').filter((email: string) => !!email.trim()).length;
});
const buttonLabel = computed((): string => {
if (emailsCount.value > 1) {
return i18n.baseText(
`settings.users.inviteXUser${settingsStore.isSmtpSetup ? '' : '.inviteUrl'}`,
{
interpolate: { count: emailsCount.value.toString() },
},
);
}
return i18n.baseText(`settings.users.inviteUser${settingsStore.isSmtpSetup ? '' : '.inviteUrl'}`);
});
const enabledButton = computed((): boolean => {
return emailsCount.value >= 1;
});
const invitedUsers = computed((): IUser[] => {
return showInviteUrls.value
? usersStore.allUsers.filter((user) =>
showInviteUrls.value?.find((invite) => invite.user.id === user.id),
)
: [];
});
const isAdvancedPermissionsEnabled = computed((): boolean => {
return settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.AdvancedPermissions];
});
const validateEmails = (value: string | number | boolean | null | undefined) => {
if (typeof value !== 'string') {
return false;
}
const emails = value.split(',');
for (let i = 0; i < emails.length; i++) {
const email = emails[i];
const parsed = getEmail(email);
if (!!parsed.trim() && !VALID_EMAIL_REGEX.test(String(parsed).trim().toLowerCase())) {
return {
messageKey: 'settings.users.invalidEmailError',
options: { interpolate: { email: parsed } },
};
}
}
return false;
};
function onInput(e: { name: string; value: InvitableRoleName }) {
if (e.name === 'emails') {
emails.value = e.value;
}
if (e.name === 'role') {
role.value = e.value;
}
}
async function onSubmit() {
try {
loading.value = true;
const emailList = emails.value
.split(',')
.map((email) => ({ email: getEmail(email), role: role.value }))
.filter((invite) => !!invite.email);
if (emailList.length === 0) {
throw new Error(i18n.baseText('settings.users.noUsersToInvite'));
}
const invited = await usersStore.inviteUsers(emailList);
const erroredInvites = invited.filter((invite) => invite.error);
const successfulEmailInvites = invited.filter(
(invite) => !invite.error && invite.user.emailSent,
);
const successfulUrlInvites = invited.filter(
(invite) => !invite.error && !invite.user.emailSent,
);
if (successfulEmailInvites.length) {
showMessage({
type: 'success',
title: i18n.baseText(
successfulEmailInvites.length > 1
? 'settings.users.usersInvited'
: 'settings.users.userInvited',
),
message: i18n.baseText('settings.users.emailInvitesSent', {
interpolate: {
emails: successfulEmailInvites.map(({ user }) => user.email).join(', '),
},
}),
});
}
if (successfulUrlInvites.length) {
if (successfulUrlInvites.length === 1) {
void clipboard.copy(successfulUrlInvites[0].user.inviteAcceptUrl);
}
showMessage({
type: 'success',
title: i18n.baseText(
successfulUrlInvites.length > 1
? 'settings.users.multipleInviteUrlsCreated'
: 'settings.users.inviteUrlCreated',
),
message: i18n.baseText(
successfulUrlInvites.length > 1
? 'settings.users.multipleInviteUrlsCreated.message'
: 'settings.users.inviteUrlCreated.message',
{
interpolate: {
emails: successfulUrlInvites.map(({ user }) => user.email).join(', '),
},
},
),
});
}
if (erroredInvites.length) {
setTimeout(() => {
showMessage({
type: 'error',
title: i18n.baseText('settings.users.usersEmailedError'),
message: i18n.baseText('settings.users.emailInvitesSentError', {
interpolate: { emails: erroredInvites.map(({ error }) => error).join(', ') },
}),
});
}, 0); // notifications stack on top of each other otherwise
}
if (successfulUrlInvites.length > 1) {
showInviteUrls.value = successfulUrlInvites;
} else {
modalBus.emit('close');
}
} catch (error) {
showError(error, i18n.baseText('settings.users.usersInvitedError'));
}
loading.value = false;
}
function showCopyInviteLinkToast(successfulUrlInvites: IInviteResponse[]) {
showMessage({
type: 'success',
title: i18n.baseText(
successfulUrlInvites.length > 1
? 'settings.users.multipleInviteUrlsCreated'
: 'settings.users.inviteUrlCreated',
),
message: i18n.baseText(
successfulUrlInvites.length > 1
? 'settings.users.multipleInviteUrlsCreated.message'
: 'settings.users.inviteUrlCreated.message',
{
interpolate: {
emails: successfulUrlInvites.map(({ user }) => user.email).join(', '),
},
},
),
});
}
function onSubmitClick() {
formBus.emit('submit');
}
function onCopyInviteLink(user: IUser) {
if (user.inviteAcceptUrl && showInviteUrls.value) {
void clipboard.copy(user.inviteAcceptUrl);
showCopyInviteLinkToast([]);
}
}
function goToUpgradeAdvancedPermissions() {
void goToUpgrade('advanced-permissions', 'upgrade-advanced-permissions');
}
function getEmail(email: string): string {
let parsed = email.trim();
if (NAME_EMAIL_FORMAT_REGEX.test(parsed)) {
@ -29,267 +271,13 @@ function getEmail(email: string): string {
}
return parsed;
}
export default defineComponent({
name: 'InviteUsersModal',
components: { Modal },
props: {
modalName: {
type: String,
},
},
setup() {
const clipboard = useClipboard();
return {
clipboard,
...useToast(),
...usePageRedirectionHelper(),
};
},
data() {
return {
config: null as IFormInputs | null,
formBus: createFormEventBus(),
modalBus: createEventBus(),
emails: '',
role: ROLE.Member as InvitableRoleName,
showInviteUrls: null as IInviteResponse[] | null,
loading: false,
INVITE_USER_MODAL_KEY,
};
},
mounted() {
this.config = [
{
name: 'emails',
properties: {
label: this.$locale.baseText('settings.users.newEmailsToInvite'),
required: true,
validationRules: [{ name: 'VALID_EMAILS' }],
validators: {
VALID_EMAILS: {
validate: this.validateEmails,
},
},
placeholder: 'name1@email.com, name2@email.com, ...',
capitalize: true,
focusInitially: true,
},
},
{
name: 'role',
initialValue: ROLE.Member,
properties: {
label: this.$locale.baseText('auth.role'),
required: true,
type: 'select',
options: [
{
value: ROLE.Member,
label: this.$locale.baseText('auth.roles.member'),
},
{
value: ROLE.Admin,
label: this.$locale.baseText('auth.roles.admin'),
disabled: !this.isAdvancedPermissionsEnabled,
},
],
capitalize: true,
},
},
];
},
computed: {
...mapStores(useUsersStore, useSettingsStore, useUIStore),
emailsCount(): number {
return this.emails.split(',').filter((email: string) => !!email.trim()).length;
},
buttonLabel(): string {
if (this.emailsCount > 1) {
return this.$locale.baseText(
`settings.users.inviteXUser${this.settingsStore.isSmtpSetup ? '' : '.inviteUrl'}`,
{
interpolate: { count: this.emailsCount.toString() },
},
);
}
return this.$locale.baseText(
`settings.users.inviteUser${this.settingsStore.isSmtpSetup ? '' : '.inviteUrl'}`,
);
},
enabledButton(): boolean {
return this.emailsCount >= 1;
},
invitedUsers(): IUser[] {
return this.showInviteUrls
? this.usersStore.allUsers.filter((user) =>
this.showInviteUrls!.find((invite) => invite.user.id === user.id),
)
: [];
},
isAdvancedPermissionsEnabled(): boolean {
return this.settingsStore.isEnterpriseFeatureEnabled[
EnterpriseEditionFeature.AdvancedPermissions
];
},
},
methods: {
validateEmails(value: string | number | boolean | null | undefined) {
if (typeof value !== 'string') {
return false;
}
const emails = value.split(',');
for (let i = 0; i < emails.length; i++) {
const email = emails[i];
const parsed = getEmail(email);
if (!!parsed.trim() && !VALID_EMAIL_REGEX.test(String(parsed).trim().toLowerCase())) {
return {
messageKey: 'settings.users.invalidEmailError',
options: { interpolate: { email: parsed } },
};
}
}
return false;
},
onInput(e: { name: string; value: InvitableRoleName }) {
if (e.name === 'emails') {
this.emails = e.value;
}
if (e.name === 'role') {
this.role = e.value;
}
},
async onSubmit() {
try {
this.loading = true;
const emails = this.emails
.split(',')
.map((email) => ({ email: getEmail(email), role: this.role }))
.filter((invite) => !!invite.email);
if (emails.length === 0) {
throw new Error(this.$locale.baseText('settings.users.noUsersToInvite'));
}
const invited = await this.usersStore.inviteUsers(emails);
const erroredInvites = invited.filter((invite) => invite.error);
const successfulEmailInvites = invited.filter(
(invite) => !invite.error && invite.user.emailSent,
);
const successfulUrlInvites = invited.filter(
(invite) => !invite.error && !invite.user.emailSent,
);
if (successfulEmailInvites.length) {
this.showMessage({
type: 'success',
title: this.$locale.baseText(
successfulEmailInvites.length > 1
? 'settings.users.usersInvited'
: 'settings.users.userInvited',
),
message: this.$locale.baseText('settings.users.emailInvitesSent', {
interpolate: {
emails: successfulEmailInvites.map(({ user }) => user.email).join(', '),
},
}),
});
}
if (successfulUrlInvites.length) {
if (successfulUrlInvites.length === 1) {
void this.clipboard.copy(successfulUrlInvites[0].user.inviteAcceptUrl);
}
this.showMessage({
type: 'success',
title: this.$locale.baseText(
successfulUrlInvites.length > 1
? 'settings.users.multipleInviteUrlsCreated'
: 'settings.users.inviteUrlCreated',
),
message: this.$locale.baseText(
successfulUrlInvites.length > 1
? 'settings.users.multipleInviteUrlsCreated.message'
: 'settings.users.inviteUrlCreated.message',
{
interpolate: {
emails: successfulUrlInvites.map(({ user }) => user.email).join(', '),
},
},
),
});
}
if (erroredInvites.length) {
setTimeout(() => {
this.showMessage({
type: 'error',
title: this.$locale.baseText('settings.users.usersEmailedError'),
message: this.$locale.baseText('settings.users.emailInvitesSentError', {
interpolate: { emails: erroredInvites.map(({ error }) => error).join(', ') },
}),
});
}, 0); // notifications stack on top of each other otherwise
}
if (successfulUrlInvites.length > 1) {
this.showInviteUrls = successfulUrlInvites;
} else {
this.modalBus.emit('close');
}
} catch (error) {
this.showError(error, this.$locale.baseText('settings.users.usersInvitedError'));
}
this.loading = false;
},
showCopyInviteLinkToast(successfulUrlInvites: IInviteResponse[]) {
this.showMessage({
type: 'success',
title: this.$locale.baseText(
successfulUrlInvites.length > 1
? 'settings.users.multipleInviteUrlsCreated'
: 'settings.users.inviteUrlCreated',
),
message: this.$locale.baseText(
successfulUrlInvites.length > 1
? 'settings.users.multipleInviteUrlsCreated.message'
: 'settings.users.inviteUrlCreated.message',
{
interpolate: {
emails: successfulUrlInvites.map(({ user }) => user.email).join(', '),
},
},
),
});
},
onSubmitClick() {
this.formBus.emit('submit');
},
onCopyInviteLink(user: IUser) {
if (user.inviteAcceptUrl && this.showInviteUrls) {
void this.clipboard.copy(user.inviteAcceptUrl);
this.showCopyInviteLinkToast([]);
}
},
goToUpgradeAdvancedPermissions() {
void this.goToUpgrade('advanced-permissions', 'upgrade-advanced-permissions');
},
},
});
</script>
<template>
<Modal
:name="INVITE_USER_MODAL_KEY"
:title="
$locale.baseText(
i18n.baseText(
showInviteUrls ? 'settings.users.copyInviteUrls' : 'settings.users.inviteNewUsers',
)
"
@ -303,7 +291,7 @@ export default defineComponent({
<i18n-t keypath="settings.users.advancedPermissions.warning">
<template #link>
<n8n-link size="small" @click="goToUpgradeAdvancedPermissions">
{{ $locale.baseText('settings.users.advancedPermissions.warning.link') }}
{{ i18n.baseText('settings.users.advancedPermissions.warning.link') }}
</n8n-link>
</template>
</i18n-t>
@ -313,7 +301,7 @@ export default defineComponent({
<template #actions="{ user }">
<n8n-tooltip>
<template #content>
{{ $locale.baseText('settings.users.inviteLink.copy') }}
{{ i18n.baseText('settings.users.inviteLink.copy') }}
</template>
<n8n-icon-button
icon="link"

View file

@ -122,7 +122,7 @@ const badge = computed(() => {
:disabled="disabled"
:size="size"
:circle="circle"
:node-type-name="nodeName ?? nodeType?.displayName ?? ''"
:node-type-name="nodeName ? nodeName : nodeType?.displayName"
:show-tooltip="showTooltip"
:tooltip-position="tooltipPosition"
:badge="badge"

View file

@ -35,7 +35,7 @@ type Props = {
isReadOnly?: boolean;
linkedRuns?: boolean;
canLinkRuns?: boolean;
pushRef?: string;
pushRef: string;
blockUI?: boolean;
isProductionExecutionPreview?: boolean;
isPaneActive?: boolean;

View file

@ -135,6 +135,7 @@ export default defineComponent({
},
pushRef: {
type: String,
required: true,
},
paneType: {
type: String as PropType<NodePanelType>,

View file

@ -23,15 +23,15 @@ const LazyRunDataJsonActions = defineAsyncComponent(
const props = withDefaults(
defineProps<{
editMode: { enabled?: boolean; value?: string };
pushRef?: string;
paneType?: string;
pushRef: string;
paneType: string;
node: INodeUi;
inputData: INodeExecutionData[];
mappingEnabled?: boolean;
distanceFromActive: number;
runIndex?: number;
totalRuns?: number;
search?: string;
runIndex: number | undefined;
totalRuns: number | undefined;
search: string | undefined;
}>(),
{
editMode: () => ({}),

View file

@ -1,7 +1,4 @@
<script lang="ts">
import { defineComponent } from 'vue';
import type { PropType } from 'vue';
import { mapStores, storeToRefs } from 'pinia';
<script lang="ts" setup>
import jp from 'jsonpath';
import type { INodeUi } from '@/Interface';
import type { IDataObject } from 'n8n-workflow';
@ -14,192 +11,168 @@ import { useToast } from '@/composables/useToast';
import { useI18n } from '@/composables/useI18n';
import { nonExistingJsonPath } from '@/constants';
import { useClipboard } from '@/composables/useClipboard';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { usePinnedData } from '@/composables/usePinnedData';
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useTelemetry } from '@/composables/useTelemetry';
type JsonPathData = {
path: string;
startPath: string;
};
export default defineComponent({
name: 'RunDataJsonActions',
props: {
node: {
type: Object as PropType<INodeUi>,
required: true,
},
paneType: {
type: String,
},
pushRef: {
type: String,
},
currentOutputIndex: {
type: Number,
},
runIndex: {
type: Number,
},
displayMode: {
type: String,
},
distanceFromActive: {
type: Number,
},
selectedJsonPath: {
type: String,
default: nonExistingJsonPath,
},
jsonData: {
type: Array as PropType<IDataObject[]>,
required: true,
},
const props = withDefaults(
defineProps<{
node: INodeUi;
paneType: string;
pushRef: string;
displayMode: string;
distanceFromActive: number;
selectedJsonPath: string;
jsonData: IDataObject[];
currentOutputIndex?: number;
runIndex?: number;
}>(),
{
selectedJsonPath: nonExistingJsonPath,
},
setup() {
const ndvStore = useNDVStore();
const i18n = useI18n();
const nodeHelpers = useNodeHelpers();
const clipboard = useClipboard();
const { activeNode } = storeToRefs(ndvStore);
const pinnedData = usePinnedData(activeNode);
);
const ndvStore = useNDVStore();
const workflowsStore = useWorkflowsStore();
return {
i18n,
nodeHelpers,
clipboard,
pinnedData,
...useToast(),
};
},
computed: {
...mapStores(useNodeTypesStore, useNDVStore, useWorkflowsStore, useSourceControlStore),
isReadOnlyRoute() {
return this.$route?.meta?.readOnlyCanvas === true;
},
activeNode(): INodeUi | null {
return this.ndvStore.activeNode;
},
noSelection() {
return this.selectedJsonPath === nonExistingJsonPath;
},
normalisedJsonPath(): string {
return this.noSelection ? '[""]' : this.selectedJsonPath;
},
},
methods: {
getJsonValue(): string {
let selectedValue = jp.query(this.jsonData, `$${this.normalisedJsonPath}`)[0];
if (this.noSelection) {
const inExecutionsFrame =
window !== window.parent && window.parent.location.pathname.includes('/executions');
const i18n = useI18n();
const nodeHelpers = useNodeHelpers();
const clipboard = useClipboard();
const { activeNode } = ndvStore;
const pinnedData = usePinnedData(activeNode);
const { showToast } = useToast();
const telemetry = useTelemetry();
if (this.pinnedData.hasData.value && !inExecutionsFrame) {
selectedValue = clearJsonKey(this.pinnedData.data.value as object);
} else {
selectedValue = executionDataToJson(
this.nodeHelpers.getNodeInputData(this.node, this.runIndex, this.currentOutputIndex),
);
}
}
const route = useRoute();
let value = '';
if (typeof selectedValue === 'object') {
value = JSON.stringify(selectedValue, null, 2);
} else {
value = selectedValue.toString();
}
return value;
},
getJsonItemPath(): JsonPathData {
const newPath = convertPath(this.normalisedJsonPath);
let startPath = '';
let path = '';
const pathParts = newPath.split(']');
const index = pathParts[0].slice(1);
path = pathParts.slice(1).join(']');
startPath = `$item(${index}).$node["${this.node.name}"].json`;
return { path, startPath };
},
getJsonParameterPath(): JsonPathData {
const newPath = convertPath(this.normalisedJsonPath);
const path = newPath.split(']').slice(1).join(']');
let startPath = `$node["${this.node.name}"].json`;
if (this.distanceFromActive === 1) {
startPath = '$json';
}
return { path, startPath };
},
handleCopyClick(commandData: { command: string }) {
let value: string;
if (commandData.command === 'value') {
value = this.getJsonValue();
this.showToast({
title: this.i18n.baseText('runData.copyValue.toast'),
message: '',
type: 'success',
duration: 2000,
});
} else {
let startPath = '';
let path = '';
if (commandData.command === 'itemPath') {
const jsonItemPath = this.getJsonItemPath();
startPath = jsonItemPath.startPath;
path = jsonItemPath.path;
this.showToast({
title: this.i18n.baseText('runData.copyItemPath.toast'),
message: '',
type: 'success',
duration: 2000,
});
} else if (commandData.command === 'parameterPath') {
const jsonParameterPath = this.getJsonParameterPath();
startPath = jsonParameterPath.startPath;
path = jsonParameterPath.path;
this.showToast({
title: this.i18n.baseText('runData.copyParameterPath.toast'),
message: '',
type: 'success',
duration: 2000,
});
}
if (!path.startsWith('[') && !path.startsWith('.') && path) {
path += '.';
}
value = `{{ ${startPath + path} }}`;
}
const copyType = {
value: 'selection',
itemPath: 'item_path',
parameterPath: 'parameter_path',
}[commandData.command];
this.$telemetry.track('User copied ndv data', {
node_type: this.activeNode?.type,
push_ref: this.pushRef,
run_index: this.runIndex,
view: 'json',
copy_type: copyType,
workflow_id: this.workflowsStore.workflowId,
pane: this.paneType,
in_execution_log: this.isReadOnlyRoute,
});
void this.clipboard.copy(value);
},
},
const isReadOnlyRoute = computed(() => {
return route?.meta?.readOnlyCanvas === true;
});
const noSelection = computed(() => {
return props.selectedJsonPath === nonExistingJsonPath;
});
const normalisedJsonPath = computed((): string => {
return noSelection.value ? '[""]' : props.selectedJsonPath;
});
function getJsonValue(): string {
let selectedValue = jp.query(props.jsonData, `$${normalisedJsonPath.value}`)[0];
if (noSelection.value) {
const inExecutionsFrame =
window !== window.parent && window.parent.location.pathname.includes('/executions');
if (pinnedData.hasData.value && !inExecutionsFrame) {
selectedValue = clearJsonKey(pinnedData.data.value as object);
} else {
selectedValue = executionDataToJson(
nodeHelpers.getNodeInputData(props.node, props.runIndex, props.currentOutputIndex),
);
}
}
let value = '';
if (typeof selectedValue === 'object') {
value = JSON.stringify(selectedValue, null, 2);
} else {
value = selectedValue.toString();
}
return value;
}
function getJsonItemPath(): JsonPathData {
const newPath = convertPath(normalisedJsonPath.value);
let startPath = '';
let path = '';
const pathParts = newPath.split(']');
const index = pathParts[0].slice(1);
path = pathParts.slice(1).join(']');
startPath = `$item(${index}).$node["${props.node.name}"].json`;
return { path, startPath };
}
function getJsonParameterPath(): JsonPathData {
const newPath = convertPath(normalisedJsonPath.value);
const path = newPath.split(']').slice(1).join(']');
let startPath = `$node["${props.node.name}"].json`;
if (props.distanceFromActive === 1) {
startPath = '$json';
}
return { path, startPath };
}
function handleCopyClick(commandData: { command: string }) {
let value: string;
if (commandData.command === 'value') {
value = getJsonValue();
showToast({
title: i18n.baseText('runData.copyValue.toast'),
message: '',
type: 'success',
duration: 2000,
});
} else {
let startPath = '';
let path = '';
if (commandData.command === 'itemPath') {
const jsonItemPath = getJsonItemPath();
startPath = jsonItemPath.startPath;
path = jsonItemPath.path;
showToast({
title: i18n.baseText('runData.copyItemPath.toast'),
message: '',
type: 'success',
duration: 2000,
});
} else if (commandData.command === 'parameterPath') {
const jsonParameterPath = getJsonParameterPath();
startPath = jsonParameterPath.startPath;
path = jsonParameterPath.path;
showToast({
title: i18n.baseText('runData.copyParameterPath.toast'),
message: '',
type: 'success',
duration: 2000,
});
}
if (!path.startsWith('[') && !path.startsWith('.') && path) {
path += '.';
}
value = `{{ ${startPath + path} }}`;
}
const copyType = {
value: 'selection',
itemPath: 'item_path',
parameterPath: 'parameter_path',
}[commandData.command];
telemetry.track('User copied ndv data', {
node_type: activeNode?.type,
push_ref: props.pushRef,
run_index: props.runIndex,
view: 'json',
copy_type: copyType,
workflow_id: workflowsStore.workflowId,
pane: props.paneType,
in_execution_log: isReadOnlyRoute.value,
});
void clipboard.copy(value);
}
</script>
<template>

View file

@ -1,134 +1,135 @@
<script lang="ts">
import { defineComponent } from 'vue';
import { EnterpriseEditionFeature, MODAL_CONFIRM } from '@/constants';
<script lang="ts" setup>
import { computed, onBeforeMount, onMounted, ref } from 'vue';
import { MODAL_CONFIRM } from '@/constants';
import { useMessage } from '@/composables/useMessage';
import { useLogStreamingStore } from '@/stores/logStreaming.store';
import type { PropType } from 'vue';
import { mapStores } from 'pinia';
import type { MessageEventBusDestinationOptions } from 'n8n-workflow';
import { deepCopy, defaultMessageEventBusDestinationOptions } from 'n8n-workflow';
import type { BaseTextKey } from '@/plugins/i18n';
import type { EventBus } from 'n8n-design-system';
import { useI18n } from '@/composables/useI18n';
import { assert } from '@/utils/assert';
export const DESTINATION_LIST_ITEM_ACTIONS = {
const DESTINATION_LIST_ITEM_ACTIONS = {
OPEN: 'open',
DELETE: 'delete',
};
export default defineComponent({
components: {},
setup() {
return {
...useMessage(),
};
},
data() {
return {
EnterpriseEditionFeature,
nodeParameters: {} as MessageEventBusDestinationOptions,
};
},
props: {
eventBus: {
type: Object as PropType<EventBus>,
},
destination: {
type: Object,
required: true,
default: deepCopy(defaultMessageEventBusDestinationOptions),
},
readonly: Boolean,
},
mounted() {
this.nodeParameters = Object.assign(
deepCopy(defaultMessageEventBusDestinationOptions),
this.destination,
);
this.eventBus?.on('destinationWasSaved', this.onDestinationWasSaved);
},
beforeUnmount() {
this.eventBus?.off('destinationWasSaved', this.onDestinationWasSaved);
},
computed: {
...mapStores(useLogStreamingStore),
actions(): Array<{ label: string; value: string }> {
const actions = [
{
label: this.$locale.baseText('workflows.item.open'),
value: DESTINATION_LIST_ITEM_ACTIONS.OPEN,
},
];
if (!this.readonly) {
actions.push({
label: this.$locale.baseText('workflows.item.delete'),
value: DESTINATION_LIST_ITEM_ACTIONS.DELETE,
});
}
return actions;
},
typeLabelName(): BaseTextKey {
return `settings.log-streaming.${this.destination.__type}` as BaseTextKey;
},
},
methods: {
onDestinationWasSaved() {
const updatedDestination = this.logStreamingStore.getDestination(this.destination.id);
if (updatedDestination) {
this.nodeParameters = Object.assign(
deepCopy(defaultMessageEventBusDestinationOptions),
this.destination,
);
}
},
async onClick(event: Event) {
const cardActions = this.$refs.cardActions as HTMLDivElement | null;
const target = event.target as HTMLDivElement | null;
if (
cardActions === target ||
cardActions?.contains(target) ||
target?.contains(cardActions)
) {
return;
}
const { confirm } = useMessage();
const i18n = useI18n();
const logStreamingStore = useLogStreamingStore();
this.$emit('edit', this.destination.id);
},
onEnabledSwitched(state: boolean) {
this.nodeParameters.enabled = state;
void this.saveDestination();
},
async saveDestination() {
await this.logStreamingStore.saveDestination(this.nodeParameters);
},
async onAction(action: string) {
if (action === DESTINATION_LIST_ITEM_ACTIONS.OPEN) {
this.$emit('edit', this.destination.id);
} else if (action === DESTINATION_LIST_ITEM_ACTIONS.DELETE) {
const deleteConfirmed = await this.confirm(
this.$locale.baseText('settings.log-streaming.destinationDelete.message', {
interpolate: { destinationName: this.destination.label },
}),
this.$locale.baseText('settings.log-streaming.destinationDelete.headline'),
{
type: 'warning',
confirmButtonText: this.$locale.baseText(
'settings.log-streaming.destinationDelete.confirmButtonText',
),
cancelButtonText: this.$locale.baseText(
'settings.log-streaming.destinationDelete.cancelButtonText',
),
},
);
const nodeParameters = ref<MessageEventBusDestinationOptions>({});
const cardActions = ref<HTMLDivElement | null>(null);
if (deleteConfirmed !== MODAL_CONFIRM) {
return;
}
this.$emit('remove', this.destination.id);
}
},
const props = withDefaults(
defineProps<{
eventBus: EventBus;
destination: MessageEventBusDestinationOptions;
readonly: boolean;
}>(),
{
destination: () => deepCopy(defaultMessageEventBusDestinationOptions),
},
);
const emit = defineEmits<{
edit: [id: string | undefined];
remove: [id: string | undefined];
}>();
onMounted(() => {
nodeParameters.value = Object.assign(
deepCopy(defaultMessageEventBusDestinationOptions),
props.destination,
);
props.eventBus?.on('destinationWasSaved', onDestinationWasSaved);
});
onBeforeMount(() => {
props.eventBus?.off('destinationWasSaved', onDestinationWasSaved);
});
const actions = computed((): Array<{ label: string; value: string }> => {
const actionList = [
{
label: i18n.baseText('workflows.item.open'),
value: DESTINATION_LIST_ITEM_ACTIONS.OPEN,
},
];
if (!props.readonly) {
actionList.push({
label: i18n.baseText('workflows.item.delete'),
value: DESTINATION_LIST_ITEM_ACTIONS.DELETE,
});
}
return actionList;
});
const typeLabelName = computed((): BaseTextKey => {
return `settings.log-streaming.${props.destination.__type}` as BaseTextKey;
});
function onDestinationWasSaved() {
assert(props.destination.id);
const updatedDestination = logStreamingStore.getDestination(props.destination.id);
if (updatedDestination) {
nodeParameters.value = Object.assign(
deepCopy(defaultMessageEventBusDestinationOptions),
props.destination,
);
}
}
async function onClick(event: Event) {
const target = event.target as HTMLDivElement | null;
if (
cardActions.value === target ||
cardActions.value?.contains(target) ||
target?.contains(cardActions.value)
) {
return;
}
emit('edit', props.destination.id);
}
function onEnabledSwitched(state: boolean) {
nodeParameters.value.enabled = state;
void saveDestination();
}
async function saveDestination() {
await logStreamingStore.saveDestination(nodeParameters.value);
}
async function onAction(action: string) {
if (action === DESTINATION_LIST_ITEM_ACTIONS.OPEN) {
emit('edit', props.destination.id);
} else if (action === DESTINATION_LIST_ITEM_ACTIONS.DELETE) {
const deleteConfirmed = await confirm(
i18n.baseText('settings.log-streaming.destinationDelete.message', {
interpolate: { destinationName: props.destination.label ?? '' },
}),
i18n.baseText('settings.log-streaming.destinationDelete.headline'),
{
type: 'warning',
confirmButtonText: i18n.baseText(
'settings.log-streaming.destinationDelete.confirmButtonText',
),
cancelButtonText: i18n.baseText(
'settings.log-streaming.destinationDelete.cancelButtonText',
),
},
);
if (deleteConfirmed !== MODAL_CONFIRM) {
return;
}
emit('remove', props.destination.id);
}
}
</script>
<template>
@ -140,7 +141,7 @@ export default defineComponent({
</n8n-heading>
<div :class="$style.cardDescription">
<n8n-text color="text-light" size="small">
<span>{{ $locale.baseText(typeLabelName) }}</span>
<span>{{ i18n.baseText(typeLabelName) }}</span>
</n8n-text>
</div>
</div>
@ -149,10 +150,10 @@ export default defineComponent({
<div ref="cardActions" :class="$style.cardActions">
<div :class="$style.activeStatusText" data-test-id="destination-activator-status">
<n8n-text v-if="nodeParameters.enabled" :color="'success'" size="small" bold>
{{ $locale.baseText('workflowActivator.active') }}
{{ i18n.baseText('workflowActivator.active') }}
</n8n-text>
<n8n-text v-else color="text-base" size="small" bold>
{{ $locale.baseText('workflowActivator.inactive') }}
{{ i18n.baseText('workflowActivator.inactive') }}
</n8n-text>
</div>
@ -162,8 +163,8 @@ export default defineComponent({
:model-value="nodeParameters.enabled"
:title="
nodeParameters.enabled
? $locale.baseText('workflowActivator.deactivateWorkflow')
: $locale.baseText('workflowActivator.activateWorkflow')
? i18n.baseText('workflowActivator.deactivateWorkflow')
: i18n.baseText('workflowActivator.activateWorkflow')
"
active-color="#13ce66"
inactive-color="#8899AA"

View file

@ -1,63 +1,45 @@
<script lang="ts">
import { type PropType, defineComponent } from 'vue';
import { filterTemplateNodes } from '@/utils/nodeTypesUtils';
<script lang="ts" setup>
import { abbreviateNumber } from '@/utils/typesUtils';
import NodeList from './NodeList.vue';
import TimeAgo from '@/components/TimeAgo.vue';
import type { ITemplatesWorkflow } from '@/Interface';
import { useI18n } from '@/composables/useI18n';
import type { BaseTextKey } from '@/plugins/i18n';
export default defineComponent({
name: 'TemplateCard',
components: {
TimeAgo,
NodeList,
const i18n = useI18n();
const nodesToBeShown = 5;
withDefaults(
defineProps<{
workflow?: ITemplatesWorkflow;
lastItem?: boolean;
firstItem?: boolean;
useWorkflowButton?: boolean;
loading?: boolean;
simpleView?: boolean;
}>(),
{
lastItem: false,
firstItem: false,
useWorkflowButton: false,
loading: false,
simpleView: false,
},
props: {
workflow: {
type: Object as PropType<ITemplatesWorkflow>,
},
lastItem: {
type: Boolean,
default: false,
},
firstItem: {
type: Boolean,
default: false,
},
useWorkflowButton: {
type: Boolean,
},
loading: {
type: Boolean,
},
simpleView: {
type: Boolean,
default: false,
},
},
data() {
return {
nodesToBeShown: 5,
};
},
methods: {
filterTemplateNodes,
abbreviateNumber,
countNodesToBeSliced(nodes: []): number {
if (nodes.length > this.nodesToBeShown) {
return this.nodesToBeShown - 1;
} else {
return this.nodesToBeShown;
}
},
onUseWorkflowClick(e: MouseEvent) {
this.$emit('useWorkflow', e);
},
onCardClick(e: MouseEvent) {
this.$emit('click', e);
},
},
});
);
const emit = defineEmits<{
useWorkflow: [e: MouseEvent];
click: [e: MouseEvent];
}>();
function onUseWorkflowClick(e: MouseEvent) {
emit('useWorkflow', e);
}
function onCardClick(e: MouseEvent) {
emit('click', e);
}
</script>
<template>
@ -88,8 +70,12 @@ export default defineComponent({
<TimeAgo :date="workflow.createdAt" />
</n8n-text>
<div v-if="workflow.user" :class="$style.line" v-text="'|'" />
<n8n-text v-if="workflow.user" size="small" color="text-light"
>By {{ workflow.user.username }}</n8n-text
<n8n-text v-if="workflow.user" size="small" color="text-light">
{{
i18n.baseText('template.byAuthor' as BaseTextKey, {
interpolate: { name: workflow.user.username },
})
}}</n8n-text
>
</div>
</div>

View file

@ -1,101 +1,104 @@
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import type { ITemplatesCategory } from '@/Interface';
import type { PropType } from 'vue';
import { useTemplatesStore } from '@/stores/templates.store';
import { mapStores } from 'pinia';
import { useI18n } from '@/composables/useI18n';
export default defineComponent({
name: 'TemplateFilters',
props: {
categories: {
type: Array as PropType<ITemplatesCategory[]>,
default: () => [],
},
sortOnPopulate: {
type: Boolean,
default: false,
},
expandLimit: {
type: Number,
default: 12,
},
loading: {
type: Boolean,
},
selected: {
type: Array as PropType<ITemplatesCategory[]>,
default: () => [],
},
},
emits: ['clearAll', 'select', 'clear'],
data() {
return {
collapsed: true,
sortedCategories: [] as ITemplatesCategory[],
};
},
computed: {
...mapStores(useTemplatesStore),
allSelected(): boolean {
return this.selected.length === 0;
},
},
watch: {
sortOnPopulate: {
handler(value: boolean) {
if (value) {
this.sortCategories();
}
},
immediate: true,
},
categories: {
handler(categories: ITemplatesCategory[]) {
if (categories.length > 0) {
this.sortCategories();
}
},
immediate: true,
},
},
methods: {
sortCategories() {
if (!this.sortOnPopulate) {
this.sortedCategories = this.categories;
} else {
const selected = this.selected || [];
const selectedCategories = this.categories.filter((cat) => selected.includes(cat));
const notSelectedCategories = this.categories.filter((cat) => !selected.includes(cat));
this.sortedCategories = selectedCategories.concat(notSelectedCategories);
}
},
collapseAction() {
this.collapsed = false;
},
handleCheckboxChanged(value: boolean, selectedCategory: ITemplatesCategory) {
this.$emit(value ? 'select' : 'clear', selectedCategory);
},
isSelected(category: ITemplatesCategory) {
return this.selected.includes(category);
},
resetCategories() {
this.$emit('clearAll');
},
},
interface Props {
categories?: ITemplatesCategory[];
sortOnPopulate?: boolean;
expandLimit?: number;
loading?: boolean;
selected?: ITemplatesCategory[];
}
const props = withDefaults(defineProps<Props>(), {
categories: () => [],
sortOnPopulate: false,
expandLimit: 12,
loading: false,
selected: () => [],
});
const emit = defineEmits<{
clearAll: [];
select: [category: ITemplatesCategory];
clear: [category: ITemplatesCategory];
}>();
const i18n = useI18n();
const collapsed = ref(true);
const sortedCategories = ref<ITemplatesCategory[]>([]);
const allSelected = computed((): boolean => {
return props.selected.length === 0;
});
function sortCategories() {
if (!props.sortOnPopulate) {
sortedCategories.value = props.categories;
} else {
const selected = props.selected || [];
const selectedCategories = props.categories.filter((cat) => selected.includes(cat));
const notSelectedCategories = props.categories.filter((cat) => !selected.includes(cat));
sortedCategories.value = selectedCategories.concat(notSelectedCategories);
}
}
function collapseAction() {
collapsed.value = false;
}
function handleCheckboxChanged(value: boolean, selectedCategory: ITemplatesCategory) {
if (value) {
emit('select', selectedCategory);
} else {
emit('clear', selectedCategory);
}
}
function isSelected(category: ITemplatesCategory) {
return props.selected.includes(category);
}
function resetCategories() {
emit('clearAll');
}
watch(
() => props.sortOnPopulate,
(value: boolean) => {
if (value) {
sortCategories();
}
},
{
immediate: true,
},
);
watch(
() => props.categories,
(categories: ITemplatesCategory[]) => {
if (categories.length > 0) {
sortCategories();
}
},
{
immediate: true,
},
);
</script>
<template>
<div :class="$style.filters" class="template-filters" data-test-id="templates-filter-container">
<div :class="$style.title" v-text="$locale.baseText('templates.categoriesHeading')" />
<div :class="$style.title" v-text="i18n.baseText('templates.categoriesHeading')" />
<div v-if="loading" :class="$style.list">
<n8n-loading :loading="loading" :rows="expandLimit" />
</div>
<ul v-if="!loading" :class="$style.categories">
<li :class="$style.item" data-test-id="template-filter-all-categories">
<el-checkbox :model-value="allSelected" @update:model-value="() => resetCategories()">
{{ $locale.baseText('templates.allCategories') }}
{{ i18n.baseText('templates.allCategories') }}
</el-checkbox>
</li>
<li

View file

@ -1,33 +1,23 @@
<script lang="ts">
import { type PropType, defineComponent } from 'vue';
<script lang="ts" setup>
import Card from '@/components/CollectionWorkflowCard.vue';
import NodeList from '@/components/NodeList.vue';
import { useI18n } from '@/composables/useI18n';
import type { ITemplatesCollection } from '@/Interface';
export default defineComponent({
name: 'TemplatesInfoCard',
components: {
Card,
NodeList,
withDefaults(
defineProps<{
collection: ITemplatesCollection;
loading?: boolean;
showItemCount?: boolean;
width: string;
}>(),
{
loading: false,
showItemCount: true,
},
props: {
collection: {
type: Object as PropType<ITemplatesCollection>,
required: true,
},
loading: {
type: Boolean,
},
showItemCount: {
type: Boolean,
default: true,
},
width: {
type: String,
required: true,
},
},
});
);
const i18n = useI18n();
</script>
<template>
@ -36,7 +26,7 @@ export default defineComponent({
<span>
<n8n-text v-show="showItemCount" size="small" color="text-light">
{{ collection.workflows.length }}
{{ $locale.baseText('templates.workflows') }}
{{ i18n.baseText('templates.workflows') }}
</n8n-text>
</span>
<NodeList :nodes="collection.nodes" :show-more="false" />

View file

@ -1991,6 +1991,7 @@
"template.details.details": "Details",
"template.details.times": "times",
"template.details.viewed": "Viewed",
"template.byAuthor": "By {name}",
"templates.allCategories": "All Categories",
"templates.categoriesHeading": "Categories",
"templates.collection": "Collection",

View file

@ -1,142 +1,122 @@
<script lang="ts">
import { defineComponent } from 'vue';
import type { ApiKey, IUser } from '@/Interface';
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import type { ApiKey } from '@/Interface';
import { useToast } from '@/composables/useToast';
import { useMessage } from '@/composables/useMessage';
import { useDocumentTitle } from '@/composables/useDocumentTitle';
import CopyInput from '@/components/CopyInput.vue';
import { mapStores } from 'pinia';
import { useSettingsStore } from '@/stores/settings.store';
import { useRootStore } from '@/stores/root.store';
import { useUIStore } from '@/stores/ui.store';
import { useUsersStore } from '@/stores/users.store';
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import { DOCS_DOMAIN, MODAL_CONFIRM } from '@/constants';
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
export default defineComponent({
name: 'SettingsApiView',
components: {
CopyInput,
},
setup() {
return {
...useToast(),
...useMessage(),
...useUIStore(),
pageRedirectionHelper: usePageRedirectionHelper(),
documentTitle: useDocumentTitle(),
};
},
data() {
return {
loading: false,
mounted: false,
apiKeys: [] as ApiKey[],
swaggerUIEnabled: false,
apiDocsURL: '',
};
},
mounted() {
this.documentTitle.set(this.$locale.baseText('settings.api'));
if (!this.isPublicApiEnabled) return;
const settingsStore = useSettingsStore();
const cloudPlanStore = useCloudPlanStore();
const { baseUrl } = useRootStore();
void this.getApiKeys();
const baseUrl = this.rootStore.baseUrl;
const apiPath = this.settingsStore.publicApiPath;
const latestVersion = this.settingsStore.publicApiLatestVersion;
this.swaggerUIEnabled = this.settingsStore.isSwaggerUIEnabled;
this.apiDocsURL = this.swaggerUIEnabled
? `${baseUrl}${apiPath}/v${latestVersion}/docs`
: `https://${DOCS_DOMAIN}/api/api-reference/`;
},
computed: {
...mapStores(useRootStore, useSettingsStore, useUsersStore, useCloudPlanStore, useUIStore),
currentUser(): IUser | null {
return this.usersStore.currentUser;
},
isTrialing(): boolean {
return this.cloudPlanStore.userIsTrialing;
},
isLoadingCloudPlans(): boolean {
return this.cloudPlanStore.state.loadingPlan;
},
isPublicApiEnabled(): boolean {
return this.settingsStore.isPublicApiEnabled;
},
isRedactedApiKey(): boolean {
if (!this.apiKeys) return false;
return this.apiKeys[0].apiKey.includes('*');
},
},
methods: {
onUpgrade() {
void this.pageRedirectionHelper.goToUpgrade('settings-n8n-api', 'upgrade-api', 'redirect');
},
async showDeleteModal() {
const confirmed = await this.confirm(
this.$locale.baseText('settings.api.delete.description'),
this.$locale.baseText('settings.api.delete.title'),
{
confirmButtonText: this.$locale.baseText('settings.api.delete.button'),
cancelButtonText: this.$locale.baseText('generic.cancel'),
},
);
if (confirmed === MODAL_CONFIRM) {
await this.deleteApiKey();
}
},
async getApiKeys() {
try {
this.apiKeys = await this.settingsStore.getApiKeys();
} catch (error) {
this.showError(error, this.$locale.baseText('settings.api.view.error'));
} finally {
this.mounted = true;
}
},
async createApiKey() {
this.loading = true;
const { showError, showMessage } = useToast();
const { confirm } = useMessage();
const documentTitle = useDocumentTitle();
const i18n = useI18n();
const { goToUpgrade } = usePageRedirectionHelper();
const telemetry = useTelemetry();
try {
const newApiKey = await this.settingsStore.createApiKey();
this.apiKeys.push(newApiKey);
} catch (error) {
this.showError(error, this.$locale.baseText('settings.api.create.error'));
} finally {
this.loading = false;
this.$telemetry.track('User clicked create API key button');
}
},
async deleteApiKey() {
try {
await this.settingsStore.deleteApiKey(this.apiKeys[0].id);
this.showMessage({
title: this.$locale.baseText('settings.api.delete.toast'),
type: 'success',
});
this.apiKeys = [];
} catch (error) {
this.showError(error, this.$locale.baseText('settings.api.delete.error'));
} finally {
this.$telemetry.track('User clicked delete API key button');
}
},
onCopy() {
this.$telemetry.track('User clicked copy API key button');
},
},
const loading = ref(false);
const mounted = ref(false);
const apiKeys = ref<ApiKey[]>([]);
const apiDocsURL = ref('');
const { isPublicApiEnabled, isSwaggerUIEnabled, publicApiPath, publicApiLatestVersion } =
settingsStore;
const isRedactedApiKey = computed((): boolean => {
if (!apiKeys.value) return false;
return apiKeys.value[0].apiKey.includes('*');
});
onMounted(() => {
documentTitle.set(i18n.baseText('settings.api'));
if (!isPublicApiEnabled) return;
void getApiKeys();
apiDocsURL.value = isSwaggerUIEnabled
? `${baseUrl}${publicApiPath}/v${publicApiLatestVersion}/docs`
: `https://${DOCS_DOMAIN}/api/api-reference/`;
});
function onUpgrade() {
void goToUpgrade('settings-n8n-api', 'upgrade-api', 'redirect');
}
async function showDeleteModal() {
const confirmed = await confirm(
i18n.baseText('settings.api.delete.description'),
i18n.baseText('settings.api.delete.title'),
{
confirmButtonText: i18n.baseText('settings.api.delete.button'),
cancelButtonText: i18n.baseText('generic.cancel'),
},
);
if (confirmed === MODAL_CONFIRM) {
await deleteApiKey();
}
}
async function getApiKeys() {
try {
apiKeys.value = await settingsStore.getApiKeys();
} catch (error) {
showError(error, i18n.baseText('settings.api.view.error'));
} finally {
mounted.value = true;
}
}
async function createApiKey() {
loading.value = true;
try {
const newApiKey = await settingsStore.createApiKey();
apiKeys.value.push(newApiKey);
} catch (error) {
showError(error, i18n.baseText('settings.api.create.error'));
} finally {
loading.value = false;
telemetry.track('User clicked create API key button');
}
}
async function deleteApiKey() {
try {
await settingsStore.deleteApiKey(apiKeys.value[0].id);
showMessage({
title: i18n.baseText('settings.api.delete.toast'),
type: 'success',
});
apiKeys.value = [];
} catch (error) {
showError(error, i18n.baseText('settings.api.delete.error'));
} finally {
telemetry.track('User clicked delete API key button');
}
}
function onCopy() {
telemetry.track('User clicked copy API key button');
}
</script>
<template>
<div :class="$style.container">
<div :class="$style.header">
<n8n-heading size="2xlarge">
{{ $locale.baseText('settings.api') }}
{{ i18n.baseText('settings.api') }}
<span :style="{ fontSize: 'var(--font-size-s)', color: 'var(--color-text-light)' }">
({{ $locale.baseText('generic.beta') }})
({{ i18n.baseText('generic.beta') }})
</span>
</n8n-heading>
</div>
@ -149,14 +129,14 @@ export default defineComponent({
<a
href="https://docs.n8n.io/api"
target="_blank"
v-text="$locale.baseText('settings.api.view.info.api')"
v-text="i18n.baseText('settings.api.view.info.api')"
/>
</template>
<template #webhookAction>
<a
href="https://docs.n8n.io/integrations/core-nodes/n8n-nodes-base.webhook/"
target="_blank"
v-text="$locale.baseText('settings.api.view.info.webhook')"
v-text="i18n.baseText('settings.api.view.info.webhook')"
/>
</template>
</i18n-t>
@ -165,7 +145,7 @@ export default defineComponent({
<n8n-card class="mb-4xs" :class="$style.card">
<span :class="$style.delete">
<n8n-link :bold="true" @click="showDeleteModal">
{{ $locale.baseText('generic.delete') }}
{{ i18n.baseText('generic.delete') }}
</n8n-link>
</span>
@ -173,47 +153,43 @@ export default defineComponent({
<CopyInput
:label="apiKeys[0].label"
:value="apiKeys[0].apiKey"
:copy-button-text="$locale.baseText('generic.clickToCopy')"
:toast-title="$locale.baseText('settings.api.view.copy.toast')"
:copy-button-text="i18n.baseText('generic.clickToCopy')"
:toast-title="i18n.baseText('settings.api.view.copy.toast')"
:redact-value="true"
:disable-copy="isRedactedApiKey"
:hint="!isRedactedApiKey ? $locale.baseText('settings.api.view.copy') : ''"
:hint="!isRedactedApiKey ? i18n.baseText('settings.api.view.copy') : ''"
@copy="onCopy"
/>
</div>
</n8n-card>
<div :class="$style.hint">
<n8n-text size="small">
{{
$locale.baseText(`settings.api.view.${swaggerUIEnabled ? 'tryapi' : 'more-details'}`)
}}
{{ i18n.baseText(`settings.api.view.${isSwaggerUIEnabled ? 'tryapi' : 'more-details'}`) }}
</n8n-text>
{{ ' ' }}
<n8n-link :to="apiDocsURL" :new-window="true" size="small">
{{
$locale.baseText(
`settings.api.view.${swaggerUIEnabled ? 'apiPlayground' : 'external-docs'}`,
i18n.baseText(
`settings.api.view.${isSwaggerUIEnabled ? 'apiPlayground' : 'external-docs'}`,
)
}}
</n8n-link>
</div>
</div>
<n8n-action-box
v-else-if="!isPublicApiEnabled && isTrialing"
v-else-if="!isPublicApiEnabled && cloudPlanStore.userIsTrialing"
data-test-id="public-api-upgrade-cta"
:heading="$locale.baseText('settings.api.trial.upgradePlan.title')"
:description="$locale.baseText('settings.api.trial.upgradePlan.description')"
:button-text="$locale.baseText('settings.api.trial.upgradePlan.cta')"
:heading="i18n.baseText('settings.api.trial.upgradePlan.title')"
:description="i18n.baseText('settings.api.trial.upgradePlan.description')"
:button-text="i18n.baseText('settings.api.trial.upgradePlan.cta')"
@click:button="onUpgrade"
/>
<n8n-action-box
v-else-if="mounted && !isLoadingCloudPlans"
v-else-if="mounted && !cloudPlanStore.state.loadingPlan"
:button-text="
$locale.baseText(
loading ? 'settings.api.create.button.loading' : 'settings.api.create.button',
)
i18n.baseText(loading ? 'settings.api.create.button.loading' : 'settings.api.create.button')
"
:description="$locale.baseText('settings.api.create.description')"
:description="i18n.baseText('settings.api.create.description')"
@click:button="createApiKey"
/>
</div>

View file

@ -1,6 +1,5 @@
<script lang="ts">
import { defineComponent, nextTick } from 'vue';
import { mapStores } from 'pinia';
<script lang="ts" setup>
import { computed, nextTick, onBeforeMount, onMounted } from 'vue';
import { v4 as uuid } from 'uuid';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { hasPermission } from '@/utils/rbac/permissions';
@ -14,154 +13,158 @@ import { deepCopy, defaultMessageEventBusDestinationOptions } from 'n8n-workflow
import EventDestinationCard from '@/components/SettingsLogStreaming/EventDestinationCard.ee.vue';
import { createEventBus } from 'n8n-design-system/utils';
import { useDocumentTitle } from '@/composables/useDocumentTitle';
import { ref, getCurrentInstance } from 'vue';
import { useI18n } from '@/composables/useI18n';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
export default defineComponent({
name: 'SettingsLogStreamingView',
components: {
EventDestinationCard,
},
props: {},
data() {
return {
eventBus: createEventBus(),
destinations: Array<MessageEventBusDestinationOptions>,
disableLicense: false,
allDestinations: [] as MessageEventBusDestinationOptions[],
documentTitle: useDocumentTitle(),
pageRedirectionHelper: usePageRedirectionHelper(),
};
},
async mounted() {
this.documentTitle.set(this.$locale.baseText('settings.log-streaming.heading'));
if (!this.isLicensed) return;
const environment = process.env.NODE_ENV;
// Prepare credentialsStore so modals can pick up credentials
await this.credentialsStore.fetchCredentialTypes(false);
await this.credentialsStore.fetchAllCredentials();
this.uiStore.nodeViewInitialized = false;
const settingsStore = useSettingsStore();
const logStreamingStore = useLogStreamingStore();
const workflowsStore = useWorkflowsStore();
const uiStore = useUIStore();
const credentialsStore = useCredentialsStore();
const documentTitle = useDocumentTitle();
const i18n = useI18n();
// fetch Destination data from the backend
await this.getDestinationDataFromBackend();
const pageRedirectHelper = usePageRedirectionHelper();
// since we are not really integrated into the hooks, we listen to the store and refresh the destinations
this.logStreamingStore.$onAction(({ name, after }) => {
if (name === 'removeDestination' || name === 'updateDestination') {
after(async () => {
this.$forceUpdate();
});
}
});
// refresh when a modal closes
this.eventBus.on('destinationWasSaved', this.onDestinationWasSaved);
// listen to remove emission
this.eventBus.on('remove', this.onRemove);
// listen to modal closing and remove nodes from store
this.eventBus.on('closing', this.onBusClosing);
},
beforeUnmount() {
this.eventBus.off('destinationWasSaved', this.onDestinationWasSaved);
this.eventBus.off('remove', this.onRemove);
this.eventBus.off('closing', this.onBusClosing);
},
computed: {
...mapStores(
useSettingsStore,
useLogStreamingStore,
useWorkflowsStore,
useUIStore,
useCredentialsStore,
),
sortedItemKeysByLabel() {
const sortedKeys: Array<{ label: string; key: string }> = [];
for (const [key, value] of Object.entries(this.logStreamingStore.items)) {
sortedKeys.push({ key, label: value.destination?.label ?? 'Destination' });
}
return sortedKeys.sort((a, b) => a.label.localeCompare(b.label));
},
environment() {
return process.env.NODE_ENV;
},
isLicensed(): boolean {
if (this.disableLicense) return false;
return this.settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.LogStreaming];
},
canManageLogStreaming(): boolean {
return hasPermission(['rbac'], { rbac: { scope: 'logStreaming:manage' } });
},
},
methods: {
onDestinationWasSaved() {
this.$forceUpdate();
},
onBusClosing() {
this.workflowsStore.removeAllNodes({ setStateDirty: false, removePinData: true });
this.uiStore.stateIsDirty = false;
},
async getDestinationDataFromBackend(): Promise<void> {
this.logStreamingStore.clearEventNames();
this.logStreamingStore.clearDestinations();
this.allDestinations = [];
const eventNamesData = await this.logStreamingStore.fetchEventNames();
if (eventNamesData) {
for (const eventName of eventNamesData) {
this.logStreamingStore.addEventName(eventName);
}
}
const destinationData: MessageEventBusDestinationOptions[] =
await this.logStreamingStore.fetchDestinations();
if (destinationData) {
for (const destination of destinationData) {
this.logStreamingStore.addDestination(destination);
this.allDestinations.push(destination);
}
}
this.$forceUpdate();
},
goToUpgrade() {
void this.pageRedirectionHelper.goToUpgrade('log-streaming', 'upgrade-log-streaming');
},
storeHasItems(): boolean {
return this.logStreamingStore.items && Object.keys(this.logStreamingStore.items).length > 0;
},
async addDestination() {
const newDestination = deepCopy(defaultMessageEventBusDestinationOptions);
newDestination.id = uuid();
this.logStreamingStore.addDestination(newDestination);
await nextTick();
this.uiStore.openModalWithData({
name: LOG_STREAM_MODAL_KEY,
data: {
destination: newDestination,
isNew: true,
eventBus: this.eventBus,
},
});
},
async onRemove(destinationId?: string) {
if (!destinationId) return;
await this.logStreamingStore.deleteDestination(destinationId);
const foundNode = this.workflowsStore.getNodeByName(destinationId);
if (foundNode) {
this.workflowsStore.removeNode(foundNode);
}
},
async onEdit(destinationId?: string) {
if (!destinationId) return;
const editDestination = this.logStreamingStore.getDestination(destinationId);
if (editDestination) {
this.uiStore.openModalWithData({
name: LOG_STREAM_MODAL_KEY,
data: {
destination: editDestination,
isNew: false,
eventBus: this.eventBus,
},
});
}
},
},
const eventBus = createEventBus();
const disableLicense = ref(false);
const allDestinations = ref<MessageEventBusDestinationOptions[]>([]);
const sortedItemKeysByLabel = computed(() => {
const sortedKeys: Array<{ label: string; key: string }> = [];
for (const [key, value] of Object.entries(logStreamingStore.items)) {
sortedKeys.push({ key, label: value.destination?.label ?? 'Destination' });
}
return sortedKeys.sort((a, b) => a.label.localeCompare(b.label));
});
const isLicensed = computed((): boolean => {
if (disableLicense.value) return false;
return settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.LogStreaming];
});
const canManageLogStreaming = computed((): boolean => {
return hasPermission(['rbac'], { rbac: { scope: 'logStreaming:manage' } });
});
onMounted(async () => {
documentTitle.set(i18n.baseText('settings.log-streaming.heading'));
if (!isLicensed.value) return;
// Prepare credentialsStore so modals can pick up credentials
await credentialsStore.fetchCredentialTypes(false);
await credentialsStore.fetchAllCredentials();
uiStore.nodeViewInitialized = false;
// fetch Destination data from the backend
await getDestinationDataFromBackend();
// since we are not really integrated into the hooks, we listen to the store and refresh the destinations
logStreamingStore.$onAction(({ name, after }) => {
if (name === 'removeDestination' || name === 'updateDestination') {
after(async () => {
forceUpdateInstance();
});
}
});
// refresh when a modal closes
eventBus.on('destinationWasSaved', onDestinationWasSaved);
// listen to remove emission
eventBus.on('remove', onRemove);
// listen to modal closing and remove nodes from store
eventBus.on('closing', onBusClosing);
});
onBeforeMount(() => {
eventBus.off('destinationWasSaved', onDestinationWasSaved);
eventBus.off('remove', onRemove);
eventBus.off('closing', onBusClosing);
});
function onDestinationWasSaved() {
forceUpdateInstance();
}
function forceUpdateInstance() {
const instance = getCurrentInstance();
instance?.proxy?.$forceUpdate();
}
function onBusClosing() {
workflowsStore.removeAllNodes({ setStateDirty: false, removePinData: true });
uiStore.stateIsDirty = false;
}
async function getDestinationDataFromBackend(): Promise<void> {
logStreamingStore.clearEventNames();
logStreamingStore.clearDestinations();
allDestinations.value = [];
const eventNamesData = await logStreamingStore.fetchEventNames();
if (eventNamesData) {
for (const eventName of eventNamesData) {
logStreamingStore.addEventName(eventName);
}
}
const destinationData: MessageEventBusDestinationOptions[] =
await logStreamingStore.fetchDestinations();
if (destinationData) {
for (const destination of destinationData) {
logStreamingStore.addDestination(destination);
allDestinations.value.push(destination);
}
}
forceUpdateInstance();
}
function goToUpgrade() {
void pageRedirectHelper.goToUpgrade('log-streaming', 'upgrade-log-streaming');
}
function storeHasItems(): boolean {
return logStreamingStore.items && Object.keys(logStreamingStore.items).length > 0;
}
async function addDestination() {
const newDestination = deepCopy(defaultMessageEventBusDestinationOptions);
newDestination.id = uuid();
logStreamingStore.addDestination(newDestination);
await nextTick();
uiStore.openModalWithData({
name: LOG_STREAM_MODAL_KEY,
data: {
destination: newDestination,
isNew: true,
eventBus,
},
});
}
async function onRemove(destinationId?: string) {
if (!destinationId) return;
await logStreamingStore.deleteDestination(destinationId);
const foundNode = workflowsStore.getNodeByName(destinationId);
if (foundNode) {
workflowsStore.removeNode(foundNode);
}
}
async function onEdit(destinationId?: string) {
if (!destinationId) return;
const editDestination = logStreamingStore.getDestination(destinationId);
if (editDestination) {
uiStore.openModalWithData({
name: LOG_STREAM_MODAL_KEY,
data: {
destination: editDestination,
isNew: false,
eventBus,
},
});
}
}
</script>
<template>
@ -169,7 +172,7 @@ export default defineComponent({
<div :class="$style.header">
<div class="mb-2xl">
<n8n-heading size="2xlarge">
{{ $locale.baseText(`settings.log-streaming.heading`) }}
{{ i18n.baseText(`settings.log-streaming.heading`) }}
</n8n-heading>
<template v-if="environment !== 'production'">
<strong class="ml-m">Disable License ({{ environment }})&nbsp;</strong>
@ -180,7 +183,7 @@ export default defineComponent({
<template v-if="isLicensed">
<div class="mb-l">
<n8n-info-tip theme="info" type="note">
<span v-n8n-html="$locale.baseText('settings.log-streaming.infoText')"></span>
<span v-n8n-html="i18n.baseText('settings.log-streaming.infoText')"></span>
</n8n-info-tip>
</div>
<template v-if="storeHasItems()">
@ -202,35 +205,35 @@ export default defineComponent({
</el-row>
<div class="mt-m text-right">
<n8n-button v-if="canManageLogStreaming" size="large" @click="addDestination">
{{ $locale.baseText(`settings.log-streaming.add`) }}
{{ i18n.baseText(`settings.log-streaming.add`) }}
</n8n-button>
</div>
</template>
<div v-else data-test-id="action-box-licensed">
<n8n-action-box
:button-text="$locale.baseText(`settings.log-streaming.add`)"
:button-text="i18n.baseText(`settings.log-streaming.add`)"
@click:button="addDestination"
>
<template #heading>
<span v-n8n-html="$locale.baseText(`settings.log-streaming.addFirstTitle`)" />
<span v-n8n-html="i18n.baseText(`settings.log-streaming.addFirstTitle`)" />
</template>
</n8n-action-box>
</div>
</template>
<template v-else>
<div v-if="$locale.baseText('settings.log-streaming.infoText')" class="mb-l">
<div v-if="i18n.baseText('settings.log-streaming.infoText')" class="mb-l">
<n8n-info-tip theme="info" type="note">
<span v-n8n-html="$locale.baseText('settings.log-streaming.infoText')"></span>
<span v-n8n-html="i18n.baseText('settings.log-streaming.infoText')"></span>
</n8n-info-tip>
</div>
<div data-test-id="action-box-unlicensed">
<n8n-action-box
:description="$locale.baseText('settings.log-streaming.actionBox.description')"
:button-text="$locale.baseText('settings.log-streaming.actionBox.button')"
:description="i18n.baseText('settings.log-streaming.actionBox.description')"
:button-text="i18n.baseText('settings.log-streaming.actionBox.button')"
@click:button="goToUpgrade"
>
<template #heading>
<span v-n8n-html="$locale.baseText('settings.log-streaming.actionBox.title')" />
<span v-n8n-html="i18n.baseText('settings.log-streaming.actionBox.title')" />
</template>
</n8n-action-box>
</div>