mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
refactor(editor): Migrate workflows store to setup function with composition API (no-changelog) (#9270)
This commit is contained in:
parent
6b6e8dfc33
commit
f64a41d617
|
@ -253,6 +253,12 @@ export interface IWorkflowToShare extends IWorkflowDataUpdate {
|
||||||
meta?: WorkflowMetadata;
|
meta?: WorkflowMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NewWorkflowResponse {
|
||||||
|
name: string;
|
||||||
|
onboardingFlowEnabled?: boolean;
|
||||||
|
defaultSettings: IWorkflowSettings;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IWorkflowTemplateNode
|
export interface IWorkflowTemplateNode
|
||||||
extends Pick<INodeUi, 'name' | 'type' | 'position' | 'parameters' | 'typeVersion' | 'webhookId'> {
|
extends Pick<INodeUi, 'name' | 'type' | 'position' | 'parameters' | 'typeVersion' | 'webhookId'> {
|
||||||
// The credentials in a template workflow have a different type than in a regular workflow
|
// The credentials in a template workflow have a different type than in a regular workflow
|
||||||
|
|
|
@ -1,9 +1,20 @@
|
||||||
import type { IExecutionsCurrentSummaryExtended, IRestApiContext } from '@/Interface';
|
import type {
|
||||||
|
IExecutionResponse,
|
||||||
|
IExecutionsCurrentSummaryExtended,
|
||||||
|
IRestApiContext,
|
||||||
|
IWorkflowDb,
|
||||||
|
NewWorkflowResponse,
|
||||||
|
} from '@/Interface';
|
||||||
import type { ExecutionFilters, ExecutionOptions, IDataObject } from 'n8n-workflow';
|
import type { ExecutionFilters, ExecutionOptions, IDataObject } from 'n8n-workflow';
|
||||||
import { makeRestApiRequest } from '@/utils/apiUtils';
|
import { makeRestApiRequest } from '@/utils/apiUtils';
|
||||||
|
|
||||||
export async function getNewWorkflow(context: IRestApiContext, name?: string) {
|
export async function getNewWorkflow(context: IRestApiContext, name?: string) {
|
||||||
const response = await makeRestApiRequest(context, 'GET', '/workflows/new', name ? { name } : {});
|
const response = await makeRestApiRequest<NewWorkflowResponse>(
|
||||||
|
context,
|
||||||
|
'GET',
|
||||||
|
'/workflows/new',
|
||||||
|
name ? { name } : {},
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
name: response.name,
|
name: response.name,
|
||||||
onboardingFlowEnabled: response.onboardingFlowEnabled === true,
|
onboardingFlowEnabled: response.onboardingFlowEnabled === true,
|
||||||
|
@ -14,17 +25,17 @@ export async function getNewWorkflow(context: IRestApiContext, name?: string) {
|
||||||
export async function getWorkflow(context: IRestApiContext, id: string, filter?: object) {
|
export async function getWorkflow(context: IRestApiContext, id: string, filter?: object) {
|
||||||
const sendData = filter ? { filter } : undefined;
|
const sendData = filter ? { filter } : undefined;
|
||||||
|
|
||||||
return await makeRestApiRequest(context, 'GET', `/workflows/${id}`, sendData);
|
return await makeRestApiRequest<IWorkflowDb>(context, 'GET', `/workflows/${id}`, sendData);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getWorkflows(context: IRestApiContext, filter?: object) {
|
export async function getWorkflows(context: IRestApiContext, filter?: object) {
|
||||||
const sendData = filter ? { filter } : undefined;
|
const sendData = filter ? { filter } : undefined;
|
||||||
|
|
||||||
return await makeRestApiRequest(context, 'GET', '/workflows', sendData);
|
return await makeRestApiRequest<IWorkflowDb[]>(context, 'GET', '/workflows', sendData);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getActiveWorkflows(context: IRestApiContext) {
|
export async function getActiveWorkflows(context: IRestApiContext) {
|
||||||
return await makeRestApiRequest(context, 'GET', '/active-workflows');
|
return await makeRestApiRequest<string[]>(context, 'GET', '/active-workflows');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getActiveExecutions(context: IRestApiContext, filter: IDataObject) {
|
export async function getActiveExecutions(context: IRestApiContext, filter: IDataObject) {
|
||||||
|
@ -42,5 +53,9 @@ export async function getExecutions(
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getExecutionData(context: IRestApiContext, executionId: string) {
|
export async function getExecutionData(context: IRestApiContext, executionId: string) {
|
||||||
return await makeRestApiRequest(context, 'GET', `/executions/${executionId}`);
|
return await makeRestApiRequest<IExecutionResponse | null>(
|
||||||
|
context,
|
||||||
|
'GET',
|
||||||
|
`/executions/${executionId}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,20 @@ import RunData from '@/components/RunData.vue';
|
||||||
import { STORES, VIEWS } from '@/constants';
|
import { STORES, VIEWS } from '@/constants';
|
||||||
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
|
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
import type { IRunDataDisplayMode } from '@/Interface';
|
import type { INodeUi, IRunDataDisplayMode } from '@/Interface';
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import { setActivePinia } from 'pinia';
|
||||||
|
|
||||||
|
const nodes = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
typeVersion: 1,
|
||||||
|
name: 'Test Node',
|
||||||
|
position: [0, 0],
|
||||||
|
type: 'test',
|
||||||
|
parameters: {},
|
||||||
|
},
|
||||||
|
] as INodeUi[];
|
||||||
|
|
||||||
describe('RunData', () => {
|
describe('RunData', () => {
|
||||||
it('should render data correctly even when "item.json" has another "json" key', async () => {
|
it('should render data correctly even when "item.json" has another "json" key', async () => {
|
||||||
|
@ -81,40 +94,8 @@ describe('RunData', () => {
|
||||||
expect(getByTestId('ndv-binary-data_0')).toBeInTheDocument();
|
expect(getByTestId('ndv-binary-data_0')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
const render = (outputData: unknown[], displayMode: IRunDataDisplayMode) =>
|
const render = (outputData: unknown[], displayMode: IRunDataDisplayMode) => {
|
||||||
createComponentRenderer(RunData, {
|
const pinia = createTestingPinia({
|
||||||
props: {
|
|
||||||
node: {
|
|
||||||
name: 'Test Node',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
canPinData: true,
|
|
||||||
showData: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
global: {
|
|
||||||
mocks: {
|
|
||||||
$route: {
|
|
||||||
name: VIEWS.WORKFLOW,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})({
|
|
||||||
props: {
|
|
||||||
node: {
|
|
||||||
id: '1',
|
|
||||||
name: 'Test Node',
|
|
||||||
position: [0, 0],
|
|
||||||
},
|
|
||||||
runIndex: 0,
|
|
||||||
paneType: 'output',
|
|
||||||
isExecuting: false,
|
|
||||||
mappingEnabled: true,
|
|
||||||
distanceFromActive: 0,
|
|
||||||
},
|
|
||||||
pinia: createTestingPinia({
|
|
||||||
initialState: {
|
initialState: {
|
||||||
[STORES.SETTINGS]: {
|
[STORES.SETTINGS]: {
|
||||||
settings: merge({}, SETTINGS_STORE_DEFAULT_STATE.settings),
|
settings: merge({}, SETTINGS_STORE_DEFAULT_STATE.settings),
|
||||||
|
@ -127,16 +108,7 @@ describe('RunData', () => {
|
||||||
},
|
},
|
||||||
[STORES.WORKFLOWS]: {
|
[STORES.WORKFLOWS]: {
|
||||||
workflow: {
|
workflow: {
|
||||||
nodes: [
|
nodes,
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
typeVersion: 1,
|
|
||||||
name: 'Test Node',
|
|
||||||
position: [0, 0],
|
|
||||||
type: 'test',
|
|
||||||
parameters: {},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
workflowExecutionData: {
|
workflowExecutionData: {
|
||||||
id: '1',
|
id: '1',
|
||||||
|
@ -172,6 +144,46 @@ describe('RunData', () => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setActivePinia(pinia);
|
||||||
|
|
||||||
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
vi.mocked(workflowsStore).getNodeByName.mockReturnValue(nodes[0]);
|
||||||
|
|
||||||
|
return createComponentRenderer(RunData, {
|
||||||
|
props: {
|
||||||
|
node: {
|
||||||
|
name: 'Test Node',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
canPinData: true,
|
||||||
|
showData: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
mocks: {
|
||||||
|
$route: {
|
||||||
|
name: VIEWS.WORKFLOW,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})({
|
||||||
|
props: {
|
||||||
|
node: {
|
||||||
|
id: '1',
|
||||||
|
name: 'Test Node',
|
||||||
|
position: [0, 0],
|
||||||
|
},
|
||||||
|
runIndex: 0,
|
||||||
|
paneType: 'output',
|
||||||
|
isExecuting: false,
|
||||||
|
mappingEnabled: true,
|
||||||
|
distanceFromActive: 0,
|
||||||
|
},
|
||||||
|
pinia,
|
||||||
|
});
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
|
@ -8,6 +8,8 @@ import { renderComponent } from '@/__tests__/render';
|
||||||
import { waitFor } from '@testing-library/vue';
|
import { waitFor } from '@testing-library/vue';
|
||||||
import { setActivePinia } from 'pinia';
|
import { setActivePinia } from 'pinia';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import type { INodeUi } from '@/Interface';
|
||||||
|
|
||||||
const EXPRESSION_OUTPUT_TEST_ID = 'inline-expression-editor-output';
|
const EXPRESSION_OUTPUT_TEST_ID = 'inline-expression-editor-output';
|
||||||
|
|
||||||
|
@ -18,7 +20,29 @@ const DEFAULT_SETUP = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const nodes = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
typeVersion: 1,
|
||||||
|
name: 'Test Node',
|
||||||
|
position: [0, 0],
|
||||||
|
type: 'test',
|
||||||
|
parameters: {},
|
||||||
|
},
|
||||||
|
] as INodeUi[];
|
||||||
|
|
||||||
|
const mockResolveExpression = () => {
|
||||||
|
const mock = vi.fn();
|
||||||
|
vi.spyOn(workflowHelpers, 'useWorkflowHelpers').mockReturnValueOnce({
|
||||||
|
...workflowHelpers.useWorkflowHelpers({ router: useRouter() }),
|
||||||
|
resolveExpression: mock,
|
||||||
|
});
|
||||||
|
|
||||||
|
return mock;
|
||||||
|
};
|
||||||
|
|
||||||
describe('SqlEditor.vue', () => {
|
describe('SqlEditor.vue', () => {
|
||||||
|
beforeEach(() => {
|
||||||
const pinia = createTestingPinia({
|
const pinia = createTestingPinia({
|
||||||
initialState: {
|
initialState: {
|
||||||
[STORES.SETTINGS]: {
|
[STORES.SETTINGS]: {
|
||||||
|
@ -29,16 +53,7 @@ describe('SqlEditor.vue', () => {
|
||||||
},
|
},
|
||||||
[STORES.WORKFLOWS]: {
|
[STORES.WORKFLOWS]: {
|
||||||
workflow: {
|
workflow: {
|
||||||
nodes: [
|
nodes,
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
typeVersion: 1,
|
|
||||||
name: 'Test Node',
|
|
||||||
position: [0, 0],
|
|
||||||
type: 'test',
|
|
||||||
parameters: {},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
connections: {},
|
connections: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -46,16 +61,10 @@ describe('SqlEditor.vue', () => {
|
||||||
});
|
});
|
||||||
setActivePinia(pinia);
|
setActivePinia(pinia);
|
||||||
|
|
||||||
const mockResolveExpression = () => {
|
const workflowsStore = useWorkflowsStore();
|
||||||
const mock = vi.fn();
|
vi.mocked(workflowsStore).getNodeByName.mockReturnValue(nodes[0]);
|
||||||
vi.spyOn(workflowHelpers, 'useWorkflowHelpers').mockReturnValueOnce({
|
|
||||||
...workflowHelpers.useWorkflowHelpers({ router: useRouter() }),
|
|
||||||
resolveExpression: mock,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return mock;
|
|
||||||
};
|
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
import type { INodeUi } from '@/Interface';
|
import type { INodeUi } from '@/Interface';
|
||||||
import { useContextMenu } from '@/composables/useContextMenu';
|
import { useContextMenu } from '@/composables/useContextMenu';
|
||||||
import { BASIC_CHAIN_NODE_TYPE, NO_OP_NODE_TYPE, STICKY_NODE_TYPE, STORES } from '@/constants';
|
import { BASIC_CHAIN_NODE_TYPE, NO_OP_NODE_TYPE, STICKY_NODE_TYPE } from '@/constants';
|
||||||
import { faker } from '@faker-js/faker';
|
import { faker } from '@faker-js/faker';
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createPinia, setActivePinia } from 'pinia';
|
||||||
import { setActivePinia } from 'pinia';
|
|
||||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
@ -27,21 +26,18 @@ describe('useContextMenu', () => {
|
||||||
const selectedNodes = nodes.slice(0, 2);
|
const selectedNodes = nodes.slice(0, 2);
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
setActivePinia(
|
setActivePinia(createPinia());
|
||||||
createTestingPinia({
|
|
||||||
initialState: {
|
|
||||||
[STORES.UI]: { selectedNodes },
|
|
||||||
[STORES.WORKFLOWS]: { workflow: { nodes } },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
sourceControlStore = useSourceControlStore();
|
sourceControlStore = useSourceControlStore();
|
||||||
uiStore = useUIStore();
|
|
||||||
workflowsStore = useWorkflowsStore();
|
|
||||||
vi.spyOn(uiStore, 'isReadOnlyView', 'get').mockReturnValue(false);
|
|
||||||
vi.spyOn(sourceControlStore, 'preferences', 'get').mockReturnValue({
|
vi.spyOn(sourceControlStore, 'preferences', 'get').mockReturnValue({
|
||||||
branchReadOnly: false,
|
branchReadOnly: false,
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
|
uiStore = useUIStore();
|
||||||
|
uiStore.selectedNodes = selectedNodes;
|
||||||
|
vi.spyOn(uiStore, 'isReadOnlyView', 'get').mockReturnValue(false);
|
||||||
|
|
||||||
|
workflowsStore = useWorkflowsStore();
|
||||||
|
workflowsStore.workflow.nodes = nodes;
|
||||||
vi.spyOn(workflowsStore, 'getCurrentWorkflow').mockReturnValue({
|
vi.spyOn(workflowsStore, 'getCurrentWorkflow').mockReturnValue({
|
||||||
nodes,
|
nodes,
|
||||||
getNode: (_: string) => {
|
getNode: (_: string) => {
|
||||||
|
|
|
@ -1,101 +0,0 @@
|
||||||
import { setActivePinia, createPinia } from 'pinia';
|
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
|
||||||
import type { IWorkflowDataUpdate } from '@/Interface';
|
|
||||||
import { makeRestApiRequest } from '@/utils/apiUtils';
|
|
||||||
import { useRootStore } from '../n8nRoot.store';
|
|
||||||
|
|
||||||
vi.mock('@/utils/apiUtils', () => ({
|
|
||||||
makeRestApiRequest: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const MOCK_WORKFLOW_SIMPLE: IWorkflowDataUpdate = {
|
|
||||||
id: '1',
|
|
||||||
name: 'test',
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
parameters: {
|
|
||||||
path: '21a77783-e050-4e0f-9915-2d2dd5b53cde',
|
|
||||||
options: {},
|
|
||||||
},
|
|
||||||
id: '2dbf9369-2eec-42e7-9b89-37e50af12289',
|
|
||||||
name: 'Webhook',
|
|
||||||
type: 'n8n-nodes-base.webhook',
|
|
||||||
typeVersion: 1,
|
|
||||||
position: [340, 240],
|
|
||||||
webhookId: '21a77783-e050-4e0f-9915-2d2dd5b53cde',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
parameters: {
|
|
||||||
table: 'product',
|
|
||||||
columns: 'name,ean',
|
|
||||||
additionalFields: {},
|
|
||||||
},
|
|
||||||
name: 'Insert Rows1',
|
|
||||||
type: 'n8n-nodes-base.postgres',
|
|
||||||
position: [580, 240],
|
|
||||||
typeVersion: 1,
|
|
||||||
id: 'a10ba62a-8792-437c-87df-0762fa53e157',
|
|
||||||
credentials: {
|
|
||||||
postgres: {
|
|
||||||
id: 'iEFl08xIegmR8xF6',
|
|
||||||
name: 'Postgres account',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
connections: {
|
|
||||||
Webhook: {
|
|
||||||
main: [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
node: 'Insert Rows1',
|
|
||||||
type: 'main',
|
|
||||||
index: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('worklfows store', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
setActivePinia(createPinia());
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('createNewWorkflow', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates new workflow', async () => {
|
|
||||||
const workflowsStore = useWorkflowsStore();
|
|
||||||
await workflowsStore.createNewWorkflow(MOCK_WORKFLOW_SIMPLE);
|
|
||||||
|
|
||||||
expect(makeRestApiRequest).toHaveBeenCalledWith(
|
|
||||||
useRootStore().getRestApiContext,
|
|
||||||
'POST',
|
|
||||||
'/workflows',
|
|
||||||
{
|
|
||||||
...MOCK_WORKFLOW_SIMPLE,
|
|
||||||
active: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets active to false', async () => {
|
|
||||||
const workflowsStore = useWorkflowsStore();
|
|
||||||
await workflowsStore.createNewWorkflow({ ...MOCK_WORKFLOW_SIMPLE, active: true });
|
|
||||||
|
|
||||||
expect(makeRestApiRequest).toHaveBeenCalledWith(
|
|
||||||
useRootStore().getRestApiContext,
|
|
||||||
'POST',
|
|
||||||
'/workflows',
|
|
||||||
{
|
|
||||||
...MOCK_WORKFLOW_SIMPLE,
|
|
||||||
active: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,31 +0,0 @@
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
|
||||||
|
|
||||||
let pinia: ReturnType<typeof createTestingPinia>;
|
|
||||||
beforeAll(() => {
|
|
||||||
pinia = createTestingPinia();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Workflows Store', () => {
|
|
||||||
describe('shouldReplaceInputDataWithPinData', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
pinia.state.value = {
|
|
||||||
workflows: useWorkflowsStore(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true if no active execution is set', () => {
|
|
||||||
expect(useWorkflowsStore().shouldReplaceInputDataWithPinData).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true if active execution is set and mode is manual', () => {
|
|
||||||
pinia.state.value.workflows.activeWorkflowExecution = { mode: 'manual' };
|
|
||||||
expect(useWorkflowsStore().shouldReplaceInputDataWithPinData).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false if active execution is set and mode is not manual', () => {
|
|
||||||
pinia.state.value.workflows.activeWorkflowExecution = { mode: 'webhook' };
|
|
||||||
expect(useWorkflowsStore().shouldReplaceInputDataWithPinData).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
444
packages/editor-ui/src/stores/workflows.store.spec.ts
Normal file
444
packages/editor-ui/src/stores/workflows.store.spec.ts
Normal file
|
@ -0,0 +1,444 @@
|
||||||
|
import { setActivePinia, createPinia } from 'pinia';
|
||||||
|
import * as workflowsApi from '@/api/workflows';
|
||||||
|
import {
|
||||||
|
DUPLICATE_POSTFFIX,
|
||||||
|
MAX_WORKFLOW_NAME_LENGTH,
|
||||||
|
PLACEHOLDER_EMPTY_WORKFLOW_ID,
|
||||||
|
} from '@/constants';
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import type { IExecutionResponse, INodeUi, IWorkflowDb, IWorkflowSettings } from '@/Interface';
|
||||||
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
|
import type { ExecutionSummary, IConnection, INodeExecutionData } from 'n8n-workflow';
|
||||||
|
import { stringSizeInBytes } from '@/utils/typesUtils';
|
||||||
|
import { dataPinningEventBus } from '@/event-bus';
|
||||||
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
|
||||||
|
vi.mock('@/api/workflows', () => ({
|
||||||
|
getWorkflows: vi.fn(),
|
||||||
|
getWorkflow: vi.fn(),
|
||||||
|
getNewWorkflow: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/stores/nodeTypes.store', () => ({
|
||||||
|
useNodeTypesStore: vi.fn(() => ({
|
||||||
|
getNodeType: vi.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('useWorkflowsStore', () => {
|
||||||
|
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
|
||||||
|
let uiStore: ReturnType<typeof useUIStore>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
workflowsStore = useWorkflowsStore();
|
||||||
|
uiStore = useUIStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize with default state', () => {
|
||||||
|
expect(workflowsStore.workflow.name).toBe('');
|
||||||
|
expect(workflowsStore.workflow.id).toBe(PLACEHOLDER_EMPTY_WORKFLOW_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('allWorkflows', () => {
|
||||||
|
it('should return sorted workflows by name', () => {
|
||||||
|
workflowsStore.setWorkflows([
|
||||||
|
{ id: '3', name: 'Zeta' },
|
||||||
|
{ id: '1', name: 'Alpha' },
|
||||||
|
{ id: '2', name: 'Beta' },
|
||||||
|
] as IWorkflowDb[]);
|
||||||
|
|
||||||
|
const allWorkflows = workflowsStore.allWorkflows;
|
||||||
|
expect(allWorkflows[0].name).toBe('Alpha');
|
||||||
|
expect(allWorkflows[1].name).toBe('Beta');
|
||||||
|
expect(allWorkflows[2].name).toBe('Zeta');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when no workflows are set', () => {
|
||||||
|
workflowsStore.setWorkflows([]);
|
||||||
|
|
||||||
|
const allWorkflows = workflowsStore.allWorkflows;
|
||||||
|
expect(allWorkflows).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isNewWorkflow', () => {
|
||||||
|
it('should return true for a new workflow', () => {
|
||||||
|
expect(workflowsStore.isNewWorkflow).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for an existing workflow', () => {
|
||||||
|
workflowsStore.setWorkflowId('123');
|
||||||
|
expect(workflowsStore.isNewWorkflow).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('workflowTriggerNodes', () => {
|
||||||
|
it('should return only nodes that are triggers', () => {
|
||||||
|
vi.mocked(useNodeTypesStore).mockReturnValueOnce({
|
||||||
|
getNodeType: vi.fn(() => ({
|
||||||
|
group: ['trigger'],
|
||||||
|
})),
|
||||||
|
} as unknown as ReturnType<typeof useNodeTypesStore>);
|
||||||
|
|
||||||
|
workflowsStore.workflow.nodes = [
|
||||||
|
{ type: 'triggerNode', typeVersion: '1' },
|
||||||
|
{ type: 'nonTriggerNode', typeVersion: '1' },
|
||||||
|
] as unknown as IWorkflowDb['nodes'];
|
||||||
|
|
||||||
|
expect(workflowsStore.workflowTriggerNodes).toHaveLength(1);
|
||||||
|
expect(workflowsStore.workflowTriggerNodes[0].type).toBe('triggerNode');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when no nodes are triggers', () => {
|
||||||
|
workflowsStore.workflow.nodes = [
|
||||||
|
{ type: 'nonTriggerNode1', typeVersion: '1' },
|
||||||
|
{ type: 'nonTriggerNode2', typeVersion: '1' },
|
||||||
|
] as unknown as IWorkflowDb['nodes'];
|
||||||
|
|
||||||
|
expect(workflowsStore.workflowTriggerNodes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('currentWorkflowHasWebhookNode', () => {
|
||||||
|
it('should return true when a node has a webhookId', () => {
|
||||||
|
workflowsStore.workflow.nodes = [
|
||||||
|
{ name: 'Node1', webhookId: 'webhook1' },
|
||||||
|
{ name: 'Node2' },
|
||||||
|
] as unknown as IWorkflowDb['nodes'];
|
||||||
|
|
||||||
|
const hasWebhookNode = workflowsStore.currentWorkflowHasWebhookNode;
|
||||||
|
expect(hasWebhookNode).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when no nodes have a webhookId', () => {
|
||||||
|
workflowsStore.workflow.nodes = [
|
||||||
|
{ name: 'Node1' },
|
||||||
|
{ name: 'Node2' },
|
||||||
|
] as unknown as IWorkflowDb['nodes'];
|
||||||
|
|
||||||
|
const hasWebhookNode = workflowsStore.currentWorkflowHasWebhookNode;
|
||||||
|
expect(hasWebhookNode).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when there are no nodes', () => {
|
||||||
|
workflowsStore.workflow.nodes = [];
|
||||||
|
|
||||||
|
const hasWebhookNode = workflowsStore.currentWorkflowHasWebhookNode;
|
||||||
|
expect(hasWebhookNode).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getWorkflowRunData', () => {
|
||||||
|
it('should return null when no execution data is present', () => {
|
||||||
|
workflowsStore.workflowExecutionData = null;
|
||||||
|
|
||||||
|
const runData = workflowsStore.getWorkflowRunData;
|
||||||
|
expect(runData).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when execution data does not contain resultData', () => {
|
||||||
|
workflowsStore.workflowExecutionData = { data: {} } as IExecutionResponse;
|
||||||
|
|
||||||
|
const runData = workflowsStore.getWorkflowRunData;
|
||||||
|
expect(runData).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return runData when execution data contains resultData', () => {
|
||||||
|
const expectedRunData = { node1: [{}, {}], node2: [{}] };
|
||||||
|
workflowsStore.workflowExecutionData = {
|
||||||
|
data: { resultData: { runData: expectedRunData } },
|
||||||
|
} as unknown as IExecutionResponse;
|
||||||
|
|
||||||
|
const runData = workflowsStore.getWorkflowRunData;
|
||||||
|
expect(runData).toEqual(expectedRunData);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('nodesIssuesExist', () => {
|
||||||
|
it('should return true when a node has issues', () => {
|
||||||
|
workflowsStore.workflow.nodes = [
|
||||||
|
{ name: 'Node1', issues: { error: ['Error message'] } },
|
||||||
|
{ name: 'Node2' },
|
||||||
|
] as unknown as IWorkflowDb['nodes'];
|
||||||
|
|
||||||
|
const hasIssues = workflowsStore.nodesIssuesExist;
|
||||||
|
expect(hasIssues).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when no nodes have issues', () => {
|
||||||
|
workflowsStore.workflow.nodes = [
|
||||||
|
{ name: 'Node1' },
|
||||||
|
{ name: 'Node2' },
|
||||||
|
] as unknown as IWorkflowDb['nodes'];
|
||||||
|
|
||||||
|
const hasIssues = workflowsStore.nodesIssuesExist;
|
||||||
|
expect(hasIssues).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when there are no nodes', () => {
|
||||||
|
workflowsStore.workflow.nodes = [];
|
||||||
|
|
||||||
|
const hasIssues = workflowsStore.nodesIssuesExist;
|
||||||
|
expect(hasIssues).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shouldReplaceInputDataWithPinData', () => {
|
||||||
|
it('should return true when no active workflow execution', () => {
|
||||||
|
workflowsStore.activeWorkflowExecution = null;
|
||||||
|
|
||||||
|
expect(workflowsStore.shouldReplaceInputDataWithPinData).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when active workflow execution mode is manual', () => {
|
||||||
|
workflowsStore.activeWorkflowExecution = { mode: 'manual' } as unknown as ExecutionSummary;
|
||||||
|
|
||||||
|
expect(workflowsStore.shouldReplaceInputDataWithPinData).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when active workflow execution mode is not manual', () => {
|
||||||
|
workflowsStore.activeWorkflowExecution = { mode: 'automatic' } as unknown as ExecutionSummary;
|
||||||
|
|
||||||
|
expect(workflowsStore.shouldReplaceInputDataWithPinData).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getWorkflowResultDataByNodeName()', () => {
|
||||||
|
it('should return null when no workflow run data is present', () => {
|
||||||
|
workflowsStore.workflowExecutionData = null;
|
||||||
|
|
||||||
|
const resultData = workflowsStore.getWorkflowResultDataByNodeName('Node1');
|
||||||
|
expect(resultData).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when node name is not present in workflow run data', () => {
|
||||||
|
workflowsStore.workflowExecutionData = {
|
||||||
|
data: { resultData: { runData: {} } },
|
||||||
|
} as unknown as IExecutionResponse;
|
||||||
|
|
||||||
|
const resultData = workflowsStore.getWorkflowResultDataByNodeName('Node1');
|
||||||
|
expect(resultData).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return result data when node name is present in workflow run data', () => {
|
||||||
|
const expectedData = [{}, {}];
|
||||||
|
workflowsStore.workflowExecutionData = {
|
||||||
|
data: { resultData: { runData: { Node1: expectedData } } },
|
||||||
|
} as unknown as IExecutionResponse;
|
||||||
|
|
||||||
|
const resultData = workflowsStore.getWorkflowResultDataByNodeName('Node1');
|
||||||
|
expect(resultData).toEqual(expectedData);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isNodeInOutgoingNodeConnections()', () => {
|
||||||
|
it('should return false when no outgoing connections from root node', () => {
|
||||||
|
workflowsStore.workflow.connections = {};
|
||||||
|
|
||||||
|
const result = workflowsStore.isNodeInOutgoingNodeConnections('RootNode', 'SearchNode');
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when search node is directly connected to root node', () => {
|
||||||
|
workflowsStore.workflow.connections = {
|
||||||
|
RootNode: { main: [[{ node: 'SearchNode' } as IConnection]] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = workflowsStore.isNodeInOutgoingNodeConnections('RootNode', 'SearchNode');
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when search node is indirectly connected to root node', () => {
|
||||||
|
workflowsStore.workflow.connections = {
|
||||||
|
RootNode: { main: [[{ node: 'IntermediateNode' } as IConnection]] },
|
||||||
|
IntermediateNode: { main: [[{ node: 'SearchNode' } as IConnection]] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = workflowsStore.isNodeInOutgoingNodeConnections('RootNode', 'SearchNode');
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when search node is not connected to root node', () => {
|
||||||
|
workflowsStore.workflow.connections = {
|
||||||
|
RootNode: { main: [[{ node: 'IntermediateNode' } as IConnection]] },
|
||||||
|
IntermediateNode: { main: [[{ node: 'AnotherNode' } as IConnection]] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = workflowsStore.isNodeInOutgoingNodeConnections('RootNode', 'SearchNode');
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPinDataSize()', () => {
|
||||||
|
it('returns zero when pinData is empty', () => {
|
||||||
|
const pinData = {};
|
||||||
|
const result = workflowsStore.getPinDataSize(pinData);
|
||||||
|
expect(result).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct size when pinData contains string values', () => {
|
||||||
|
const pinData = {
|
||||||
|
key1: 'value1',
|
||||||
|
key2: 'value2',
|
||||||
|
} as Record<string, string | INodeExecutionData[]>;
|
||||||
|
const result = workflowsStore.getPinDataSize(pinData);
|
||||||
|
expect(result).toBe(stringSizeInBytes(pinData.key1) + stringSizeInBytes(pinData.key2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct size when pinData contains array values', () => {
|
||||||
|
const pinData = {
|
||||||
|
key1: [{ parameters: 'value1', data: null }],
|
||||||
|
key2: [{ parameters: 'value2', data: null }],
|
||||||
|
} as unknown as Record<string, string | INodeExecutionData[]>;
|
||||||
|
const result = workflowsStore.getPinDataSize(pinData);
|
||||||
|
expect(result).toBe(stringSizeInBytes(pinData.key1) + stringSizeInBytes(pinData.key2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct size when pinData contains mixed string and array values', () => {
|
||||||
|
const pinData = {
|
||||||
|
key1: 'value1',
|
||||||
|
key2: [{ parameters: 'value2', data: null }],
|
||||||
|
} as unknown as Record<string, string | INodeExecutionData[]>;
|
||||||
|
const result = workflowsStore.getPinDataSize(pinData);
|
||||||
|
expect(result).toBe(stringSizeInBytes(pinData.key1) + stringSizeInBytes(pinData.key2));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchAllWorkflows()', () => {
|
||||||
|
it('should fetch workflows successfully', async () => {
|
||||||
|
const mockWorkflows = [{ id: '1', name: 'Test Workflow' }] as IWorkflowDb[];
|
||||||
|
vi.mocked(workflowsApi).getWorkflows.mockResolvedValue(mockWorkflows);
|
||||||
|
|
||||||
|
await workflowsStore.fetchAllWorkflows();
|
||||||
|
|
||||||
|
expect(workflowsApi.getWorkflows).toHaveBeenCalled();
|
||||||
|
expect(Object.values(workflowsStore.workflowsById)).toEqual(mockWorkflows);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setWorkflowName()', () => {
|
||||||
|
it('should set the workflow name correctly', () => {
|
||||||
|
workflowsStore.setWorkflowName({ newName: 'New Workflow Name', setStateDirty: false });
|
||||||
|
expect(workflowsStore.workflow.name).toBe('New Workflow Name');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setWorkflowActive()', () => {
|
||||||
|
it('should set workflow as active when it is not already active', () => {
|
||||||
|
workflowsStore.workflowsById = { '1': { active: false } as IWorkflowDb };
|
||||||
|
workflowsStore.workflow.id = '1';
|
||||||
|
|
||||||
|
workflowsStore.setWorkflowActive('1');
|
||||||
|
|
||||||
|
expect(workflowsStore.activeWorkflows).toContain('1');
|
||||||
|
expect(workflowsStore.workflowsById['1'].active).toBe(true);
|
||||||
|
expect(workflowsStore.workflow.active).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not modify active workflows when workflow is already active', () => {
|
||||||
|
workflowsStore.activeWorkflows = ['1'];
|
||||||
|
workflowsStore.workflowsById = { '1': { active: true } as IWorkflowDb };
|
||||||
|
workflowsStore.workflow.id = '1';
|
||||||
|
|
||||||
|
workflowsStore.setWorkflowActive('1');
|
||||||
|
|
||||||
|
expect(workflowsStore.activeWorkflows).toEqual(['1']);
|
||||||
|
expect(workflowsStore.workflowsById['1'].active).toBe(true);
|
||||||
|
expect(workflowsStore.workflow.active).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setWorkflowInactive()', () => {
|
||||||
|
it('should set workflow as inactive when it exists', () => {
|
||||||
|
workflowsStore.activeWorkflows = ['1', '2'];
|
||||||
|
workflowsStore.workflowsById = { '1': { active: true } as IWorkflowDb };
|
||||||
|
workflowsStore.setWorkflowInactive('1');
|
||||||
|
expect(workflowsStore.workflowsById['1'].active).toBe(false);
|
||||||
|
expect(workflowsStore.activeWorkflows).toEqual(['2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not modify active workflows when workflow is not active', () => {
|
||||||
|
workflowsStore.workflowsById = { '2': { active: true } as IWorkflowDb };
|
||||||
|
workflowsStore.activeWorkflows = ['2'];
|
||||||
|
workflowsStore.setWorkflowInactive('1');
|
||||||
|
expect(workflowsStore.activeWorkflows).toEqual(['2']);
|
||||||
|
expect(workflowsStore.workflowsById['2'].active).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set current workflow as inactive when it is the target', () => {
|
||||||
|
workflowsStore.workflow.id = '1';
|
||||||
|
workflowsStore.workflow.active = true;
|
||||||
|
workflowsStore.setWorkflowInactive('1');
|
||||||
|
expect(workflowsStore.workflow.active).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDuplicateCurrentWorkflowName()', () => {
|
||||||
|
it('should return the same name if appending postfix exceeds max length', async () => {
|
||||||
|
const longName = 'a'.repeat(MAX_WORKFLOW_NAME_LENGTH - DUPLICATE_POSTFFIX.length + 1);
|
||||||
|
const newName = await workflowsStore.getDuplicateCurrentWorkflowName(longName);
|
||||||
|
expect(newName).toBe(longName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should append postfix to the name if it does not exceed max length', async () => {
|
||||||
|
const name = 'TestWorkflow';
|
||||||
|
const expectedName = `${name}${DUPLICATE_POSTFFIX}`;
|
||||||
|
vi.mocked(workflowsApi).getNewWorkflow.mockResolvedValue({
|
||||||
|
name: expectedName,
|
||||||
|
onboardingFlowEnabled: false,
|
||||||
|
settings: {} as IWorkflowSettings,
|
||||||
|
});
|
||||||
|
const newName = await workflowsStore.getDuplicateCurrentWorkflowName(name);
|
||||||
|
expect(newName).toBe(expectedName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle API failure gracefully', async () => {
|
||||||
|
const name = 'TestWorkflow';
|
||||||
|
const expectedName = `${name}${DUPLICATE_POSTFFIX}`;
|
||||||
|
vi.mocked(workflowsApi).getNewWorkflow.mockRejectedValue(new Error('API Error'));
|
||||||
|
const newName = await workflowsStore.getDuplicateCurrentWorkflowName(name);
|
||||||
|
expect(newName).toBe(expectedName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pinData', () => {
|
||||||
|
it('should create pinData object if it does not exist', async () => {
|
||||||
|
workflowsStore.workflow.pinData = undefined;
|
||||||
|
const node = { name: 'TestNode' } as INodeUi;
|
||||||
|
const data = [{ json: 'testData' }] as unknown as INodeExecutionData[];
|
||||||
|
workflowsStore.pinData({ node, data });
|
||||||
|
expect(workflowsStore.workflow.pinData).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert data to array if it is not', async () => {
|
||||||
|
const node = { name: 'TestNode' } as INodeUi;
|
||||||
|
const data = { json: 'testData' } as unknown as INodeExecutionData;
|
||||||
|
workflowsStore.pinData({ node, data: data as unknown as INodeExecutionData[] });
|
||||||
|
expect(Array.isArray(workflowsStore.workflow.pinData?.[node.name])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should store pinData correctly', async () => {
|
||||||
|
const node = { name: 'TestNode' } as INodeUi;
|
||||||
|
const data = [{ json: 'testData' }] as unknown as INodeExecutionData[];
|
||||||
|
workflowsStore.pinData({ node, data });
|
||||||
|
expect(workflowsStore.workflow.pinData?.[node.name]).toEqual(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit pin-data event', async () => {
|
||||||
|
const node = { name: 'TestNode' } as INodeUi;
|
||||||
|
const data = [{ json: 'testData' }] as unknown as INodeExecutionData[];
|
||||||
|
const emitSpy = vi.spyOn(dataPinningEventBus, 'emit');
|
||||||
|
workflowsStore.pinData({ node, data });
|
||||||
|
expect(emitSpy).toHaveBeenCalledWith('pin-data', { [node.name]: data });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set stateIsDirty to true', async () => {
|
||||||
|
uiStore.stateIsDirty = false;
|
||||||
|
const node = { name: 'TestNode' } as INodeUi;
|
||||||
|
const data = [{ json: 'testData' }] as unknown as INodeExecutionData[];
|
||||||
|
workflowsStore.pinData({ node, data });
|
||||||
|
expect(uiStore.stateIsDirty).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue