feat(editor): Add lead enrichment suggestions to workflow list (#8042)
## Summary We want to show lead enrichment template suggestions to cloud users that agreed to this. This PR introduces the front-end part of this feature - Handoff document - Figma Hi-fi - [How to test](https://linear.app/n8n/issue/ADO-1549/[n8n-fe]-update-workflows-list-page-to-show-fake-door-templates#comment-b6644c99) Tests are being worked on in a separate PR ## Related tickets and issues Fixes ADO-1546 Fixes ADO-1549 Fixes ADO-1604 ## Review / Merge checklist - [ ] PR title and summary are descriptive. **Remember, the title automatically goes into the changelog. Use `(no-changelog)` otherwise.** ([conventions](https://github.com/n8n-io/n8n/blob/master/.github/pull_request_title_conventions.md)) - [ ] [Docs updated](https://github.com/n8n-io/n8n-docs) or follow-up ticket created. - [ ] Tests included. > A bug is not considered fixed, unless a test is added to prevent it from happening again. > A feature is not complete without tests. --------- Co-authored-by: ricardo <ricardoespinoza105@gmail.com>
This commit is contained in:
committed by
GitHub
parent
c170dd1da3
commit
36a923cf7b
@@ -30,7 +30,7 @@ export default defineComponent({
|
||||
|
||||
<style lang="scss" module>
|
||||
.card {
|
||||
width: 240px !important;
|
||||
min-width: 180px;
|
||||
height: 140px;
|
||||
margin-right: var(--spacing-2xs);
|
||||
cursor: pointer;
|
||||
|
||||
@@ -149,6 +149,15 @@
|
||||
/>
|
||||
</template>
|
||||
</ModalRoot>
|
||||
<ModalRoot :name="SUGGESTED_TEMPLATES_PREVIEW_MODAL_KEY">
|
||||
<template #default="{ modalName, data }">
|
||||
<SuggestedTemplatesPreviewModal
|
||||
data-test-id="suggested-templates-preview-modal"
|
||||
:modalName="modalName"
|
||||
:data="data"
|
||||
/>
|
||||
</template>
|
||||
</ModalRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -183,6 +192,7 @@ import {
|
||||
DEBUG_PAYWALL_MODAL_KEY,
|
||||
MFA_SETUP_MODAL_KEY,
|
||||
WORKFLOW_HISTORY_VERSION_RESTORE,
|
||||
SUGGESTED_TEMPLATES_PREVIEW_MODAL_KEY,
|
||||
} from '@/constants';
|
||||
|
||||
import AboutModal from './AboutModal.vue';
|
||||
@@ -214,6 +224,7 @@ import SourceControlPullModal from '@/components/SourceControlPullModal.ee.vue';
|
||||
import ExternalSecretsProviderModal from '@/components/ExternalSecretsProviderModal.ee.vue';
|
||||
import DebugPaywallModal from '@/components/DebugPaywallModal.vue';
|
||||
import WorkflowHistoryVersionRestoreModal from '@/components/WorkflowHistory/WorkflowHistoryVersionRestoreModal.vue';
|
||||
import SuggestedTemplatesPreviewModal from '@/components/SuggestedTemplates/SuggestedTemplatesPreviewModal.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Modals',
|
||||
@@ -247,6 +258,7 @@ export default defineComponent({
|
||||
DebugPaywallModal,
|
||||
MfaSetupModal,
|
||||
WorkflowHistoryVersionRestoreModal,
|
||||
SuggestedTemplatesPreviewModal,
|
||||
},
|
||||
data: () => ({
|
||||
CHAT_EMBED_MODAL_KEY,
|
||||
@@ -277,6 +289,7 @@ export default defineComponent({
|
||||
DEBUG_PAYWALL_MODAL_KEY,
|
||||
MFA_SETUP_MODAL_KEY,
|
||||
WORKFLOW_HISTORY_VERSION_RESTORE,
|
||||
SUGGESTED_TEMPLATES_PREVIEW_MODAL_KEY,
|
||||
}),
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
v-if="!data.disabled"
|
||||
:class="{ 'node-info-icon': true, 'shift-icon': shiftOutputCount }"
|
||||
>
|
||||
<div v-if="hasIssues" class="node-issues" data-test-id="node-issues">
|
||||
<div v-if="hasIssues && !hideNodeIssues" class="node-issues" data-test-id="node-issues">
|
||||
<n8n-tooltip :show-after="500" placement="bottom">
|
||||
<template #content>
|
||||
<titled-list :title="`${$locale.baseText('node.issues')}:`" :items="nodeIssues" />
|
||||
@@ -212,6 +212,10 @@ export default defineComponent({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hideNodeIssues: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useNodeTypesStore, useNDVStore, useUIStore, useWorkflowsStore),
|
||||
@@ -487,7 +491,7 @@ export default defineComponent({
|
||||
if (this.data.disabled) {
|
||||
borderColor = '--color-foreground-base';
|
||||
} else if (!this.isExecuting) {
|
||||
if (this.hasIssues) {
|
||||
if (this.hasIssues && !this.hideNodeIssues) {
|
||||
// Do not set red border if there is an issue with the configuration node
|
||||
if (
|
||||
(this.nodeRunData?.[0]?.error as NodeOperationError)?.functionality !==
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router';
|
||||
import { computed } from 'vue';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { VIEWS } from '@/constants';
|
||||
import type { ITemplatesCollection } from '@/Interface';
|
||||
import SuggestedTemplatesSection from '@/components/SuggestedTemplates/SuggestedTemplatesSection.vue';
|
||||
import type { IUser } from '@/Interface';
|
||||
|
||||
const usersStore = useUsersStore();
|
||||
const uiStore = useUIStore();
|
||||
const router = useRouter();
|
||||
|
||||
const currentUser = computed(() => usersStore.currentUser);
|
||||
|
||||
const upperCaseFirstName = (user: IUser | null) => {
|
||||
if (!user || !user.firstName) return;
|
||||
return user.firstName?.charAt(0)?.toUpperCase() + user?.firstName?.slice(1);
|
||||
};
|
||||
|
||||
const defaultSection = computed(() => {
|
||||
if (!uiStore.suggestedTemplates) {
|
||||
return null;
|
||||
}
|
||||
return uiStore.suggestedTemplates.sections[0];
|
||||
});
|
||||
|
||||
const suggestedTemplates = computed(() => {
|
||||
const carouselCollections = Array<ITemplatesCollection>();
|
||||
if (!uiStore.suggestedTemplates || !defaultSection.value) {
|
||||
return carouselCollections;
|
||||
}
|
||||
defaultSection.value.workflows.forEach((workflow, index) => {
|
||||
carouselCollections.push({
|
||||
id: index,
|
||||
name: workflow.title,
|
||||
workflows: [{ id: index }],
|
||||
nodes: workflow.nodes,
|
||||
});
|
||||
});
|
||||
return carouselCollections;
|
||||
});
|
||||
|
||||
function openCanvas() {
|
||||
uiStore.nodeViewInitialized = false;
|
||||
void router.push({ name: VIEWS.NEW_WORKFLOW });
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
currentUser,
|
||||
openCanvas,
|
||||
suggestedTemplates,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container" data-test-id="suggested-templates-page-container">
|
||||
<div :class="$style.header">
|
||||
<n8n-heading tag="h1" size="2xlarge" class="mb-2xs">
|
||||
{{
|
||||
$locale.baseText('suggestedTemplates.heading', {
|
||||
interpolate: {
|
||||
name: upperCaseFirstName(currentUser) || $locale.baseText('generic.welcome'),
|
||||
},
|
||||
})
|
||||
}}
|
||||
</n8n-heading>
|
||||
<n8n-text
|
||||
size="large"
|
||||
color="text-base"
|
||||
data-test-id="suggested-template-section-description"
|
||||
>
|
||||
{{ defaultSection?.description }}
|
||||
</n8n-text>
|
||||
</div>
|
||||
<div :class="$style.content">
|
||||
<suggested-templates-section
|
||||
v-for="section in uiStore.suggestedTemplates?.sections"
|
||||
:key="section.title"
|
||||
:section="section"
|
||||
:showTitle="false"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<n8n-button
|
||||
:label="$locale.baseText('suggestedTemplates.newWorkflowButton')"
|
||||
type="secondary"
|
||||
size="medium"
|
||||
icon="plus"
|
||||
data-test-id="suggested-templates-new-workflow-button"
|
||||
@click="openCanvas"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-l);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: var(--spacing-l);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script lang="ts" setup>
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import {
|
||||
SUGGESTED_TEMPLATES_FLAG,
|
||||
SUGGESTED_TEMPLATES_PREVIEW_MODAL_KEY,
|
||||
VIEWS,
|
||||
} from '@/constants';
|
||||
import type { IWorkflowDb, SuggestedTemplatesWorkflowPreview } from '@/Interface';
|
||||
import Modal from '@/components/Modal.vue';
|
||||
import WorkflowPreview from '@/components/WorkflowPreview.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
modalName: string;
|
||||
data: {
|
||||
workflow: SuggestedTemplatesWorkflowPreview;
|
||||
};
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const router = useRouter();
|
||||
const uiStore = useUIStore();
|
||||
const usersStore = useUsersStore();
|
||||
const toast = useToast();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
function showConfirmationMessage(event: PointerEvent) {
|
||||
if (event.target instanceof HTMLAnchorElement) {
|
||||
event.preventDefault();
|
||||
// @ts-expect-error Additional parameters are not necessary for this function
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('suggestedTemplates.notification.confirmation.title'),
|
||||
message: i18n.baseText('suggestedTemplates.notification.confirmation.message'),
|
||||
type: 'success',
|
||||
});
|
||||
telemetry.track(
|
||||
'User wants to be notified once template is ready',
|
||||
{ templateName: props.data.workflow.title, email: usersStore.currentUser?.email },
|
||||
{
|
||||
withPostHog: true,
|
||||
},
|
||||
);
|
||||
localStorage.setItem(SUGGESTED_TEMPLATES_FLAG, 'false');
|
||||
uiStore.deleteSuggestedTemplates();
|
||||
}
|
||||
}
|
||||
|
||||
function openCanvas() {
|
||||
uiStore.setNotificationsForView(VIEWS.WORKFLOW, [
|
||||
{
|
||||
title: i18n.baseText('suggestedTemplates.notification.comingSoon.title'),
|
||||
message: i18n.baseText('suggestedTemplates.notification.comingSoon.message'),
|
||||
type: 'info',
|
||||
onClick: showConfirmationMessage,
|
||||
},
|
||||
]);
|
||||
uiStore.closeModal(SUGGESTED_TEMPLATES_PREVIEW_MODAL_KEY);
|
||||
uiStore.nodeViewInitialized = false;
|
||||
void router.push({ name: VIEWS.NEW_WORKFLOW });
|
||||
telemetry.track(
|
||||
'User clicked Use Template button',
|
||||
{ templateName: props.data.workflow.title },
|
||||
{ withPostHog: true },
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal width="900px" height="640px" :name="props.modalName">
|
||||
<template #header>
|
||||
<n8n-heading tag="h2" size="xlarge">
|
||||
{{ $props.data.workflow.title }}
|
||||
</n8n-heading>
|
||||
</template>
|
||||
<template #content>
|
||||
<workflow-preview
|
||||
:loading="false"
|
||||
:workflow="$props.data.workflow.preview as IWorkflowDb"
|
||||
:canOpenNDV="false"
|
||||
:hideNodeIssues="true"
|
||||
@close="uiStore.closeModal(SUGGESTED_TEMPLATES_PREVIEW_MODAL_KEY)"
|
||||
/>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div>
|
||||
<n8n-text> {{ $props.data.workflow.description }} </n8n-text>
|
||||
</div>
|
||||
<div :class="$style.footerButtons">
|
||||
<n8n-button
|
||||
@click="openCanvas"
|
||||
float="right"
|
||||
data-test-id="use-template-button"
|
||||
:label="$locale.baseText('suggestedTemplates.modal.button.label')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.footerButtons {
|
||||
margin-top: var(--spacing-xl);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { type PropType, computed } from 'vue';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import type { ITemplatesCollection, ITemplatesNode, SuggestedTemplatesSection } from '@/Interface';
|
||||
import TemplatesInfoCarousel from '@/components/TemplatesInfoCarousel.vue';
|
||||
import { SUGGESTED_TEMPLATES_PREVIEW_MODAL_KEY } from '@/constants';
|
||||
|
||||
const uiStore = useUIStore();
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
const props = defineProps({
|
||||
section: {
|
||||
type: Object as PropType<SuggestedTemplatesSection>,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String as PropType<string>,
|
||||
required: false,
|
||||
},
|
||||
showTitle: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const sectionTemplates = computed(() => {
|
||||
const carouselCollections = Array<ITemplatesCollection>();
|
||||
if (!uiStore.suggestedTemplates) {
|
||||
return carouselCollections;
|
||||
}
|
||||
props.section.workflows.forEach((workflow, index) => {
|
||||
carouselCollections.push({
|
||||
id: index,
|
||||
name: workflow.title,
|
||||
workflows: [{ id: index }],
|
||||
nodes: workflow.nodes as ITemplatesNode[],
|
||||
});
|
||||
});
|
||||
return carouselCollections;
|
||||
});
|
||||
|
||||
function onOpenCollection({ id }: { event: Event; id: number }) {
|
||||
uiStore.openModalWithData({
|
||||
name: SUGGESTED_TEMPLATES_PREVIEW_MODAL_KEY,
|
||||
data: { workflow: props.section.workflows[id] },
|
||||
});
|
||||
telemetry.track(
|
||||
'User clicked template recommendation',
|
||||
{ templateName: props.section.workflows[id].title },
|
||||
{ withPostHog: true },
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container" data-test-id="suggested-templates-section-container">
|
||||
<div v-if="showTitle" :class="$style.header">
|
||||
<n8n-text size="large" color="text-base" :bold="true">
|
||||
{{ props.title ?? section.title }}
|
||||
</n8n-text>
|
||||
</div>
|
||||
<div :class="$style.content">
|
||||
<templates-info-carousel
|
||||
:collections="sectionTemplates"
|
||||
:loading="false"
|
||||
:showItemCount="false"
|
||||
:showNavigation="false"
|
||||
cardsWidth="24%"
|
||||
@openCollection="onOpenCollection"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-l);
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<Card :loading="loading" :title="collection.name">
|
||||
<Card :loading="loading" :title="collection.name" :style="{ width }">
|
||||
<template #footer>
|
||||
<span>
|
||||
<n8n-text v-show="showItemCount" size="small" color="text-light">
|
||||
@@ -32,6 +32,10 @@ export default defineComponent({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
Card,
|
||||
|
||||
@@ -11,15 +11,26 @@
|
||||
<Card v-for="n in loading ? 4 : 0" :key="`loading-${n}`" :loading="loading" />
|
||||
<TemplatesInfoCard
|
||||
v-for="collection in loading ? [] : collections"
|
||||
data-test-id="templates-info-card"
|
||||
:key="collection.id"
|
||||
:collection="collection"
|
||||
:showItemCount="showItemCount"
|
||||
:width="cardsWidth"
|
||||
@click="(e) => onCardClick(e, collection.id)"
|
||||
/>
|
||||
</agile>
|
||||
<button v-show="carouselScrollPosition > 0" :class="$style.leftButton" @click="scrollLeft">
|
||||
<button
|
||||
v-show="showNavigation && carouselScrollPosition > 0"
|
||||
:class="{ [$style.leftButton]: true }"
|
||||
@click="scrollLeft"
|
||||
>
|
||||
<font-awesome-icon icon="chevron-left" />
|
||||
</button>
|
||||
<button v-show="!scrollEnd" :class="$style.rightButton" @click="scrollRight">
|
||||
<button
|
||||
v-show="showNavigation && !scrollEnd"
|
||||
:class="{ [$style.rightButton]: true }"
|
||||
@click="scrollRight"
|
||||
>
|
||||
<font-awesome-icon icon="chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -47,6 +58,18 @@ export default defineComponent({
|
||||
loading: {
|
||||
type: Boolean,
|
||||
},
|
||||
showItemCount: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showNavigation: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
cardsWidth: {
|
||||
type: String,
|
||||
default: '240px',
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
collections() {
|
||||
@@ -68,7 +91,8 @@ export default defineComponent({
|
||||
data() {
|
||||
return {
|
||||
carouselScrollPosition: 0,
|
||||
cardWidth: 240,
|
||||
cardWidth: parseInt(this.cardsWidth, 10),
|
||||
sliderWidth: 0,
|
||||
scrollEnd: false,
|
||||
listElement: null as null | Element,
|
||||
};
|
||||
@@ -175,6 +199,7 @@ export default defineComponent({
|
||||
.rightButton {
|
||||
composes: button;
|
||||
right: -30px;
|
||||
|
||||
&:after {
|
||||
right: 27px;
|
||||
background: linear-gradient(
|
||||
@@ -204,9 +229,5 @@ export default defineComponent({
|
||||
overflow-x: auto;
|
||||
transition: all 1s ease-in-out;
|
||||
}
|
||||
|
||||
&__track {
|
||||
width: 50px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -38,12 +38,14 @@ const props = withDefaults(
|
||||
executionMode?: string;
|
||||
loaderType?: 'image' | 'spinner';
|
||||
canOpenNDV?: boolean;
|
||||
hideNodeIssues?: boolean;
|
||||
}>(),
|
||||
{
|
||||
loading: false,
|
||||
mode: 'workflow',
|
||||
loaderType: 'image',
|
||||
canOpenNDV: true,
|
||||
hideNodeIssues: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -85,6 +87,7 @@ const loadWorkflow = () => {
|
||||
command: 'openWorkflow',
|
||||
workflow: props.workflow,
|
||||
canOpenNDV: props.canOpenNDV,
|
||||
hideNodeIssues: props.hideNodeIssues,
|
||||
}),
|
||||
'*',
|
||||
);
|
||||
|
||||
@@ -101,6 +101,7 @@ describe('WorkflowPreview', () => {
|
||||
command: 'openWorkflow',
|
||||
workflow,
|
||||
canOpenNDV: true,
|
||||
hideNodeIssues: false,
|
||||
}),
|
||||
'*',
|
||||
);
|
||||
@@ -207,6 +208,7 @@ describe('WorkflowPreview', () => {
|
||||
command: 'openWorkflow',
|
||||
workflow,
|
||||
canOpenNDV: true,
|
||||
hideNodeIssues: false,
|
||||
}),
|
||||
'*',
|
||||
);
|
||||
@@ -225,10 +227,10 @@ describe('WorkflowPreview', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass the "Disable NDV" flag to using PostMessage', async () => {
|
||||
it('should pass the "Disable NDV" & "Hide issues" flags to using PostMessage', async () => {
|
||||
const nodes = [{ name: 'Start' }] as INodeUi[];
|
||||
const workflow = { nodes } as IWorkflowDb;
|
||||
const { container } = renderComponent({
|
||||
renderComponent({
|
||||
pinia,
|
||||
props: {
|
||||
workflow,
|
||||
@@ -242,6 +244,7 @@ describe('WorkflowPreview', () => {
|
||||
command: 'openWorkflow',
|
||||
workflow,
|
||||
canOpenNDV: false,
|
||||
hideNodeIssues: false,
|
||||
}),
|
||||
'*',
|
||||
);
|
||||
|
||||
@@ -134,6 +134,9 @@
|
||||
<template #default="{ item, updateItemSize }">
|
||||
<slot :data="item" :updateItemSize="updateItemSize" />
|
||||
</template>
|
||||
<template #postListContent>
|
||||
<slot name="postListContent" />
|
||||
</template>
|
||||
</n8n-recycle-scroller>
|
||||
<n8n-datatable
|
||||
v-if="typeProps.columns"
|
||||
|
||||
Reference in New Issue
Block a user