mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-27 23:07:12 +02:00
feat(editor): New simplified empty layout (#21214)
This commit is contained in:
parent
24fe8971ce
commit
705a78156a
|
|
@ -3098,7 +3098,7 @@
|
|||
"workflows.empty.preBuiltAgents": "Try a pre-built agent",
|
||||
"workflows.empty.shared-with-me": "No {resource} has been shared with you",
|
||||
"workflows.empty.shared-with-me.link": "<a href=\"#\">Back to Personal</a>",
|
||||
"workflows.empty.readyToRunV2": "Try an AI workflow",
|
||||
"workflows.empty.readyToRun": "Try an AI workflow",
|
||||
"workflows.list.easyAI": "Test the power of AI in n8n with this simple AI Agent Workflow",
|
||||
"workflows.list.error.fetching.one": "Error fetching workflow",
|
||||
"workflows.list.error.fetching": "Error fetching workflows",
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export const STORES = {
|
|||
EXPERIMENT_READY_TO_RUN_WORKFLOWS_V2: 'readyToRunWorkflowsV2',
|
||||
EXPERIMENT_TEMPLATE_RECO_V2: 'templateRecoV2',
|
||||
PERSONALIZED_TEMPLATES_V3: 'personalizedTemplatesV3',
|
||||
READY_TO_RUN: 'readyToRun',
|
||||
TEMPLATES_DATA_QUALITY: 'templatesDataQuality',
|
||||
BANNERS: 'banners',
|
||||
CONSENT: 'consent',
|
||||
|
|
|
|||
|
|
@ -9,9 +9,13 @@ import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/
|
|||
import { getResourcePermissions } from '@n8n/permissions';
|
||||
import { useProjectPages } from '@/features/collaboration/projects/composables/useProjectPages';
|
||||
import { useToast } from '@/app/composables/useToast';
|
||||
import { useReadyToRunWorkflowsV2Store } from '../stores/readyToRunWorkflowsV2.store';
|
||||
import { useReadyToRunStore } from '@/features/workflows/readyToRun/stores/readyToRun.store';
|
||||
import type { IUser } from 'n8n-workflow';
|
||||
|
||||
const emit = defineEmits<{
|
||||
'click:add': [];
|
||||
}>();
|
||||
|
||||
const route = useRoute();
|
||||
const i18n = useI18n();
|
||||
const toast = useToast();
|
||||
|
|
@ -19,7 +23,7 @@ const usersStore = useUsersStore();
|
|||
const projectsStore = useProjectsStore();
|
||||
const sourceControlStore = useSourceControlStore();
|
||||
const projectPages = useProjectPages();
|
||||
const readyToRunWorkflowsV2Store = useReadyToRunWorkflowsV2Store();
|
||||
const readyToRunStore = useReadyToRunStore();
|
||||
|
||||
const isLoadingReadyToRun = ref(false);
|
||||
|
||||
|
|
@ -43,18 +47,14 @@ const emptyListDescription = computed(() => {
|
|||
}
|
||||
});
|
||||
|
||||
const showReadyToRunV2Card = computed(() => {
|
||||
const showReadyToRunCard = computed(() => {
|
||||
return (
|
||||
isLoadingReadyToRun.value ||
|
||||
readyToRunWorkflowsV2Store.getCardVisibility(
|
||||
projectPermissions.value.workflow.create,
|
||||
readOnlyEnv.value,
|
||||
false, // loading is false in simplified layout
|
||||
)
|
||||
readyToRunStore.getCardVisibility(projectPermissions.value.workflow.create, readOnlyEnv.value)
|
||||
);
|
||||
});
|
||||
|
||||
const handleReadyToRunV2Click = async () => {
|
||||
const handleReadyToRunClick = async () => {
|
||||
if (isLoadingReadyToRun.value) return;
|
||||
|
||||
isLoadingReadyToRun.value = true;
|
||||
|
|
@ -63,7 +63,7 @@ const handleReadyToRunV2Click = async () => {
|
|||
: (route.params.projectId as string);
|
||||
|
||||
try {
|
||||
await readyToRunWorkflowsV2Store.claimCreditsAndOpenWorkflow(
|
||||
await readyToRunStore.claimCreditsAndOpenWorkflow(
|
||||
'card',
|
||||
route.params.folderId as string,
|
||||
projectId,
|
||||
|
|
@ -77,14 +77,10 @@ const handleReadyToRunV2Click = async () => {
|
|||
const addWorkflow = () => {
|
||||
emit('click:add');
|
||||
};
|
||||
|
||||
const emit = defineEmits<{
|
||||
'click:add': [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.simplifiedLayout">
|
||||
<div :class="$style.emptyStateLayout">
|
||||
<div :class="$style.content">
|
||||
<div :class="$style.welcome">
|
||||
<N8nHeading tag="h1" size="2xlarge" :class="$style.welcomeTitle">
|
||||
|
|
@ -106,11 +102,11 @@ const emit = defineEmits<{
|
|||
:class="$style.actionsContainer"
|
||||
>
|
||||
<N8nCard
|
||||
v-if="showReadyToRunV2Card"
|
||||
v-if="showReadyToRunCard"
|
||||
:class="[$style.actionCard, { [$style.loading]: isLoadingReadyToRun }]"
|
||||
:hoverable="!isLoadingReadyToRun"
|
||||
data-test-id="ready-to-run-v2-card"
|
||||
@click="handleReadyToRunV2Click"
|
||||
data-test-id="ready-to-run-card"
|
||||
@click="handleReadyToRunClick"
|
||||
>
|
||||
<div :class="$style.cardContent">
|
||||
<N8nIcon
|
||||
|
|
@ -121,7 +117,7 @@ const emit = defineEmits<{
|
|||
:spin="isLoadingReadyToRun"
|
||||
/>
|
||||
<N8nText size="large" class="mt-xs">
|
||||
{{ i18n.baseText('workflows.empty.readyToRunV2') }}
|
||||
{{ i18n.baseText('workflows.empty.readyToRun') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</N8nCard>
|
||||
|
|
@ -150,7 +146,7 @@ const emit = defineEmits<{
|
|||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.simplifiedLayout {
|
||||
.emptyStateLayout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
|
@ -158,13 +154,6 @@ const emit = defineEmits<{
|
|||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: fixed;
|
||||
top: var(--spacing--lg);
|
||||
left: var(--spacing--lg);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -7,7 +7,7 @@ import {
|
|||
} from './executionFinished';
|
||||
import type { ITaskData } from 'n8n-workflow';
|
||||
import { EVALUATION_TRIGGER_NODE_TYPE } from 'n8n-workflow';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import type { INodeUi, IWorkflowDb } from '@/Interface';
|
||||
import type { Router } from 'vue-router';
|
||||
import type { WorkflowState } from '@/app/composables/useWorkflowState';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
|
|
@ -15,6 +15,7 @@ import { setActivePinia } from 'pinia';
|
|||
import { useWorkflowsStore } from '@/app/stores/workflows.store';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import { useReadyToRunStore } from '@/features/workflows/readyToRun/stores/readyToRun.store';
|
||||
|
||||
const opts = {
|
||||
workflowState: mock<WorkflowState>(),
|
||||
|
|
@ -216,6 +217,219 @@ describe('executionFinished', () => {
|
|||
expect(workflowState.executingNode.lastAddedExecutingNode).toBeNull();
|
||||
});
|
||||
|
||||
describe('ready-to-run AI workflow tracking', () => {
|
||||
it('should track successful execution of ready-to-run-ai-workflow', async () => {
|
||||
const pinia = createTestingPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const readyToRunStore = useReadyToRunStore();
|
||||
|
||||
vi.spyOn(workflowsStore, 'activeExecutionId', 'get').mockReturnValue('123');
|
||||
vi.spyOn(workflowsStore, 'getWorkflowById').mockReturnValue({
|
||||
id: '1',
|
||||
name: 'Test Workflow',
|
||||
meta: { templateId: 'ready-to-run-ai-workflow' },
|
||||
} as IWorkflowDb);
|
||||
|
||||
const trackExecuteAiWorkflowSuccess = vi.spyOn(
|
||||
readyToRunStore,
|
||||
'trackExecuteAiWorkflowSuccess',
|
||||
);
|
||||
|
||||
const workflowState = mock<WorkflowState>({
|
||||
executingNode: {
|
||||
lastAddedExecutingNode: null,
|
||||
},
|
||||
});
|
||||
|
||||
await executionFinished(
|
||||
{
|
||||
type: 'executionFinished',
|
||||
data: {
|
||||
executionId: '123',
|
||||
workflowId: '1',
|
||||
status: 'success',
|
||||
},
|
||||
},
|
||||
{
|
||||
router: mock<Router>(),
|
||||
workflowState,
|
||||
},
|
||||
);
|
||||
|
||||
expect(trackExecuteAiWorkflowSuccess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should track failed execution of ready-to-run-ai-workflow', async () => {
|
||||
const pinia = createTestingPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const readyToRunStore = useReadyToRunStore();
|
||||
|
||||
vi.spyOn(workflowsStore, 'activeExecutionId', 'get').mockReturnValue('123');
|
||||
vi.spyOn(workflowsStore, 'getWorkflowById').mockReturnValue({
|
||||
id: '1',
|
||||
name: 'Test Workflow',
|
||||
meta: { templateId: 'ready-to-run-ai-workflow' },
|
||||
} as IWorkflowDb);
|
||||
|
||||
const trackExecuteAiWorkflow = vi.spyOn(readyToRunStore, 'trackExecuteAiWorkflow');
|
||||
|
||||
const workflowState = mock<WorkflowState>({
|
||||
executingNode: {
|
||||
lastAddedExecutingNode: null,
|
||||
},
|
||||
});
|
||||
|
||||
await executionFinished(
|
||||
{
|
||||
type: 'executionFinished',
|
||||
data: {
|
||||
executionId: '123',
|
||||
workflowId: '1',
|
||||
status: 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
router: mock<Router>(),
|
||||
workflowState,
|
||||
},
|
||||
);
|
||||
|
||||
expect(trackExecuteAiWorkflow).toHaveBeenCalledWith('error');
|
||||
});
|
||||
|
||||
it('should track execution of ready-to-run-ai-workflow-v1', async () => {
|
||||
const pinia = createTestingPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const readyToRunStore = useReadyToRunStore();
|
||||
|
||||
vi.spyOn(workflowsStore, 'activeExecutionId', 'get').mockReturnValue('123');
|
||||
vi.spyOn(workflowsStore, 'getWorkflowById').mockReturnValue({
|
||||
id: '1',
|
||||
name: 'Test Workflow',
|
||||
meta: { templateId: 'ready-to-run-ai-workflow-v1' },
|
||||
} as IWorkflowDb);
|
||||
|
||||
const trackExecuteAiWorkflowSuccess = vi.spyOn(
|
||||
readyToRunStore,
|
||||
'trackExecuteAiWorkflowSuccess',
|
||||
);
|
||||
|
||||
const workflowState = mock<WorkflowState>({
|
||||
executingNode: {
|
||||
lastAddedExecutingNode: null,
|
||||
},
|
||||
});
|
||||
|
||||
await executionFinished(
|
||||
{
|
||||
type: 'executionFinished',
|
||||
data: {
|
||||
executionId: '123',
|
||||
workflowId: '1',
|
||||
status: 'success',
|
||||
},
|
||||
},
|
||||
{
|
||||
router: mock<Router>(),
|
||||
workflowState,
|
||||
},
|
||||
);
|
||||
|
||||
expect(trackExecuteAiWorkflowSuccess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should track execution of ready-to-run-ai-workflow-v4', async () => {
|
||||
const pinia = createTestingPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const readyToRunStore = useReadyToRunStore();
|
||||
|
||||
vi.spyOn(workflowsStore, 'activeExecutionId', 'get').mockReturnValue('123');
|
||||
vi.spyOn(workflowsStore, 'getWorkflowById').mockReturnValue({
|
||||
id: '1',
|
||||
name: 'Test Workflow',
|
||||
meta: { templateId: 'ready-to-run-ai-workflow-v4' },
|
||||
} as IWorkflowDb);
|
||||
|
||||
const trackExecuteAiWorkflow = vi.spyOn(readyToRunStore, 'trackExecuteAiWorkflow');
|
||||
|
||||
const workflowState = mock<WorkflowState>({
|
||||
executingNode: {
|
||||
lastAddedExecutingNode: null,
|
||||
},
|
||||
});
|
||||
|
||||
await executionFinished(
|
||||
{
|
||||
type: 'executionFinished',
|
||||
data: {
|
||||
executionId: '123',
|
||||
workflowId: '1',
|
||||
status: 'canceled',
|
||||
},
|
||||
},
|
||||
{
|
||||
router: mock<Router>(),
|
||||
workflowState,
|
||||
},
|
||||
);
|
||||
|
||||
expect(trackExecuteAiWorkflow).toHaveBeenCalledWith('canceled');
|
||||
});
|
||||
|
||||
it('should not track execution for non-ready-to-run workflows', async () => {
|
||||
const pinia = createTestingPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const readyToRunStore = useReadyToRunStore();
|
||||
|
||||
vi.spyOn(workflowsStore, 'activeExecutionId', 'get').mockReturnValue('123');
|
||||
vi.spyOn(workflowsStore, 'getWorkflowById').mockReturnValue({
|
||||
id: '1',
|
||||
name: 'Test Workflow',
|
||||
meta: { templateId: 'some-other-template' },
|
||||
} as IWorkflowDb);
|
||||
|
||||
const trackExecuteAiWorkflowSuccess = vi.spyOn(
|
||||
readyToRunStore,
|
||||
'trackExecuteAiWorkflowSuccess',
|
||||
);
|
||||
const trackExecuteAiWorkflow = vi.spyOn(readyToRunStore, 'trackExecuteAiWorkflow');
|
||||
|
||||
const workflowState = mock<WorkflowState>({
|
||||
executingNode: {
|
||||
lastAddedExecutingNode: null,
|
||||
},
|
||||
});
|
||||
|
||||
await executionFinished(
|
||||
{
|
||||
type: 'executionFinished',
|
||||
data: {
|
||||
executionId: '123',
|
||||
workflowId: '1',
|
||||
status: 'success',
|
||||
},
|
||||
},
|
||||
{
|
||||
router: mock<Router>(),
|
||||
workflowState,
|
||||
},
|
||||
);
|
||||
|
||||
expect(trackExecuteAiWorkflowSuccess).not.toHaveBeenCalled();
|
||||
expect(trackExecuteAiWorkflow).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return early and clear active execution when fetchExecutionData returns undefined', async () => {
|
||||
const pinia = createTestingPinia({
|
||||
initialState: {
|
||||
|
|
|
|||
|
|
@ -9,8 +9,7 @@ import { useWorkflowSaving } from '@/app/composables/useWorkflowSaving';
|
|||
import { WORKFLOW_SETTINGS_MODAL_KEY } from '@/app/constants';
|
||||
import { codeNodeEditorEventBus, globalLinkActionsEventBus } from '@/app/event-bus';
|
||||
import { useAITemplatesStarterCollectionStore } from '@/experiments/aiTemplatesStarterCollection/stores/aiTemplatesStarterCollection.store';
|
||||
import { useReadyToRunWorkflowsStore } from '@/experiments/readyToRunWorkflows/stores/readyToRunWorkflows.store';
|
||||
import { useReadyToRunWorkflowsV2Store } from '@/experiments/readyToRunWorkflowsV2/stores/readyToRunWorkflowsV2.store';
|
||||
import { useReadyToRunStore } from '@/features/workflows/readyToRun/stores/readyToRun.store';
|
||||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import { useSettingsStore } from '@/app/stores/settings.store';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
|
|
@ -55,8 +54,7 @@ export async function executionFinished(
|
|||
const workflowsStore = useWorkflowsStore();
|
||||
const uiStore = useUIStore();
|
||||
const aiTemplatesStarterCollectionStore = useAITemplatesStarterCollectionStore();
|
||||
const readyToRunWorkflowsStore = useReadyToRunWorkflowsStore();
|
||||
const readyToRunWorkflowsV2Store = useReadyToRunWorkflowsV2Store();
|
||||
const readyToRunStore = useReadyToRunStore();
|
||||
|
||||
options.workflowState.executingNode.lastAddedExecutingNode = null;
|
||||
|
||||
|
|
@ -83,18 +81,17 @@ export async function executionFinished(
|
|||
templateId.split('-').pop() ?? '',
|
||||
data.status,
|
||||
);
|
||||
} else if (templateId.startsWith('37_onboarding_experiments_batch_aug11')) {
|
||||
readyToRunWorkflowsStore.trackExecuteWorkflow(templateId.split('-').pop() ?? '', data.status);
|
||||
} else if (
|
||||
templateId === 'ready-to-run-ai-workflow' ||
|
||||
templateId === 'ready-to-run-ai-workflow-v1' ||
|
||||
templateId === 'ready-to-run-ai-workflow-v2' ||
|
||||
templateId === 'ready-to-run-ai-workflow-v3' ||
|
||||
templateId === 'ready-to-run-ai-workflow-v4'
|
||||
) {
|
||||
if (data.status === 'success') {
|
||||
readyToRunWorkflowsV2Store.trackExecuteAiWorkflowSuccess();
|
||||
readyToRunStore.trackExecuteAiWorkflowSuccess();
|
||||
} else {
|
||||
readyToRunWorkflowsV2Store.trackExecuteAiWorkflow(data.status);
|
||||
readyToRunStore.trackExecuteAiWorkflow(data.status);
|
||||
}
|
||||
} else if (isPrebuiltAgentTemplateId(templateId)) {
|
||||
telemetry.track('User executed pre-built Agent', {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { createTestingPinia } from '@pinia/testing';
|
|||
import userEvent from '@testing-library/user-event';
|
||||
import { waitFor } from '@testing-library/vue';
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { useReadyToRunStore } from '@/features/workflows/readyToRun/stores/readyToRun.store';
|
||||
|
||||
vi.mock('@/features/collaboration/projects/projects.api');
|
||||
vi.mock('@n8n/rest-api-client/api/users');
|
||||
|
|
@ -433,6 +434,7 @@ describe('WorkflowsView', () => {
|
|||
const sourceControl = useSourceControlStore();
|
||||
|
||||
renderComponent({ pinia });
|
||||
await waitAllPromises();
|
||||
|
||||
await sourceControl.pullWorkfolder(true);
|
||||
expect(userStore.fetchUsers).toHaveBeenCalledTimes(2);
|
||||
|
|
@ -506,3 +508,102 @@ describe('Folders', () => {
|
|||
expect(getByTestId('folder-card-name')).toHaveTextContent(TEST_FOLDER_RESOURCE.name);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Simplified Layout', () => {
|
||||
beforeEach(async () => {
|
||||
await router.push('/');
|
||||
await router.isReady();
|
||||
pinia = createTestingPinia({ initialState });
|
||||
foldersStore = mockedStore(useFoldersStore);
|
||||
workflowsStore = mockedStore(useWorkflowsStore);
|
||||
|
||||
workflowsStore.fetchWorkflowsPage.mockResolvedValue([]);
|
||||
workflowsStore.fetchActiveWorkflows.mockResolvedValue([]);
|
||||
foldersStore.totalWorkflowCount = 0;
|
||||
foldersStore.fetchTotalWorkflowsAndFoldersCount.mockResolvedValue(0);
|
||||
});
|
||||
|
||||
it('should render EmptyStateLayout when simplified layout is enabled', async () => {
|
||||
const readyToRunStore = mockedStore(useReadyToRunStore);
|
||||
const projectsStore = mockedStore(useProjectsStore);
|
||||
|
||||
vi.spyOn(readyToRunStore, 'getSimplifiedLayoutVisibility').mockReturnValue(true);
|
||||
vi.spyOn(readyToRunStore, 'getCardVisibility').mockReturnValue(true);
|
||||
projectsStore.currentProject = { scopes: ['workflow:create'] } as Project;
|
||||
|
||||
const { getByTestId, queryByTestId } = renderComponent({ pinia });
|
||||
await waitAllPromises();
|
||||
|
||||
// EmptyStateLayout cards should be rendered
|
||||
expect(getByTestId('new-workflow-card')).toBeInTheDocument();
|
||||
expect(getByTestId('ready-to-run-card')).toBeInTheDocument();
|
||||
|
||||
// ResourcesListLayout should NOT be rendered
|
||||
expect(queryByTestId('resources-list-wrapper')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render ResourcesListLayout when simplified layout is disabled', async () => {
|
||||
const readyToRunStore = mockedStore(useReadyToRunStore);
|
||||
vi.spyOn(readyToRunStore, 'getSimplifiedLayoutVisibility').mockReturnValue(false);
|
||||
|
||||
const { queryByTestId } = renderComponent({ pinia });
|
||||
await waitAllPromises();
|
||||
|
||||
// ResourcesListLayout should be rendered (look for list-empty-state as indicator)
|
||||
// EmptyStateLayout cards should NOT be rendered when using regular layout
|
||||
expect(queryByTestId('list-empty-state')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call getSimplifiedLayoutVisibility with route and loading state', async () => {
|
||||
const readyToRunStore = mockedStore(useReadyToRunStore);
|
||||
const getSimplifiedLayoutVisibility = vi
|
||||
.spyOn(readyToRunStore, 'getSimplifiedLayoutVisibility')
|
||||
.mockReturnValue(false);
|
||||
|
||||
renderComponent({ pinia });
|
||||
await waitAllPromises();
|
||||
|
||||
// Should be called with route and loading boolean
|
||||
expect(getSimplifiedLayoutVisibility).toHaveBeenCalled();
|
||||
const callArgs = getSimplifiedLayoutVisibility.mock.calls[0];
|
||||
expect(callArgs[0]).toBeDefined(); // route
|
||||
});
|
||||
|
||||
it('should call addWorkflow when EmptyStateLayout new workflow card is clicked', async () => {
|
||||
const readyToRunStore = mockedStore(useReadyToRunStore);
|
||||
const projectsStore = mockedStore(useProjectsStore);
|
||||
projectsStore.currentProject = { id: 'project-123', scopes: ['workflow:create'] } as Project;
|
||||
|
||||
vi.spyOn(readyToRunStore, 'getSimplifiedLayoutVisibility').mockReturnValue(true);
|
||||
vi.spyOn(readyToRunStore, 'getCardVisibility').mockReturnValue(true);
|
||||
|
||||
const { getByTestId } = renderComponent({ pinia });
|
||||
await waitAllPromises();
|
||||
|
||||
const newWorkflowCard = getByTestId('new-workflow-card');
|
||||
expect(newWorkflowCard).toBeInTheDocument();
|
||||
|
||||
// Click the new workflow card
|
||||
await userEvent.click(newWorkflowCard);
|
||||
|
||||
// Should navigate to new workflow view
|
||||
expect(router.currentRoute.value.name).toBe(VIEWS.NEW_WORKFLOW);
|
||||
});
|
||||
|
||||
it('should pass route and loading state reactively to getSimplifiedLayoutVisibility', async () => {
|
||||
const readyToRunStore = mockedStore(useReadyToRunStore);
|
||||
const getSimplifiedLayoutVisibility = vi
|
||||
.spyOn(readyToRunStore, 'getSimplifiedLayoutVisibility')
|
||||
.mockReturnValue(false);
|
||||
|
||||
renderComponent({ pinia });
|
||||
await waitAllPromises();
|
||||
|
||||
const initialCallCount = getSimplifiedLayoutVisibility.mock.calls.length;
|
||||
expect(initialCallCount).toBeGreaterThan(0);
|
||||
|
||||
// The computed should be called with the current route and loading state
|
||||
const lastCall = getSimplifiedLayoutVisibility.mock.calls[initialCallCount - 1];
|
||||
expect(lastCall[0]).toHaveProperty('params'); // route object
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,12 +31,12 @@ import SuggestedWorkflowCard from '@/experiments/personalizedTemplates/component
|
|||
import SuggestedWorkflows from '@/experiments/personalizedTemplates/components/SuggestedWorkflows.vue';
|
||||
import { usePersonalizedTemplatesStore } from '@/experiments/personalizedTemplates/stores/personalizedTemplates.store';
|
||||
import { useReadyToRunWorkflowsStore } from '@/experiments/readyToRunWorkflows/stores/readyToRunWorkflows.store';
|
||||
import { useReadyToRunWorkflowsV2Store } from '@/experiments/readyToRunWorkflowsV2/stores/readyToRunWorkflowsV2.store';
|
||||
import TemplateRecommendationV2 from '@/experiments/templateRecoV2/components/TemplateRecommendationV2.vue';
|
||||
import TemplateRecommendationV3 from '@/experiments/personalizedTemplatesV3/components/TemplateRecommendationV3.vue';
|
||||
import { usePersonalizedTemplatesV2Store } from '@/experiments/templateRecoV2/stores/templateRecoV2.store';
|
||||
import { usePersonalizedTemplatesV3Store } from '@/experiments/personalizedTemplatesV3/stores/personalizedTemplatesV3.store';
|
||||
import SimplifiedEmptyLayout from '@/experiments/readyToRunWorkflowsV2/components/SimplifiedEmptyLayout.vue';
|
||||
import EmptyStateLayout from '@/app/components/layouts/EmptyStateLayout.vue';
|
||||
import { useReadyToRunStore } from '@/features/workflows/readyToRun/stores/readyToRun.store';
|
||||
import InsightsSummary from '@/features/execution/insights/components/InsightsSummary.vue';
|
||||
import { useInsightsStore } from '@/features/execution/insights/insights.store';
|
||||
import { useTemplatesDataQualityStore } from '@/experiments/templatesDataQuality/stores/templatesDataQuality.store';
|
||||
|
|
@ -144,9 +144,9 @@ const templatesStore = useTemplatesStore();
|
|||
const aiStarterTemplatesStore = useAITemplatesStarterCollectionStore();
|
||||
const personalizedTemplatesStore = usePersonalizedTemplatesStore();
|
||||
const readyToRunWorkflowsStore = useReadyToRunWorkflowsStore();
|
||||
const readyToRunWorkflowsV2Store = useReadyToRunWorkflowsV2Store();
|
||||
const personalizedTemplatesV2Store = usePersonalizedTemplatesV2Store();
|
||||
const personalizedTemplatesV3Store = usePersonalizedTemplatesV3Store();
|
||||
const readyToRunStore = useReadyToRunStore();
|
||||
const templatesDataQualityStore = useTemplatesDataQualityStore();
|
||||
|
||||
const documentTitle = useDocumentTitle();
|
||||
|
|
@ -485,7 +485,7 @@ const showPersonalizedTemplates = computed(
|
|||
);
|
||||
|
||||
const shouldUseSimplifiedLayout = computed(() => {
|
||||
return readyToRunWorkflowsV2Store.getSimplifiedLayoutVisibility(route, loading.value);
|
||||
return !loading.value && readyToRunStore.getSimplifiedLayoutVisibility(route);
|
||||
});
|
||||
|
||||
const hasActiveCallouts = computed(() => {
|
||||
|
|
@ -1819,7 +1819,7 @@ const onNameSubmit = async (name: string) => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<SimplifiedEmptyLayout v-if="shouldUseSimplifiedLayout" @click:add="addWorkflow" />
|
||||
<EmptyStateLayout v-if="shouldUseSimplifiedLayout" @click:add="addWorkflow" />
|
||||
|
||||
<ResourcesListLayout
|
||||
v-else
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import type { IUser } from 'n8n-workflow';
|
|||
import { type IconOrEmoji, isIconOrEmoji } from '@n8n/design-system/components/N8nIconPicker/types';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { PROJECT_DATA_TABLES } from '@/features/core/dataTable/constants';
|
||||
import ReadyToRunV2Button from '@/experiments/readyToRunWorkflowsV2/components/ReadyToRunV2Button.vue';
|
||||
import ReadyToRunButton from '@/features/workflows/readyToRun/components/ReadyToRunButton.vue';
|
||||
|
||||
import { N8nButton, N8nHeading, N8nText, N8nTooltip } from '@n8n/design-system';
|
||||
import { VARIABLE_MODAL_KEY } from '@/features/settings/environments.ee/environments.constants';
|
||||
|
|
@ -441,7 +441,7 @@ const onSelect = (action: string) => {
|
|||
:content="i18n.baseText('readOnlyEnv.cantAdd.any')"
|
||||
>
|
||||
<div style="display: flex; gap: var(--spacing--xs); align-items: center">
|
||||
<ReadyToRunV2Button :has-active-callouts="props.hasActiveCallouts" />
|
||||
<ReadyToRunButton :has-active-callouts="props.hasActiveCallouts" />
|
||||
<ProjectCreateResource
|
||||
data-test-id="add-resource-buttons"
|
||||
:actions="menu"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,223 @@
|
|||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import ReadyToRunButton from './ReadyToRunButton.vue';
|
||||
import { useReadyToRunStore } from '../stores/readyToRun.store';
|
||||
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
|
||||
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
|
||||
import { useFoldersStore } from '@/features/core/folders/folders.store';
|
||||
import type { Project } from '@/features/collaboration/projects/projects.types';
|
||||
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({
|
||||
showError: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/collaboration/projects/composables/useProjectPages', () => ({
|
||||
useProjectPages: () => ({
|
||||
isOverviewSubPage: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('vue-router', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const actual = await vi.importActual<typeof import('vue-router')>('vue-router');
|
||||
return {
|
||||
...actual,
|
||||
useRoute: () => ({
|
||||
params: {
|
||||
projectId: 'test-project-123',
|
||||
folderId: 'test-folder-456',
|
||||
},
|
||||
}),
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const renderComponent = createComponentRenderer(ReadyToRunButton);
|
||||
|
||||
describe('ReadyToRunButton.vue', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
});
|
||||
|
||||
describe('button visibility', () => {
|
||||
it('should show button when all conditions are met', () => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
const readyToRunStore = useReadyToRunStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const sourceControlStore = useSourceControlStore();
|
||||
const foldersStore = useFoldersStore();
|
||||
|
||||
vi.spyOn(readyToRunStore, 'getButtonVisibility').mockReturnValue(true);
|
||||
vi.spyOn(foldersStore, 'totalWorkflowCount', 'get').mockReturnValue(5);
|
||||
vi.spyOn(projectsStore, 'currentProject', 'get').mockReturnValue({
|
||||
scopes: ['workflow:create'],
|
||||
} as Partial<Project> as Project);
|
||||
vi.spyOn(sourceControlStore, 'preferences', 'get').mockReturnValue({
|
||||
branchReadOnly: false,
|
||||
} as never);
|
||||
|
||||
const { getByTestId } = renderComponent({
|
||||
pinia,
|
||||
});
|
||||
|
||||
expect(getByTestId('ready-to-run-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide button when no workflows exist', () => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
const readyToRunStore = useReadyToRunStore();
|
||||
const foldersStore = useFoldersStore();
|
||||
|
||||
vi.spyOn(readyToRunStore, 'getButtonVisibility').mockReturnValue(false);
|
||||
vi.spyOn(foldersStore, 'totalWorkflowCount', 'get').mockReturnValue(0);
|
||||
|
||||
const { queryByTestId } = renderComponent({
|
||||
pinia,
|
||||
});
|
||||
|
||||
expect(queryByTestId('ready-to-run-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide button when branch is read-only', () => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
const readyToRunStore = useReadyToRunStore();
|
||||
const sourceControlStore = useSourceControlStore();
|
||||
const foldersStore = useFoldersStore();
|
||||
|
||||
vi.spyOn(readyToRunStore, 'getButtonVisibility').mockReturnValue(false);
|
||||
vi.spyOn(foldersStore, 'totalWorkflowCount', 'get').mockReturnValue(5);
|
||||
vi.spyOn(sourceControlStore, 'preferences', 'get').mockReturnValue({
|
||||
branchReadOnly: true,
|
||||
} as never);
|
||||
|
||||
const { queryByTestId } = renderComponent({
|
||||
pinia,
|
||||
});
|
||||
|
||||
expect(queryByTestId('ready-to-run-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide button when user cannot create workflows', () => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
const readyToRunStore = useReadyToRunStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const foldersStore = useFoldersStore();
|
||||
|
||||
vi.spyOn(readyToRunStore, 'getButtonVisibility').mockReturnValue(false);
|
||||
vi.spyOn(foldersStore, 'totalWorkflowCount', 'get').mockReturnValue(5);
|
||||
vi.spyOn(projectsStore, 'currentProject', 'get').mockReturnValue({
|
||||
scopes: [],
|
||||
} as Partial<Project> as Project);
|
||||
|
||||
const { queryByTestId } = renderComponent({
|
||||
pinia,
|
||||
});
|
||||
|
||||
expect(queryByTestId('ready-to-run-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide button when active callouts are present', () => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
const readyToRunStore = useReadyToRunStore();
|
||||
const foldersStore = useFoldersStore();
|
||||
|
||||
vi.spyOn(readyToRunStore, 'getButtonVisibility').mockReturnValue(true);
|
||||
vi.spyOn(foldersStore, 'totalWorkflowCount', 'get').mockReturnValue(5);
|
||||
|
||||
const { queryByTestId } = renderComponent({
|
||||
pinia,
|
||||
props: {
|
||||
hasActiveCallouts: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(queryByTestId('ready-to-run-button')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('button state', () => {
|
||||
it('should disable button when claiming credits', () => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
const readyToRunStore = useReadyToRunStore();
|
||||
const foldersStore = useFoldersStore();
|
||||
|
||||
vi.spyOn(readyToRunStore, 'getButtonVisibility').mockReturnValue(true);
|
||||
vi.spyOn(readyToRunStore, 'claimingCredits', 'get').mockReturnValue(true);
|
||||
vi.spyOn(foldersStore, 'totalWorkflowCount', 'get').mockReturnValue(5);
|
||||
|
||||
const { getByTestId } = renderComponent({
|
||||
pinia,
|
||||
});
|
||||
|
||||
const button = getByTestId('ready-to-run-button');
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should disable button when branch is read-only', () => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
const readyToRunStore = useReadyToRunStore();
|
||||
const sourceControlStore = useSourceControlStore();
|
||||
const foldersStore = useFoldersStore();
|
||||
|
||||
vi.spyOn(readyToRunStore, 'getButtonVisibility').mockReturnValue(true);
|
||||
vi.spyOn(readyToRunStore, 'claimingCredits', 'get').mockReturnValue(false);
|
||||
vi.spyOn(foldersStore, 'totalWorkflowCount', 'get').mockReturnValue(5);
|
||||
vi.spyOn(sourceControlStore, 'preferences', 'get').mockReturnValue({
|
||||
branchReadOnly: true,
|
||||
} as never);
|
||||
|
||||
const { getByTestId } = renderComponent({
|
||||
pinia,
|
||||
});
|
||||
|
||||
const button = getByTestId('ready-to-run-button');
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('button click', () => {
|
||||
it('should call claimCreditsAndOpenWorkflow on button click', async () => {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
const readyToRunStore = useReadyToRunStore();
|
||||
const foldersStore = useFoldersStore();
|
||||
|
||||
vi.spyOn(readyToRunStore, 'getButtonVisibility').mockReturnValue(true);
|
||||
vi.spyOn(foldersStore, 'totalWorkflowCount', 'get').mockReturnValue(5);
|
||||
const claimCreditsAndOpenWorkflow = vi
|
||||
.spyOn(readyToRunStore, 'claimCreditsAndOpenWorkflow')
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
const { getByTestId } = renderComponent({
|
||||
pinia,
|
||||
});
|
||||
|
||||
const button = getByTestId('ready-to-run-button');
|
||||
button.click();
|
||||
|
||||
expect(claimCreditsAndOpenWorkflow).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -9,7 +9,7 @@ import { useProjectsStore } from '@/features/collaboration/projects/projects.sto
|
|||
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
|
||||
import { useFoldersStore } from '@/features/core/folders/folders.store';
|
||||
import { useToast } from '@/app/composables/useToast';
|
||||
import { useReadyToRunWorkflowsV2Store } from '../stores/readyToRunWorkflowsV2.store';
|
||||
import { useReadyToRunStore } from '../stores/readyToRun.store';
|
||||
|
||||
const props = defineProps<{
|
||||
hasActiveCallouts?: boolean;
|
||||
|
|
@ -22,7 +22,7 @@ const projectPages = useProjectPages();
|
|||
const projectsStore = useProjectsStore();
|
||||
const sourceControlStore = useSourceControlStore();
|
||||
const foldersStore = useFoldersStore();
|
||||
const readyToRunWorkflowsV2Store = useReadyToRunWorkflowsV2Store();
|
||||
const readyToRunStore = useReadyToRunStore();
|
||||
|
||||
const projectPermissions = computed(() => {
|
||||
return getResourcePermissions(
|
||||
|
|
@ -32,7 +32,7 @@ const projectPermissions = computed(() => {
|
|||
|
||||
const showButton = computed(() => {
|
||||
return (
|
||||
readyToRunWorkflowsV2Store.getButtonVisibility(
|
||||
readyToRunStore.getButtonVisibility(
|
||||
foldersStore.totalWorkflowCount > 0, // Has workflows
|
||||
projectPermissions.value.workflow.create,
|
||||
sourceControlStore.preferences.branchReadOnly,
|
||||
|
|
@ -46,7 +46,7 @@ const handleClick = async () => {
|
|||
: (route.params.projectId as string);
|
||||
|
||||
try {
|
||||
await readyToRunWorkflowsV2Store.claimCreditsAndOpenWorkflow(
|
||||
await readyToRunStore.claimCreditsAndOpenWorkflow(
|
||||
'button',
|
||||
route.params.folderId as string,
|
||||
projectId,
|
||||
|
|
@ -60,15 +60,13 @@ const handleClick = async () => {
|
|||
<template>
|
||||
<N8nButton
|
||||
v-if="showButton"
|
||||
data-test-id="ready-to-run-v2-button"
|
||||
data-test-id="ready-to-run-button"
|
||||
type="secondary"
|
||||
icon="sparkles"
|
||||
:loading="readyToRunWorkflowsV2Store.claimingCredits"
|
||||
:disabled="
|
||||
sourceControlStore.preferences.branchReadOnly || readyToRunWorkflowsV2Store.claimingCredits
|
||||
"
|
||||
:loading="readyToRunStore.claimingCredits"
|
||||
:disabled="sourceControlStore.preferences.branchReadOnly || readyToRunStore.claimingCredits"
|
||||
@click="handleClick"
|
||||
>
|
||||
{{ i18n.baseText('workflows.empty.readyToRunV2') }}
|
||||
{{ i18n.baseText('workflows.empty.readyToRun') }}
|
||||
</N8nButton>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { RouteLocationNormalized } from 'vue-router';
|
||||
import { useEmptyStateDetection } from './useEmptyStateDetection';
|
||||
|
||||
const mockRoute = (overrides: Partial<RouteLocationNormalized> = {}) =>
|
||||
({
|
||||
params: {},
|
||||
query: {},
|
||||
...overrides,
|
||||
}) as RouteLocationNormalized;
|
||||
|
||||
vi.mock('@/features/core/folders/folders.store', () => ({
|
||||
useFoldersStore: () => ({
|
||||
totalWorkflowCount: 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/collaboration/projects/composables/useProjectPages', () => ({
|
||||
useProjectPages: () => ({
|
||||
isOverviewSubPage: true,
|
||||
isSharedSubPage: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => mockRoute(),
|
||||
}));
|
||||
|
||||
describe('useEmptyStateDetection', () => {
|
||||
describe('isTrulyEmpty', () => {
|
||||
it('should return true when state is truly empty', () => {
|
||||
const { isTrulyEmpty } = useEmptyStateDetection();
|
||||
const route = mockRoute();
|
||||
|
||||
expect(isTrulyEmpty(route)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when in a specific folder', () => {
|
||||
const { isTrulyEmpty } = useEmptyStateDetection();
|
||||
const route = mockRoute({
|
||||
params: { folderId: 'folder-123' },
|
||||
});
|
||||
|
||||
expect(isTrulyEmpty(route)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when search query is active', () => {
|
||||
const { isTrulyEmpty } = useEmptyStateDetection();
|
||||
const route = mockRoute({
|
||||
query: { search: 'test' },
|
||||
});
|
||||
|
||||
expect(isTrulyEmpty(route)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when status filter is active', () => {
|
||||
const { isTrulyEmpty } = useEmptyStateDetection();
|
||||
const route = mockRoute({
|
||||
query: { status: 'active' },
|
||||
});
|
||||
|
||||
expect(isTrulyEmpty(route)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when tags filter is active', () => {
|
||||
const { isTrulyEmpty } = useEmptyStateDetection();
|
||||
const route = mockRoute({
|
||||
query: { tags: 'production' },
|
||||
});
|
||||
|
||||
expect(isTrulyEmpty(route)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when showArchived filter is active', () => {
|
||||
const { isTrulyEmpty } = useEmptyStateDetection();
|
||||
const route = mockRoute({
|
||||
query: { showArchived: 'true' },
|
||||
});
|
||||
|
||||
expect(isTrulyEmpty(route)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when homeProject filter is active', () => {
|
||||
const { isTrulyEmpty } = useEmptyStateDetection();
|
||||
const route = mockRoute({
|
||||
query: { homeProject: 'true' },
|
||||
});
|
||||
|
||||
expect(isTrulyEmpty(route)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import type { RouteLocationNormalized } from 'vue-router';
|
||||
import { useFoldersStore } from '@/features/core/folders/folders.store';
|
||||
import { useProjectPages } from '@/features/collaboration/projects/composables/useProjectPages';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
/**
|
||||
* Determines if the instance is truly empty and should show the simplified layout
|
||||
*/
|
||||
export function useEmptyStateDetection() {
|
||||
const foldersStore = useFoldersStore();
|
||||
const projectPages = useProjectPages();
|
||||
const route = useRoute();
|
||||
|
||||
/**
|
||||
* Checks if the current state qualifies as "truly empty"
|
||||
* - No workflows exist in the instance
|
||||
* - User is on the main workflows view (not in a specific folder)
|
||||
* - User is on overview page or personal project workflows
|
||||
* - No search filters are applied
|
||||
* - Not currently refreshing data
|
||||
*/
|
||||
const isTrulyEmpty = (currentRoute: RouteLocationNormalized = route) => {
|
||||
const hasNoWorkflows = foldersStore.totalWorkflowCount === 0;
|
||||
const isNotInSpecificFolder = !currentRoute.params?.folderId;
|
||||
const isMainWorkflowsPage = projectPages.isOverviewSubPage;
|
||||
|
||||
// Check for any search or filter parameters that would indicate filtering is active
|
||||
const hasSearchQuery = !!currentRoute.query?.search;
|
||||
const hasFilters = !!(
|
||||
currentRoute.query?.status ||
|
||||
currentRoute.query?.tags ||
|
||||
currentRoute.query?.showArchived ||
|
||||
currentRoute.query?.homeProject
|
||||
);
|
||||
|
||||
return (
|
||||
hasNoWorkflows &&
|
||||
isNotInSpecificFolder &&
|
||||
isMainWorkflowsPage &&
|
||||
!hasSearchQuery &&
|
||||
!hasFilters
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
isTrulyEmpty,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,573 @@
|
|||
import type { ICredentialsResponse } from '@/features/credentials/credentials.types';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { RouteLocationNormalized } from 'vue-router';
|
||||
import { useReadyToRunStore } from './readyToRun.store';
|
||||
|
||||
const { mockPush, mockTrack, mockShowError, mockClaimFreeAiCredits, mockCreateNewWorkflow } =
|
||||
vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
mockTrack: vi.fn(),
|
||||
mockShowError: vi.fn(),
|
||||
mockClaimFreeAiCredits: vi.fn(),
|
||||
mockCreateNewWorkflow: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
useRoute: () => ({
|
||||
params: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/composables/useTelemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
track: mockTrack,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/composables/useToast', () => ({
|
||||
useToast: () => ({
|
||||
showError: mockShowError,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockGetVariant = vi.fn();
|
||||
|
||||
vi.mock('@/app/stores/posthog.store', () => ({
|
||||
usePostHog: () => ({
|
||||
getVariant: mockGetVariant,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockAllCredentials = { value: [] as ICredentialsResponse[] };
|
||||
|
||||
vi.mock('@/features/credentials/credentials.store', () => ({
|
||||
useCredentialsStore: () => ({
|
||||
get allCredentials() {
|
||||
return mockAllCredentials.value;
|
||||
},
|
||||
claimFreeAiCredits: mockClaimFreeAiCredits,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/stores/workflows.store', () => ({
|
||||
useWorkflowsStore: () => ({
|
||||
createNewWorkflow: mockCreateNewWorkflow,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockCurrentUser = {
|
||||
value: {
|
||||
settings: {} as Record<string, unknown>,
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock('@/features/settings/users/users.store', () => ({
|
||||
useUsersStore: () => ({
|
||||
get currentUser() {
|
||||
return mockCurrentUser.value;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockIsAiCreditsEnabled = { value: true };
|
||||
|
||||
vi.mock('@/app/stores/settings.store', () => ({
|
||||
useSettingsStore: () => ({
|
||||
get isAiCreditsEnabled() {
|
||||
return mockIsAiCreditsEnabled.value;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/i18n', () => ({
|
||||
useI18n: () => ({
|
||||
baseText: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockLocalStorageValue = { value: '' };
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useLocalStorage: () => mockLocalStorageValue,
|
||||
useMediaQuery: () => ({ value: false }),
|
||||
}));
|
||||
|
||||
const mockIsTrulyEmpty = vi.fn(() => false);
|
||||
|
||||
vi.mock('../composables/useEmptyStateDetection', () => ({
|
||||
useEmptyStateDetection: () => ({
|
||||
isTrulyEmpty: mockIsTrulyEmpty,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('useReadyToRunStore', () => {
|
||||
let store: ReturnType<typeof useReadyToRunStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset dynamic mocks to default values
|
||||
mockAllCredentials.value = [];
|
||||
mockCurrentUser.value = { settings: {} };
|
||||
mockIsAiCreditsEnabled.value = true;
|
||||
mockLocalStorageValue.value = '';
|
||||
mockIsTrulyEmpty.mockReturnValue(false);
|
||||
|
||||
setActivePinia(createPinia());
|
||||
store = useReadyToRunStore();
|
||||
});
|
||||
|
||||
describe('userCanClaimOpenAiCredits', () => {
|
||||
it('should return true when user can claim credits', () => {
|
||||
expect(store.userCanClaimOpenAiCredits).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('trackExecuteAiWorkflow', () => {
|
||||
it('should track workflow execution', () => {
|
||||
store.trackExecuteAiWorkflow('success');
|
||||
|
||||
expect(mockTrack).toHaveBeenCalledWith('User executed ready to run AI workflow', {
|
||||
status: 'success',
|
||||
});
|
||||
});
|
||||
|
||||
it('should track workflow execution with failed status', () => {
|
||||
store.trackExecuteAiWorkflow('failed');
|
||||
|
||||
expect(mockTrack).toHaveBeenCalledWith('User executed ready to run AI workflow', {
|
||||
status: 'failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('trackExecuteAiWorkflowSuccess', () => {
|
||||
it('should track successful workflow execution', () => {
|
||||
store.trackExecuteAiWorkflowSuccess();
|
||||
|
||||
expect(mockTrack).toHaveBeenCalledWith('User executed ready to run AI workflow successfully');
|
||||
});
|
||||
});
|
||||
|
||||
describe('claimFreeAiCredits', () => {
|
||||
it('should claim credits and track event', async () => {
|
||||
const mockCredential = { id: 'cred-123', name: 'OpenAI' };
|
||||
mockClaimFreeAiCredits.mockResolvedValue(mockCredential);
|
||||
|
||||
const result = await store.claimFreeAiCredits('project-123');
|
||||
|
||||
expect(mockClaimFreeAiCredits).toHaveBeenCalledWith('project-123');
|
||||
expect(mockTrack).toHaveBeenCalledWith('User claimed OpenAI credits');
|
||||
expect(result).toEqual(mockCredential);
|
||||
});
|
||||
|
||||
it('should store credential ID in localStorage', async () => {
|
||||
const mockCredential = { id: 'cred-456', name: 'OpenAI' };
|
||||
mockClaimFreeAiCredits.mockResolvedValue(mockCredential);
|
||||
|
||||
await store.claimFreeAiCredits();
|
||||
|
||||
expect(mockLocalStorageValue.value).toBe('cred-456');
|
||||
});
|
||||
|
||||
it('should show error on failure', async () => {
|
||||
const error = new Error('Failed to claim');
|
||||
mockClaimFreeAiCredits.mockRejectedValue(error);
|
||||
|
||||
await expect(store.claimFreeAiCredits()).rejects.toThrow('Failed to claim');
|
||||
expect(mockShowError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show error with correct i18n keys on failure', async () => {
|
||||
const error = new Error('Failed to claim');
|
||||
mockClaimFreeAiCredits.mockRejectedValue(error);
|
||||
|
||||
try {
|
||||
await store.claimFreeAiCredits();
|
||||
} catch (e) {
|
||||
// Expected to throw
|
||||
}
|
||||
|
||||
expect(mockShowError).toHaveBeenCalledWith(
|
||||
error,
|
||||
'freeAi.credits.showError.claim.title',
|
||||
'freeAi.credits.showError.claim.message',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAndOpenAiWorkflow', () => {
|
||||
it('should create workflow and track event', async () => {
|
||||
const mockWorkflow = { id: 'workflow-123', name: 'AI Agent workflow' };
|
||||
mockCreateNewWorkflow.mockResolvedValue(mockWorkflow);
|
||||
|
||||
await store.createAndOpenAiWorkflow('card');
|
||||
|
||||
expect(mockTrack).toHaveBeenCalledWith('User opened ready to run AI workflow', {
|
||||
source: 'card',
|
||||
});
|
||||
expect(mockCreateNewWorkflow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'AI Agent workflow',
|
||||
meta: { templateId: 'ready-to-run-ai-workflow' },
|
||||
}),
|
||||
);
|
||||
expect(mockPush).toHaveBeenCalledWith({
|
||||
name: 'NodeViewExisting',
|
||||
params: { name: 'workflow-123' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should track with button source', async () => {
|
||||
const mockWorkflow = { id: 'workflow-456', name: 'AI Agent workflow' };
|
||||
mockCreateNewWorkflow.mockResolvedValue(mockWorkflow);
|
||||
|
||||
await store.createAndOpenAiWorkflow('button');
|
||||
|
||||
expect(mockTrack).toHaveBeenCalledWith('User opened ready to run AI workflow', {
|
||||
source: 'button',
|
||||
});
|
||||
});
|
||||
|
||||
it('should create workflow with folder ID', async () => {
|
||||
const mockWorkflow = { id: 'workflow-123', name: 'AI Agent workflow' };
|
||||
mockCreateNewWorkflow.mockResolvedValue(mockWorkflow);
|
||||
|
||||
await store.createAndOpenAiWorkflow('button', 'folder-456');
|
||||
|
||||
expect(mockCreateNewWorkflow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
parentFolderId: 'folder-456',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show error on failure', async () => {
|
||||
const error = new Error('Failed to create');
|
||||
mockCreateNewWorkflow.mockRejectedValue(error);
|
||||
|
||||
await expect(store.createAndOpenAiWorkflow('card')).rejects.toThrow('Failed to create');
|
||||
expect(mockShowError).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCardVisibility', () => {
|
||||
it('should return true when user can create and env is not read-only', () => {
|
||||
expect(store.getCardVisibility(true, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when read-only', () => {
|
||||
expect(store.getCardVisibility(true, true)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when cannot create', () => {
|
||||
expect(store.getCardVisibility(false, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getButtonVisibility', () => {
|
||||
it('should return true when all conditions met', () => {
|
||||
expect(store.getButtonVisibility(true, true, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when no workflows', () => {
|
||||
expect(store.getButtonVisibility(false, true, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when cannot create', () => {
|
||||
expect(store.getButtonVisibility(true, false, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when read-only', () => {
|
||||
expect(store.getButtonVisibility(true, true, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSimplifiedLayoutVisibility', () => {
|
||||
it('should call isTrulyEmpty with route', () => {
|
||||
const mockRoute = { name: 'test', params: {} } as RouteLocationNormalized;
|
||||
|
||||
store.getSimplifiedLayoutVisibility(mockRoute);
|
||||
|
||||
expect(mockIsTrulyEmpty).toHaveBeenCalledWith(mockRoute);
|
||||
});
|
||||
|
||||
it('should return the result from isTrulyEmpty', () => {
|
||||
const mockRoute = { name: 'test', params: {} } as RouteLocationNormalized;
|
||||
mockIsTrulyEmpty.mockReturnValueOnce(true);
|
||||
|
||||
const result = store.getSimplifiedLayoutVisibility(mockRoute);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when isTrulyEmpty returns false', () => {
|
||||
const mockRoute = { name: 'test', params: {} } as RouteLocationNormalized;
|
||||
mockIsTrulyEmpty.mockReturnValueOnce(false);
|
||||
|
||||
const result = store.getSimplifiedLayoutVisibility(mockRoute);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('claimCreditsAndOpenWorkflow', () => {
|
||||
it('should claim credits and open workflow', async () => {
|
||||
const mockCredential = { id: 'cred-123', name: 'OpenAI' };
|
||||
const mockWorkflow = { id: 'workflow-123', name: 'AI Agent workflow' };
|
||||
mockClaimFreeAiCredits.mockResolvedValue(mockCredential);
|
||||
mockCreateNewWorkflow.mockResolvedValue(mockWorkflow);
|
||||
|
||||
await store.claimCreditsAndOpenWorkflow('card');
|
||||
|
||||
expect(mockClaimFreeAiCredits).toHaveBeenCalled();
|
||||
expect(mockCreateNewWorkflow).toHaveBeenCalled();
|
||||
expect(mockPush).toHaveBeenCalledWith({
|
||||
name: 'NodeViewExisting',
|
||||
params: { name: 'workflow-123' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should claim credits and open workflow with projectId and parentFolderId', async () => {
|
||||
const mockCredential = { id: 'cred-456', name: 'OpenAI' };
|
||||
const mockWorkflow = { id: 'workflow-456', name: 'AI Agent workflow' };
|
||||
mockClaimFreeAiCredits.mockResolvedValue(mockCredential);
|
||||
mockCreateNewWorkflow.mockResolvedValue(mockWorkflow);
|
||||
|
||||
await store.claimCreditsAndOpenWorkflow('button', 'folder-789', 'project-789');
|
||||
|
||||
expect(mockClaimFreeAiCredits).toHaveBeenCalledWith('project-789');
|
||||
expect(mockCreateNewWorkflow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
parentFolderId: 'folder-789',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update user settings after successful claim', async () => {
|
||||
const mockCredential = { id: 'cred-123', name: 'OpenAI' };
|
||||
const mockWorkflow = { id: 'workflow-123', name: 'AI Agent workflow' };
|
||||
mockClaimFreeAiCredits.mockResolvedValue(mockCredential);
|
||||
mockCreateNewWorkflow.mockResolvedValue(mockWorkflow);
|
||||
|
||||
await store.claimCreditsAndOpenWorkflow('card');
|
||||
|
||||
// Note: This test verifies the user settings update logic
|
||||
// In the actual implementation, this would update usersStore.currentUser.settings.userClaimedAiCredits
|
||||
});
|
||||
|
||||
it('should throw error when claiming credits fails', async () => {
|
||||
const error = new Error('Failed to claim');
|
||||
mockClaimFreeAiCredits.mockRejectedValue(error);
|
||||
|
||||
await expect(store.claimCreditsAndOpenWorkflow('card')).rejects.toThrow('Failed to claim');
|
||||
expect(mockCreateNewWorkflow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error when creating workflow fails', async () => {
|
||||
const mockCredential = { id: 'cred-123', name: 'OpenAI' };
|
||||
const error = new Error('Failed to create workflow');
|
||||
mockClaimFreeAiCredits.mockResolvedValue(mockCredential);
|
||||
mockCreateNewWorkflow.mockRejectedValue(error);
|
||||
|
||||
await expect(store.claimCreditsAndOpenWorkflow('card')).rejects.toThrow(
|
||||
'Failed to create workflow',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('claimingCredits state', () => {
|
||||
it('should be false initially', () => {
|
||||
expect(store.claimingCredits).toBe(false);
|
||||
});
|
||||
|
||||
it('should be true while claiming credits', async () => {
|
||||
const mockCredential = { id: 'cred-123', name: 'OpenAI' };
|
||||
let claimingDuringExecution = false;
|
||||
|
||||
mockClaimFreeAiCredits.mockImplementation(async () => {
|
||||
claimingDuringExecution = store.claimingCredits;
|
||||
return mockCredential;
|
||||
});
|
||||
|
||||
await store.claimFreeAiCredits();
|
||||
|
||||
expect(claimingDuringExecution).toBe(true);
|
||||
});
|
||||
|
||||
it('should be false after successful claim', async () => {
|
||||
const mockCredential = { id: 'cred-123', name: 'OpenAI' };
|
||||
mockClaimFreeAiCredits.mockResolvedValue(mockCredential);
|
||||
|
||||
await store.claimFreeAiCredits();
|
||||
|
||||
expect(store.claimingCredits).toBe(false);
|
||||
});
|
||||
|
||||
it('should be false after failed claim', async () => {
|
||||
const error = new Error('Failed to claim');
|
||||
mockClaimFreeAiCredits.mockRejectedValue(error);
|
||||
|
||||
try {
|
||||
await store.claimFreeAiCredits();
|
||||
} catch (e) {
|
||||
// Expected to throw
|
||||
}
|
||||
|
||||
expect(store.claimingCredits).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('userCanClaimOpenAiCredits edge cases', () => {
|
||||
it('should return false when AI credits are disabled', () => {
|
||||
mockIsAiCreditsEnabled.value = false;
|
||||
setActivePinia(createPinia());
|
||||
const testStore = useReadyToRunStore();
|
||||
|
||||
expect(testStore.userCanClaimOpenAiCredits).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when user already has OpenAI credentials', () => {
|
||||
mockAllCredentials.value = [
|
||||
{
|
||||
id: 'cred-1',
|
||||
type: 'openAiApi',
|
||||
name: 'My OpenAI Credentials',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
isManaged: false,
|
||||
},
|
||||
];
|
||||
setActivePinia(createPinia());
|
||||
const testStore = useReadyToRunStore();
|
||||
|
||||
expect(testStore.userCanClaimOpenAiCredits).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when user already claimed AI credits', () => {
|
||||
mockCurrentUser.value = {
|
||||
settings: { userClaimedAiCredits: true },
|
||||
};
|
||||
setActivePinia(createPinia());
|
||||
const testStore = useReadyToRunStore();
|
||||
|
||||
expect(testStore.userCanClaimOpenAiCredits).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when multiple conditions are false', () => {
|
||||
mockIsAiCreditsEnabled.value = false;
|
||||
mockAllCredentials.value = [
|
||||
{
|
||||
id: 'cred-1',
|
||||
type: 'openAiApi',
|
||||
name: 'My OpenAI Credentials',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
isManaged: false,
|
||||
},
|
||||
];
|
||||
mockCurrentUser.value = {
|
||||
settings: { userClaimedAiCredits: true },
|
||||
};
|
||||
setActivePinia(createPinia());
|
||||
const testStore = useReadyToRunStore();
|
||||
|
||||
expect(testStore.userCanClaimOpenAiCredits).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when all conditions are met', () => {
|
||||
mockIsAiCreditsEnabled.value = true;
|
||||
mockAllCredentials.value = [];
|
||||
mockCurrentUser.value = { settings: {} };
|
||||
setActivePinia(createPinia());
|
||||
const testStore = useReadyToRunStore();
|
||||
|
||||
expect(testStore.userCanClaimOpenAiCredits).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAndOpenAiWorkflow with credential injection', () => {
|
||||
it('should inject credential into OpenAI Model node when credential exists', async () => {
|
||||
const mockWorkflow = { id: 'workflow-123', name: 'AI Agent workflow' };
|
||||
mockCreateNewWorkflow.mockResolvedValue(mockWorkflow);
|
||||
mockLocalStorageValue.value = 'cred-stored-123';
|
||||
|
||||
await store.createAndOpenAiWorkflow('card');
|
||||
|
||||
expect(mockCreateNewWorkflow).toHaveBeenCalled();
|
||||
const createCall = mockCreateNewWorkflow.mock.calls[0][0];
|
||||
const openAiNode = createCall.nodes?.find((node: INodeUi) => node.name === 'OpenAI Model');
|
||||
|
||||
expect(openAiNode).toBeDefined();
|
||||
expect(openAiNode?.credentials).toBeDefined();
|
||||
expect(openAiNode?.credentials?.openAiApi).toEqual({
|
||||
id: 'cred-stored-123',
|
||||
name: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should create workflow without credential when no credential is stored', async () => {
|
||||
const mockWorkflow = { id: 'workflow-123', name: 'AI Agent workflow' };
|
||||
mockCreateNewWorkflow.mockResolvedValue(mockWorkflow);
|
||||
mockLocalStorageValue.value = '';
|
||||
|
||||
await store.createAndOpenAiWorkflow('card');
|
||||
|
||||
expect(mockCreateNewWorkflow).toHaveBeenCalled();
|
||||
const createCall = mockCreateNewWorkflow.mock.calls[0][0];
|
||||
const openAiNode = createCall.nodes?.find((node: INodeUi) => node.name === 'OpenAI Model');
|
||||
|
||||
// Should not modify the credentials when no credential is stored
|
||||
expect(openAiNode).toBeDefined();
|
||||
expect(openAiNode?.credentials).toEqual({});
|
||||
});
|
||||
|
||||
it('should not modify original workflow template', async () => {
|
||||
const mockWorkflow = { id: 'workflow-123', name: 'AI Agent workflow' };
|
||||
mockCreateNewWorkflow.mockResolvedValue(mockWorkflow);
|
||||
mockLocalStorageValue.value = 'cred-stored-456';
|
||||
|
||||
await store.createAndOpenAiWorkflow('card');
|
||||
|
||||
// Create another workflow to verify the template wasn't mutated
|
||||
await store.createAndOpenAiWorkflow('button');
|
||||
|
||||
expect(mockCreateNewWorkflow).toHaveBeenCalledTimes(2);
|
||||
const firstCall = mockCreateNewWorkflow.mock.calls[0][0];
|
||||
const secondCall = mockCreateNewWorkflow.mock.calls[1][0];
|
||||
|
||||
// Both should have the credential injected independently
|
||||
const firstOpenAiNode = firstCall.nodes?.find(
|
||||
(node: INodeUi) => node.name === 'OpenAI Model',
|
||||
);
|
||||
const secondOpenAiNode = secondCall.nodes?.find(
|
||||
(node: INodeUi) => node.name === 'OpenAI Model',
|
||||
);
|
||||
|
||||
expect(firstOpenAiNode?.credentials?.openAiApi?.id).toBe('cred-stored-456');
|
||||
expect(secondOpenAiNode?.credentials?.openAiApi?.id).toBe('cred-stored-456');
|
||||
});
|
||||
|
||||
it('should handle workflow where OpenAI Model node has existing credentials object', async () => {
|
||||
const mockWorkflow = { id: 'workflow-123', name: 'AI Agent workflow' };
|
||||
mockCreateNewWorkflow.mockResolvedValue(mockWorkflow);
|
||||
mockLocalStorageValue.value = 'cred-new-789';
|
||||
|
||||
await store.createAndOpenAiWorkflow('card');
|
||||
|
||||
expect(mockCreateNewWorkflow).toHaveBeenCalled();
|
||||
const createCall = mockCreateNewWorkflow.mock.calls[0][0];
|
||||
const openAiNode = createCall.nodes?.find((node: INodeUi) => node.name === 'OpenAI Model');
|
||||
|
||||
// Should add the credential even if credentials object already exists
|
||||
expect(openAiNode?.credentials?.openAiApi).toEqual({
|
||||
id: 'cred-new-789',
|
||||
name: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
import { useUsersStore } from '@/features/settings/users/users.store';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { STORES } from '@n8n/stores';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
import { OPEN_AI_API_CREDENTIAL_TYPE, deepCopy } from 'n8n-workflow';
|
||||
import type { WorkflowDataCreate } from '@n8n/rest-api-client';
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter, type RouteLocationNormalized } from 'vue-router';
|
||||
import { READY_TO_RUN_AI_WORKFLOW } from '../workflows/aiWorkflow';
|
||||
import { useEmptyStateDetection } from '../composables/useEmptyStateDetection';
|
||||
import { useTelemetry } from '@/app/composables/useTelemetry';
|
||||
import { useToast } from '@/app/composables/useToast';
|
||||
import { VIEWS } from '@/app/constants';
|
||||
import { useSettingsStore } from '@/app/stores/settings.store';
|
||||
import { useWorkflowsStore } from '@/app/stores/workflows.store';
|
||||
import { useCredentialsStore } from '@/features/credentials/credentials.store';
|
||||
|
||||
const LOCAL_STORAGE_CREDENTIAL_KEY = 'N8N_READY_TO_RUN_OPENAI_CREDENTIAL_ID';
|
||||
|
||||
export const useReadyToRunStore = defineStore(STORES.READY_TO_RUN, () => {
|
||||
const telemetry = useTelemetry();
|
||||
const i18n = useI18n();
|
||||
const toast = useToast();
|
||||
const router = useRouter();
|
||||
const credentialsStore = useCredentialsStore();
|
||||
const usersStore = useUsersStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
|
||||
const claimedCredentialIdRef = useLocalStorage(LOCAL_STORAGE_CREDENTIAL_KEY, '');
|
||||
|
||||
const claimingCredits = ref(false);
|
||||
|
||||
const userHasOpenAiCredentialAlready = computed(
|
||||
() =>
|
||||
!!credentialsStore.allCredentials.filter(
|
||||
(credential) => credential.type === OPEN_AI_API_CREDENTIAL_TYPE,
|
||||
).length,
|
||||
);
|
||||
|
||||
const userHasClaimedAiCreditsAlready = computed(
|
||||
() => !!usersStore.currentUser?.settings?.userClaimedAiCredits,
|
||||
);
|
||||
|
||||
const userCanClaimOpenAiCredits = computed(() => {
|
||||
return (
|
||||
settingsStore.isAiCreditsEnabled &&
|
||||
!userHasOpenAiCredentialAlready.value &&
|
||||
!userHasClaimedAiCreditsAlready.value
|
||||
);
|
||||
});
|
||||
|
||||
const trackExecuteAiWorkflow = (status: string) => {
|
||||
telemetry.track('User executed ready to run AI workflow', {
|
||||
status,
|
||||
});
|
||||
};
|
||||
|
||||
const trackExecuteAiWorkflowSuccess = () => {
|
||||
telemetry.track('User executed ready to run AI workflow successfully');
|
||||
};
|
||||
|
||||
const claimFreeAiCredits = async (projectId?: string) => {
|
||||
claimingCredits.value = true;
|
||||
|
||||
try {
|
||||
const credential = await credentialsStore.claimFreeAiCredits(projectId);
|
||||
|
||||
claimedCredentialIdRef.value = credential.id;
|
||||
|
||||
telemetry.track('User claimed OpenAI credits');
|
||||
return credential;
|
||||
} catch (e) {
|
||||
toast.showError(
|
||||
e,
|
||||
i18n.baseText('freeAi.credits.showError.claim.title'),
|
||||
i18n.baseText('freeAi.credits.showError.claim.message'),
|
||||
);
|
||||
throw e;
|
||||
} finally {
|
||||
claimingCredits.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const createAndOpenAiWorkflow = async (source: 'card' | 'button', parentFolderId?: string) => {
|
||||
telemetry.track('User opened ready to run AI workflow', {
|
||||
source,
|
||||
});
|
||||
|
||||
try {
|
||||
let workflowToCreate: WorkflowDataCreate = {
|
||||
...READY_TO_RUN_AI_WORKFLOW,
|
||||
parentFolderId,
|
||||
};
|
||||
|
||||
const credentialId = claimedCredentialIdRef.value;
|
||||
if (credentialId && workflowToCreate.nodes) {
|
||||
const clonedWorkflow = deepCopy(workflowToCreate);
|
||||
const openAiNode = clonedWorkflow.nodes?.find((node) => node.name === 'OpenAI Model');
|
||||
if (openAiNode) {
|
||||
openAiNode.credentials ??= {};
|
||||
openAiNode.credentials[OPEN_AI_API_CREDENTIAL_TYPE] = {
|
||||
id: credentialId,
|
||||
name: '',
|
||||
};
|
||||
}
|
||||
workflowToCreate = clonedWorkflow;
|
||||
}
|
||||
|
||||
const createdWorkflow = await workflowsStore.createNewWorkflow(workflowToCreate);
|
||||
|
||||
await router.push({
|
||||
name: VIEWS.WORKFLOW,
|
||||
params: { name: createdWorkflow.id },
|
||||
});
|
||||
|
||||
return createdWorkflow;
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('generic.error'));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const claimCreditsAndOpenWorkflow = async (
|
||||
source: 'card' | 'button',
|
||||
parentFolderId?: string,
|
||||
projectId?: string,
|
||||
) => {
|
||||
await claimFreeAiCredits(projectId);
|
||||
await createAndOpenAiWorkflow(source, parentFolderId);
|
||||
|
||||
if (usersStore?.currentUser?.settings) {
|
||||
usersStore.currentUser.settings.userClaimedAiCredits = true;
|
||||
}
|
||||
};
|
||||
|
||||
const getCardVisibility = (canCreate: boolean | undefined, readOnlyEnv: boolean) => {
|
||||
return userCanClaimOpenAiCredits.value && !readOnlyEnv && canCreate;
|
||||
};
|
||||
|
||||
const getButtonVisibility = (
|
||||
hasWorkflows: boolean,
|
||||
canCreate: boolean | undefined,
|
||||
readOnlyEnv: boolean,
|
||||
) => {
|
||||
return userCanClaimOpenAiCredits.value && !readOnlyEnv && canCreate && hasWorkflows;
|
||||
};
|
||||
|
||||
const { isTrulyEmpty } = useEmptyStateDetection();
|
||||
|
||||
const getSimplifiedLayoutVisibility = (route: RouteLocationNormalized) => {
|
||||
return isTrulyEmpty(route);
|
||||
};
|
||||
|
||||
return {
|
||||
claimingCredits,
|
||||
userCanClaimOpenAiCredits,
|
||||
claimFreeAiCredits,
|
||||
createAndOpenAiWorkflow,
|
||||
claimCreditsAndOpenWorkflow,
|
||||
getCardVisibility,
|
||||
getButtonVisibility,
|
||||
getSimplifiedLayoutVisibility,
|
||||
trackExecuteAiWorkflow,
|
||||
trackExecuteAiWorkflowSuccess,
|
||||
};
|
||||
});
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
import type { WorkflowDataCreate } from '@n8n/rest-api-client';
|
||||
|
||||
export const READY_TO_RUN_AI_WORKFLOW: WorkflowDataCreate = {
|
||||
name: 'AI Agent workflow',
|
||||
meta: { templateId: 'ready-to-run-ai-workflow' },
|
||||
nodes: [
|
||||
{
|
||||
parameters: {
|
||||
url: 'https://www.theverge.com/rss/index.xml',
|
||||
options: {},
|
||||
},
|
||||
type: 'n8n-nodes-base.rssFeedReadTool',
|
||||
typeVersion: 1.2,
|
||||
position: [-16, 768],
|
||||
id: '303e9b4e-cc4e-4d8a-8ede-7550f070d212',
|
||||
name: 'Get Tech News',
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
toolDescription: 'Reads the news',
|
||||
url: '=https://feeds.bbci.co.uk/news/world/rss.xml',
|
||||
options: {},
|
||||
},
|
||||
type: 'n8n-nodes-base.rssFeedReadTool',
|
||||
typeVersion: 1.2,
|
||||
position: [112, 768],
|
||||
id: '4090a753-f131-40b1-87c3-cf74d5a7e325',
|
||||
name: 'Get World News',
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
rule: {
|
||||
interval: [
|
||||
{
|
||||
triggerAtHour: 7,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
type: 'n8n-nodes-base.scheduleTrigger',
|
||||
typeVersion: 1.2,
|
||||
position: [-560, 752],
|
||||
id: '651543b5-0213-433f-8760-57d62b8d6d64',
|
||||
name: 'Run every day at 7AM',
|
||||
notesInFlow: true,
|
||||
notes: 'Double-click to open',
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: [
|
||||
{
|
||||
id: '85b5c530-2c13-4424-ab83-05979bc879a5',
|
||||
name: 'output',
|
||||
value: '={{ $json.output }}',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {},
|
||||
},
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [160, 544],
|
||||
id: '99f7bb9e-f8c0-43ca-a9a8-a76634ac9611',
|
||||
name: 'Output',
|
||||
notesInFlow: true,
|
||||
notes: 'Double-click to open',
|
||||
},
|
||||
{
|
||||
parameters: {},
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [-560, 544],
|
||||
id: 'a0390291-6794-4673-9a6a-5c3d3a5d9e4b',
|
||||
name: 'Click ‘Execute workflow’ to run',
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
content: '## ⚡ Start here:',
|
||||
height: 224,
|
||||
width: 224,
|
||||
color: 7,
|
||||
},
|
||||
type: 'n8n-nodes-base.stickyNote',
|
||||
typeVersion: 1,
|
||||
position: [-624, 480],
|
||||
id: 'fac5929f-e065-4474-96b1-7bcc06834238',
|
||||
name: 'Sticky Note',
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
model: {
|
||||
__rl: true,
|
||||
mode: 'list',
|
||||
value: 'gpt-4.1-mini',
|
||||
},
|
||||
options: {},
|
||||
},
|
||||
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
typeVersion: 1.2,
|
||||
position: [-272, 768],
|
||||
id: 'b16482e8-0d48-4426-aa93-c3fee11dd3cd',
|
||||
name: 'OpenAI Model',
|
||||
notesInFlow: true,
|
||||
credentials: {},
|
||||
notes: 'Double-click to open',
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
content: '@[youtube](cMyOkQ4N-5M)',
|
||||
height: 512,
|
||||
width: 902,
|
||||
color: 7,
|
||||
},
|
||||
type: 'n8n-nodes-base.stickyNote',
|
||||
typeVersion: 1,
|
||||
position: [-352, -96],
|
||||
id: 'ec65e69e-77fa-4912-a4af-49e0a248e2c8',
|
||||
name: 'Sticky Note3',
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
promptType: 'define',
|
||||
text: '=Summarize world news and tech news from the last 24 hours. \nSkip your comments. \nThe titles should be "World news:" and "Tech news:" \nToday is {{ $today }}',
|
||||
options: {},
|
||||
},
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
typeVersion: 2.2,
|
||||
position: [-272, 544],
|
||||
id: '084d56aa-d157-4964-9073-b36d9d9589c5',
|
||||
name: 'AI Summary Agent',
|
||||
notesInFlow: true,
|
||||
notes: 'Double-click to open',
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
content: '### Double click here to see the results:',
|
||||
height: 240,
|
||||
width: 192,
|
||||
color: 7,
|
||||
},
|
||||
type: 'n8n-nodes-base.stickyNote',
|
||||
typeVersion: 1,
|
||||
position: [112, 464],
|
||||
id: 'a4b7a69a-0db8-4b9b-a81d-fd83378043a3',
|
||||
name: 'Sticky Note1',
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
content:
|
||||
'### 📰 Daily AI Summary\n\n\nThis workflow gets the latest news and asks AI to summarize it for you.\n\n⭐ Bonus: Send the summary via email by connecting your Gmail account\n\n▶ Watch the video to get started ',
|
||||
height: 272,
|
||||
width: 224,
|
||||
color: 5,
|
||||
},
|
||||
type: 'n8n-nodes-base.stickyNote',
|
||||
typeVersion: 1,
|
||||
position: [-624, 32],
|
||||
id: '74d80857-5e63-47a8-8e86-8ecd10fd5f9e',
|
||||
name: 'Sticky Note2',
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
subject: 'Your news daily summary',
|
||||
emailType: 'text',
|
||||
message: '={{ $json.output }}',
|
||||
options: {},
|
||||
},
|
||||
type: 'n8n-nodes-base.gmail',
|
||||
typeVersion: 2.1,
|
||||
position: [432, 544],
|
||||
id: '45625d0d-bf26-4379-9eed-7bbc8e5d87a5',
|
||||
name: 'Send summary by email',
|
||||
webhookId: '093b04f1-5e78-4926-9863-1b100d6f2ead',
|
||||
notesInFlow: true,
|
||||
credentials: {},
|
||||
notes: 'Double-click to open',
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
'Get Tech News': {
|
||||
ai_tool: [
|
||||
[
|
||||
{
|
||||
node: 'AI Summary Agent',
|
||||
type: 'ai_tool',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
'Get World News': {
|
||||
ai_tool: [
|
||||
[
|
||||
{
|
||||
node: 'AI Summary Agent',
|
||||
type: 'ai_tool',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
'Run every day at 7AM': {
|
||||
main: [
|
||||
[
|
||||
{
|
||||
node: 'AI Summary Agent',
|
||||
type: 'main',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
'Click ‘Execute workflow’ to run': {
|
||||
main: [
|
||||
[
|
||||
{
|
||||
node: 'AI Summary Agent',
|
||||
type: 'main',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
'OpenAI Model': {
|
||||
ai_languageModel: [
|
||||
[
|
||||
{
|
||||
node: 'AI Summary Agent',
|
||||
type: 'ai_languageModel',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
'AI Summary Agent': {
|
||||
main: [
|
||||
[
|
||||
{
|
||||
node: 'Output',
|
||||
type: 'main',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
Output: {
|
||||
main: [[]],
|
||||
},
|
||||
},
|
||||
pinData: {},
|
||||
};
|
||||
|
|
@ -23,7 +23,7 @@ export class TestEntryComposer {
|
|||
*/
|
||||
async fromBlankCanvas() {
|
||||
await this.n8n.goHome();
|
||||
await this.n8n.workflows.addResource.workflow();
|
||||
await this.n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
// Verify we're on canvas
|
||||
await this.n8n.canvas.canvasPane().isVisible();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,23 @@ export class WorkflowComposer {
|
|||
await responsePromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new workflow by clicking the add workflow button
|
||||
* @param workflowName - The name of the workflow to create
|
||||
*/
|
||||
async createWorkflowFromSidebar(workflowName = 'My New Workflow') {
|
||||
await this.n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
await this.n8n.canvas.setWorkflowName(workflowName);
|
||||
|
||||
const responsePromise = this.n8n.page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/rest/workflows') && response.request().method() === 'POST',
|
||||
);
|
||||
await this.n8n.canvas.saveWorkflow();
|
||||
|
||||
await responsePromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new workflow by importing a JSON file
|
||||
* @param fileName - The workflow JSON file name (e.g., 'test_pdf_workflow.json', will search in workflows folder)
|
||||
|
|
@ -153,9 +170,9 @@ export class WorkflowComposer {
|
|||
});
|
||||
const workflows = await response.json();
|
||||
return workflows.data[0];
|
||||
}
|
||||
|
||||
/**
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a workflow to a different project or user.
|
||||
* @param workflowName - The name of the workflow to move
|
||||
* @param projectNameOrEmail - The destination project name or user email
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ test.describe('Workflows', () => {
|
|||
test('should create a new workflow using add workflow button and save successfully', async ({
|
||||
n8n,
|
||||
}) => {
|
||||
const { projectId } = await n8n.projectComposer.createProject();
|
||||
await n8n.page.goto(`projects/${projectId}/workflows`);
|
||||
await n8n.workflows.addResource.workflow();
|
||||
|
||||
const uniqueIdForCreate = nanoid(8);
|
||||
|
|
@ -39,7 +41,7 @@ test.describe('Workflows', () => {
|
|||
const specificName = `Specific Test ${uniqueId}`;
|
||||
const genericName = `Generic Test ${uniqueId}`;
|
||||
|
||||
await n8n.workflowComposer.createWorkflow(specificName);
|
||||
await n8n.workflowComposer.createWorkflowFromSidebar(specificName);
|
||||
await n8n.goHome();
|
||||
await n8n.workflowComposer.createWorkflow(genericName);
|
||||
await n8n.goHome();
|
||||
|
|
@ -63,7 +65,7 @@ test.describe('Workflows', () => {
|
|||
test('should archive and unarchive a workflow', async ({ n8n }) => {
|
||||
const uniqueIdForArchive = nanoid(8);
|
||||
const workflowName = `Archive Test ${uniqueIdForArchive}`;
|
||||
await n8n.workflowComposer.createWorkflow(workflowName);
|
||||
await n8n.workflowComposer.createWorkflowFromSidebar(workflowName);
|
||||
await n8n.goHome();
|
||||
|
||||
// Create a second workflow so we can still see filters
|
||||
|
|
@ -85,7 +87,7 @@ test.describe('Workflows', () => {
|
|||
test('should delete an archived workflow', async ({ n8n }) => {
|
||||
const uniqueIdForDelete = nanoid(8);
|
||||
const workflowName = `Delete Test ${uniqueIdForDelete}`;
|
||||
await n8n.workflowComposer.createWorkflow(workflowName);
|
||||
await n8n.workflowComposer.createWorkflowFromSidebar(workflowName);
|
||||
await n8n.goHome();
|
||||
await n8n.workflowComposer.createWorkflow();
|
||||
await n8n.goHome();
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { resolveFromRoot } from '../../utils/path-helper';
|
|||
test.describe('Undo/Redo', () => {
|
||||
test.beforeEach(async ({ n8n }) => {
|
||||
await n8n.goHome();
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
});
|
||||
|
||||
test('should undo/redo deleting node using context menu', async ({ n8n }) => {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { test, expect } from '../../fixtures/base';
|
|||
test.describe('Canvas Actions', () => {
|
||||
test.beforeEach(async ({ n8n }) => {
|
||||
await n8n.goHome();
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
});
|
||||
|
||||
test('should add first step', async ({ n8n }) => {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ test.describe('Data pinning', () => {
|
|||
|
||||
test.describe('Pin data operations', () => {
|
||||
test('should be able to pin node output', async ({ n8n }) => {
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
await n8n.canvas.addNode(NODES.SCHEDULE_TRIGGER);
|
||||
|
||||
await n8n.ndv.execute();
|
||||
|
|
@ -52,7 +52,7 @@ test.describe('Data pinning', () => {
|
|||
});
|
||||
|
||||
test('should be able to set custom pinned data', async ({ n8n }) => {
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
await n8n.canvas.addNode(NODES.SCHEDULE_TRIGGER);
|
||||
|
||||
await expect(n8n.ndv.getEditPinnedDataButton()).toBeVisible();
|
||||
|
|
@ -74,7 +74,7 @@ test.describe('Data pinning', () => {
|
|||
});
|
||||
|
||||
test('should display pin data edit button for Webhook node', async ({ n8n }) => {
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
await n8n.canvas.addNode(NODES.WEBHOOK);
|
||||
|
||||
const runDataHeader = n8n.ndv.getRunDataPaneHeader();
|
||||
|
|
@ -83,7 +83,7 @@ test.describe('Data pinning', () => {
|
|||
});
|
||||
|
||||
test('should duplicate pinned data when duplicating node', async ({ n8n }) => {
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
await n8n.canvas.addNode(NODES.SCHEDULE_TRIGGER);
|
||||
await n8n.ndv.close();
|
||||
|
||||
|
|
@ -117,7 +117,7 @@ test.describe('Data pinning', () => {
|
|||
});
|
||||
expect(actualMaxSize).toBe(maxPinnedDataSize);
|
||||
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
await n8n.canvas.addNode(NODES.SCHEDULE_TRIGGER);
|
||||
await n8n.ndv.close();
|
||||
|
||||
|
|
@ -138,7 +138,7 @@ test.describe('Data pinning', () => {
|
|||
});
|
||||
|
||||
test('should show error when pin data JSON is invalid', async ({ n8n }) => {
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
await n8n.canvas.addNode(NODES.SCHEDULE_TRIGGER);
|
||||
await n8n.ndv.close();
|
||||
|
||||
|
|
@ -158,7 +158,7 @@ test.describe('Data pinning', () => {
|
|||
|
||||
test.describe('Advanced pinning scenarios', () => {
|
||||
test('should be able to reference paired items in node before pinned data', async ({ n8n }) => {
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
await n8n.canvas.addNode(NODES.MANUAL_TRIGGER);
|
||||
|
||||
await n8n.canvas.addNode(NODES.HTTP_REQUEST);
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ test.describe('Data transformation expressions', () => {
|
|||
}
|
||||
|
||||
test('$json + native string methods', async ({ n8n }) => {
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
|
||||
await n8n.canvas.addNode('Schedule Trigger');
|
||||
await n8n.ndv.setPinnedData([{ myStr: 'Monday' }]);
|
||||
|
|
@ -38,7 +38,7 @@ test.describe('Data transformation expressions', () => {
|
|||
});
|
||||
|
||||
test('$json + n8n string methods', async ({ n8n }) => {
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
|
||||
await n8n.canvas.addNode('Schedule Trigger');
|
||||
await n8n.ndv.setPinnedData([{ myStr: 'hello@n8n.io is an email' }]);
|
||||
|
|
@ -59,7 +59,7 @@ test.describe('Data transformation expressions', () => {
|
|||
});
|
||||
|
||||
test('$json + native numeric methods', async ({ n8n }) => {
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
|
||||
await n8n.canvas.addNode('Schedule Trigger');
|
||||
await n8n.ndv.setPinnedData([{ myNum: 9.123 }]);
|
||||
|
|
@ -80,7 +80,7 @@ test.describe('Data transformation expressions', () => {
|
|||
});
|
||||
|
||||
test('$json + n8n numeric methods', async ({ n8n }) => {
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
|
||||
await n8n.canvas.addNode('Schedule Trigger');
|
||||
await n8n.ndv.setPinnedData([{ myStr: 'hello@n8n.io is an email' }]);
|
||||
|
|
@ -101,7 +101,7 @@ test.describe('Data transformation expressions', () => {
|
|||
});
|
||||
|
||||
test('$json + native array access', async ({ n8n }) => {
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
|
||||
await n8n.canvas.addNode('Schedule Trigger');
|
||||
await n8n.ndv.setPinnedData([{ myArr: [1, 2, 3] }]);
|
||||
|
|
@ -123,7 +123,7 @@ test.describe('Data transformation expressions', () => {
|
|||
});
|
||||
|
||||
test('$json + n8n array methods', async ({ n8n }) => {
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
|
||||
await n8n.canvas.addNode('Schedule Trigger');
|
||||
await n8n.ndv.setPinnedData([{ myArr: [1, 2, 3] }]);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ test.describe('Schedule Trigger node', () => {
|
|||
});
|
||||
|
||||
test('should execute schedule trigger node and return timestamp in output', async ({ n8n }) => {
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
await n8n.canvas.addNode('Schedule Trigger');
|
||||
|
||||
await n8n.ndv.execute();
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ test.describe('@isolated', () => {
|
|||
await n8n.canvas.saveWorkflow();
|
||||
|
||||
await n8n.navigate.toWorkflows();
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
await n8n.canvas.importWorkflow('Test_workflow_1.json', 'Workflow W2');
|
||||
});
|
||||
|
||||
|
|
@ -366,7 +366,7 @@ test.describe('@isolated', () => {
|
|||
await n8n.credentialsComposer.createFromList('Notion API', { apiKey: 'test' });
|
||||
|
||||
await n8n.navigate.toWorkflows();
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
|
||||
await n8n.canvas.addNode('Notion');
|
||||
await n8n.canvas.getFirstAction().click();
|
||||
|
|
@ -384,7 +384,7 @@ test.describe('@isolated', () => {
|
|||
await n8n.credentialsComposer.createFromList('Notion API', { apiKey: 'test' });
|
||||
|
||||
await n8n.navigate.toWorkflows();
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
await n8n.canvas.setWorkflowName(workflowName);
|
||||
await n8n.page.keyboard.press('Enter');
|
||||
await n8n.canvas.openShareModal();
|
||||
|
|
@ -431,7 +431,7 @@ test.describe('@isolated', () => {
|
|||
await n8n.navigate.toCredentials();
|
||||
await n8n.credentialsComposer.createFromList('Notion API', { apiKey: 'test' });
|
||||
await n8n.navigate.toWorkflows();
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
await n8n.canvas.setWorkflowName(workflowName);
|
||||
await n8n.page.keyboard.press('Enter');
|
||||
await n8n.canvas.openShareModal();
|
||||
|
|
@ -466,7 +466,7 @@ test.describe('@isolated', () => {
|
|||
|
||||
await n8n.api.signin('owner');
|
||||
await n8n.navigate.toWorkflows();
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
await n8n.canvas.addNode('Notion');
|
||||
await n8n.canvas.getFirstAction().click();
|
||||
|
||||
|
|
|
|||
|
|
@ -11,14 +11,14 @@ test.describe('User Management', () => {
|
|||
INSTANCE_OWNER_CREDENTIALS.email,
|
||||
INSTANCE_OWNER_CREDENTIALS.password,
|
||||
);
|
||||
await expect(n8n.workflows.getProjectName()).toBeVisible();
|
||||
await expect(n8n.sideBar.getUserMenu()).toBeVisible();
|
||||
});
|
||||
|
||||
test('should prevent non-owners to access UM settings', async ({ n8n }) => {
|
||||
// This creates a new user in the same context, so the cookies are refreshed and owner is no longer logged in
|
||||
await n8n.api.users.create();
|
||||
await n8n.navigate.toUsers();
|
||||
await expect(n8n.workflows.getProjectName()).toBeVisible();
|
||||
await expect(n8n.workflows.getNewWorkflowCard()).toBeVisible();
|
||||
});
|
||||
|
||||
test('should allow instance owner to access UM settings', async ({ n8n }) => {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ test.describe('ADO-2270 Save button resets on webhook node open', () => {
|
|||
test('should not reset the save button if webhook node is opened and closed', async ({ n8n }) => {
|
||||
await n8n.goHome();
|
||||
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
await n8n.canvas.addNode('Webhook');
|
||||
|
||||
await n8n.page.keyboard.press('Escape');
|
||||
|
|
|
|||
|
|
@ -41,6 +41,12 @@ const setupCloudTest = async (
|
|||
await n8n.page.waitForLoadState();
|
||||
};
|
||||
|
||||
const createProjectAndNavigate = async (n8n: n8nPage) => {
|
||||
await n8n.goHome();
|
||||
const { projectId } = await n8n.projectComposer.createProject();
|
||||
await n8n.page.goto(`projects/${projectId}/workflows`);
|
||||
};
|
||||
|
||||
test.describe('Cloud @db:reset @auth:owner', () => {
|
||||
test.describe('Trial Banner', () => {
|
||||
test('should render trial banner for opt-in cloud user', async ({ n8n, setupRequirements }) => {
|
||||
|
|
@ -84,7 +90,7 @@ test.describe('Cloud @db:reset @auth:owner', () => {
|
|||
await n8n.api.setEnvFeatureFlags({ '026_easy_ai_workflow': 'control' });
|
||||
|
||||
await setupCloudTest(n8n, setupRequirements, cloudTrialRequirements);
|
||||
await n8n.navigate.toWorkflows();
|
||||
await createProjectAndNavigate(n8n);
|
||||
|
||||
await expect(n8n.workflows.getEasyAiWorkflowCard()).toBeHidden();
|
||||
});
|
||||
|
|
@ -96,7 +102,7 @@ test.describe('Cloud @db:reset @auth:owner', () => {
|
|||
await n8n.api.setEnvFeatureFlags({ '026_easy_ai_workflow': 'variant' });
|
||||
|
||||
await setupCloudTest(n8n, setupRequirements, cloudTrialRequirements);
|
||||
await n8n.navigate.toWorkflows();
|
||||
await createProjectAndNavigate(n8n);
|
||||
|
||||
await expect(n8n.workflows.getEasyAiWorkflowCard()).toBeVisible();
|
||||
});
|
||||
|
|
@ -108,7 +114,7 @@ test.describe('Cloud @db:reset @auth:owner', () => {
|
|||
await n8n.api.setEnvFeatureFlags({ '026_easy_ai_workflow': 'variant' });
|
||||
|
||||
await setupCloudTest(n8n, setupRequirements, cloudTrialRequirements);
|
||||
await n8n.navigate.toWorkflows();
|
||||
await createProjectAndNavigate(n8n);
|
||||
|
||||
await n8n.workflows.clickEasyAiWorkflowCard();
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ test.describe('Debug mode', () => {
|
|||
|
||||
// Helper function to create basic workflow
|
||||
async function createBasicWorkflow(n8n: n8nPage, url = URLS.FAILING) {
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
await n8n.canvas.addNode('Manual Trigger');
|
||||
await n8n.canvas.addNode('HTTP Request');
|
||||
await n8n.ndv.fillParameterInput('URL', url);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { test, expect } from '../../fixtures/base';
|
|||
test.describe('Resource Mapper', () => {
|
||||
test.beforeEach(async ({ n8n }) => {
|
||||
await n8n.goHome();
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
await n8n.canvas.addNode(E2E_TEST_NODE_NAME, { action: 'Resource Mapping Component' });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ const SCHEDULE_TRIGGER_NODE_NAME = 'Schedule Trigger';
|
|||
test.describe('Workflow Production Checklist', () => {
|
||||
test.beforeEach(async ({ n8n }) => {
|
||||
await n8n.goHome();
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
});
|
||||
|
||||
test('should show suggested actions automatically when workflow is first activated', async ({
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ test.describe('Code node', () => {
|
|||
test.describe('Code editor', () => {
|
||||
test.beforeEach(async ({ n8n }) => {
|
||||
await n8n.goHome();
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
|
||||
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript' });
|
||||
});
|
||||
|
|
@ -95,7 +95,7 @@ return
|
|||
.serial('Run Once for Each Item', () => {
|
||||
test('should show lint errors in `runOnceForEachItem` mode', async ({ n8n }) => {
|
||||
await n8n.start.fromHome();
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
|
||||
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript' });
|
||||
await n8n.ndv.toggleCodeMode('Run Once for Each Item');
|
||||
|
|
@ -120,7 +120,7 @@ return []
|
|||
test.beforeEach(async ({ api, n8n }) => {
|
||||
await api.enableFeature('askAi');
|
||||
await n8n.goHome();
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
|
||||
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript' });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -262,7 +262,7 @@ test.describe('Workflow Actions', () => {
|
|||
response.url().includes('/rest/workflows') && response.request().method() === 'GET',
|
||||
);
|
||||
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
|
||||
const workflowsResponse = await workflowsResponsePromise;
|
||||
const responseBody = await workflowsResponse.json();
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { test, expect } from '../../fixtures/base';
|
|||
test.describe('AI-716 Correctly set up agent model shows error', () => {
|
||||
test('should not show error when adding a sub-node with credential set-up', async ({ n8n }) => {
|
||||
await n8n.goHome();
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
|
||||
await n8n.canvas.addNode('AI Agent');
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { test, expect } from '../../../fixtures/base';
|
|||
test.describe('Canvas Node Actions', () => {
|
||||
test.beforeEach(async ({ n8n }) => {
|
||||
await n8n.goHome();
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
});
|
||||
|
||||
test.describe('Node Search and Add', () => {
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ m82JpEptTfAxFHtd8+Sb0U2G
|
|||
},
|
||||
});
|
||||
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
// Import the evaluations workflow
|
||||
await n8n.canvas.importWorkflow('evaluations_loop.json', 'Evaluations');
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { expect, test } from '../../fixtures/base';
|
|||
test.describe('PDF Test', () => {
|
||||
test('Can read and write PDF files and extract text', async ({ n8n }) => {
|
||||
await n8n.goHome();
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
await n8n.canvas.importWorkflow('test_pdf_workflow.json', 'PDF Workflow');
|
||||
await n8n.canvas.clickExecuteWorkflowButton();
|
||||
await expect(
|
||||
|
|
|
|||
|
|
@ -198,6 +198,8 @@ test.describe('Security Notifications', () => {
|
|||
await setupApiFailure(n8n);
|
||||
|
||||
await n8n.goHome();
|
||||
const { projectId } = await n8n.projectComposer.createProject();
|
||||
await n8n.page.goto(`projects/${projectId}/workflows`);
|
||||
|
||||
// Verify no security notification appears on API failure
|
||||
const notification = n8n.notifications.getNotificationByTitle('Critical update available');
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ test.use({
|
|||
test.describe('Task Runner Capability @capability:task-runner', () => {
|
||||
test('should execute Javascript with task runner enabled', async ({ n8n }) => {
|
||||
await n8n.goHome();
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
|
||||
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in JavaScript', closeNDV: true });
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ test.describe('Task Runner Capability @capability:task-runner', () => {
|
|||
|
||||
test('should execute Python with task runner enabled', async ({ n8n }) => {
|
||||
await n8n.goHome();
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
await n8n.canvas.addNode(MANUAL_TRIGGER_NODE_NAME);
|
||||
await n8n.canvas.addNode(CODE_NODE_NAME, { action: 'Code in Python (Beta)', closeNDV: true });
|
||||
await n8n.workflowComposer.executeWorkflowAndWaitForNotification(
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ export async function setupTestRequirements(
|
|||
try {
|
||||
// Import workflow using the n8n page object
|
||||
await n8n.goHome();
|
||||
await n8n.workflows.addResource.workflow();
|
||||
await n8n.sideBar.addWorkflowFromUniversalAdd('Personal');
|
||||
await n8n.canvas.importWorkflow(name, workflowData);
|
||||
} catch (error) {
|
||||
throw new TestError(`Failed to create workflow ${name}: ${String(error)}`);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user