feat(editor): Node IO filter (#7503)

Co-authored-by: Omar Ajoue <krynble@gmail.com>
This commit is contained in:
Csaba Tuncsik 2023-11-15 16:19:48 +01:00 committed by GitHub
parent 93103c0b08
commit 18817651ec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 1331 additions and 85 deletions

View file

@ -0,0 +1,116 @@
import { WorkflowPage as WorkflowPageClass, NDV } from '../pages';
const workflowPage = new WorkflowPageClass();
const ndv = new NDV();
describe('Node IO Filter', () => {
beforeEach(() => {
workflowPage.actions.visit();
cy.createFixtureWorkflow('Node_IO_filter.json', `Node IO filter`);
workflowPage.actions.saveWorkflowOnButtonClick();
workflowPage.actions.executeWorkflow();
});
it('should filter pinned data', () => {
workflowPage.getters.canvasNodes().first().dblclick();
ndv.actions.close();
workflowPage.getters.canvasNodes().first().dblclick();
cy.wait(500);
ndv.getters.outputDataContainer().should('be.visible');
cy.document().trigger('keyup', { key: '/' });
const searchInput = ndv.getters.searchInput();
searchInput.filter(':focus').should('exist');
ndv.getters.pagination().find('li').should('have.length', 3);
cy.get('.highlight').should('not.exist');
searchInput.type('ar');
ndv.getters.pagination().find('li').should('have.length', 2);
cy.get('.highlight').its('length').should('be.gt', 0);
searchInput.type('i');
ndv.getters.pagination().should('not.exist');
cy.get('.highlight').its('length').should('be.gt', 0);
});
it.only('should filter input/output data separately', () => {
workflowPage.getters.canvasNodes().eq(1).dblclick();
cy.wait(500);
ndv.getters.outputDataContainer().should('be.visible');
ndv.getters.inputDataContainer().should('be.visible');
ndv.actions.switchInputMode('Table');
cy.document().trigger('keyup', { key: '/' });
ndv.getters.outputPanel().findChildByTestId('ndv-search').filter(':focus').should('not.exist');
let focusedInput = ndv.getters
.inputPanel()
.findChildByTestId('ndv-search')
.filter(':focus')
.should('exist');
const getInputPagination = () =>
ndv.getters.inputPanel().findChildByTestId('ndv-data-pagination');
const getInputCounter = () => ndv.getters.inputPanel().findChildByTestId('ndv-items-count');
const getOuputPagination = () =>
ndv.getters.outputPanel().findChildByTestId('ndv-data-pagination');
const getOutputCounter = () => ndv.getters.outputPanel().findChildByTestId('ndv-items-count');
getInputPagination().find('li').should('have.length', 3);
getInputCounter().contains('21 items').should('exist');
getOuputPagination().find('li').should('have.length', 3);
getOutputCounter().contains('21 items').should('exist');
focusedInput.type('ar');
getInputPagination().find('li').should('have.length', 2);
getInputCounter().should('contain', '14 of 21 items');
getOuputPagination().find('li').should('have.length', 3);
getOutputCounter().should('contain', '21 items');
focusedInput.type('i');
getInputPagination().should('not.exist');
getInputCounter().should('contain', '8 of 21 items');
getOuputPagination().find('li').should('have.length', 3);
getOutputCounter().should('contain', '21 items');
focusedInput.clear();
getInputPagination().find('li').should('have.length', 3);
getInputCounter().contains('21 items').should('exist');
getOuputPagination().find('li').should('have.length', 3);
getOutputCounter().contains('21 items').should('exist');
ndv.getters.outputDataContainer().trigger('mouseover');
cy.document().trigger('keyup', { key: '/' });
ndv.getters.inputPanel().findChildByTestId('ndv-search').filter(':focus').should('not.exist');
focusedInput = ndv.getters
.outputPanel()
.findChildByTestId('ndv-search')
.filter(':focus')
.should('exist');
getInputPagination().find('li').should('have.length', 3);
getInputCounter().contains('21 items').should('exist');
getOuputPagination().find('li').should('have.length', 3);
getOutputCounter().contains('21 items').should('exist');
focusedInput.type('ar');
getInputPagination().find('li').should('have.length', 3);
getInputCounter().contains('21 items').should('exist');
getOuputPagination().find('li').should('have.length', 2);
getOutputCounter().should('contain', '14 of 21 items');
focusedInput.type('i');
getInputPagination().find('li').should('have.length', 3);
getInputCounter().contains('21 items').should('exist');
getOuputPagination().should('not.exist');
getOutputCounter().should('contain', '8 of 21 items');
focusedInput.clear();
getInputPagination().find('li').should('have.length', 3);
getInputCounter().contains('21 items').should('exist');
getOuputPagination().find('li').should('have.length', 3);
getOutputCounter().contains('21 items').should('exist');
});
});

View file

@ -0,0 +1,653 @@
{
"name": "Node IO filter",
"nodes": [
{
"parameters": {},
"id": "46770685-44d1-4aad-9107-1d790cf26b50",
"name": "When clicking \"Execute Workflow\"",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
840,
180
]
},
{
"parameters": {
"options": {}
},
"id": "480e3832-2ce4-4118-9f7b-a8aed6017174",
"name": "Edit Fields",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [
1080,
180
]
},
{
"parameters": {
"conditions": {
"string": [
{
"value1": "={{ $json.profile.name }}",
"operation": "contains",
"value2": "an"
}
]
}
},
"id": "4773d460-6ed9-49e1-a688-7e480f0fbacf",
"name": "IF",
"type": "n8n-nodes-base.if",
"typeVersion": 1,
"position": [
1300,
180
]
},
{
"parameters": {
"options": {}
},
"id": "d17dffe6-e29c-4c1a-8b4c-9e374dcd70ea",
"name": "True",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [
1560,
60
]
},
{
"parameters": {
"options": {}
},
"id": "893d6e79-feb4-4752-a6f8-e2e5f5163787",
"name": "False",
"type": "n8n-nodes-base.set",
"typeVersion": 3.2,
"position": [
1560,
240
]
}
],
"pinData": {
"When clicking \"Execute Workflow\"": [
{
"json": {
"id": "654cfa05fa51480dcb543b1a",
"email": "reese_hahn@kidgrease.coach",
"username": "reese94",
"profile": {
"name": "Reese Hahn",
"company": "Kidgrease",
"dob": "1994-06-18",
"address": "3 Richmond Street, Norfolk, Delaware",
"location": {
"lat": 22.507436,
"long": -50.812775
},
"about": "Cupidatat voluptate reprehenderit commodo mollit tempor sint id. Id exercitation id eiusmod dolore non non anim voluptate anim eu consectetur."
},
"apiKey": "a18592bf-1147-4b61-a70f-2ab90b60bb6e",
"roles": [
"guest"
],
"createdAt": "2010-10-04T09:57:59.240Z",
"updatedAt": "2010-10-05T09:57:59.240Z"
}
},
{
"json": {
"id": "654cfa055bea471bc4853158",
"email": "jeanne_boyd@hatology.gratis",
"username": "jeanne91",
"profile": {
"name": "Jeanne Boyd",
"company": "Hatology",
"dob": "1991-02-21",
"address": "81 Kingsway Place, Blairstown, Vermont",
"location": {
"lat": -57.665234,
"long": -41.301893
},
"about": "Proident pariatur non consequat cupidatat Lorem nisi est consequat dolor id eiusmod id. Amet culpa ex Lorem nostrud labore laboris culpa mollit dolor culpa ut."
},
"apiKey": "8a6056a6-0197-4920-858d-cb26f8c8a1e2",
"roles": [
"owner",
"admin"
],
"createdAt": "2011-11-06T09:05:41.945Z",
"updatedAt": "2011-11-07T09:05:41.945Z"
}
},
{
"json": {
"id": "654cfa05b012921c060dc5a5",
"email": "roslyn_underwood@portico.melbourne",
"username": "roslyn88",
"profile": {
"name": "Roslyn Underwood",
"company": "Portico",
"dob": "1988-04-30",
"address": "24 Schenck Street, Drytown, New Jersey",
"location": {
"lat": 11.797141,
"long": 10.751804
},
"about": "Duis excepteur minim consequat exercitation. Laboris occaecat cupidatat aliqua consequat occaecat."
},
"apiKey": "72d629f3-d613-4fd0-bbfe-3f67c8ad7af2",
"roles": [
"member",
"owner"
],
"createdAt": "2012-11-17T22:09:10.911Z",
"updatedAt": "2012-11-18T22:09:10.911Z"
}
},
{
"json": {
"id": "654cfa05df7b35968507efe6",
"email": "combs_hardy@acrodance.domains",
"username": "combs91",
"profile": {
"name": "Combs Hardy",
"company": "Acrodance",
"dob": "1991-04-30",
"address": "58 Pineapple Street, Falconaire, New Mexico",
"location": {
"lat": -62.922443,
"long": -159.493799
},
"about": "Magna qui minim velit magna est eiusmod aliquip elit aliquip excepteur. Laborum labore do ut et ut in incididunt do elit nostrud."
},
"apiKey": "d9807b9e-aee9-486d-9826-4e6c166bfbe4",
"roles": [
"owner",
"member"
],
"createdAt": "2014-04-13T13:02:09.319Z",
"updatedAt": "2014-04-14T13:02:09.319Z"
}
},
{
"json": {
"id": "654cfa05f2d4a0508a7c59c4",
"email": "terrell_peters@vantage.international",
"username": "terrell94",
"profile": {
"name": "Terrell Peters",
"company": "Vantage",
"dob": "1994-01-31",
"address": "10 Lafayette Walk, Vincent, Virginia",
"location": {
"lat": -62.267913,
"long": 29.682121
},
"about": "Eiusmod fugiat nulla ea tempor incididunt nulla nulla consectetur officia incididunt proident sint. Sunt duis non excepteur non."
},
"apiKey": "20b96df1-d882-4dea-a505-84d7ff296a6e",
"roles": [
"admin",
"guest"
],
"createdAt": "2010-12-09T08:24:56.517Z",
"updatedAt": "2010-12-10T08:24:56.517Z"
}
},
{
"json": {
"id": "654cfa0599fbabf3a05c7b14",
"email": "shari_winters@powernet.supply",
"username": "shari93",
"profile": {
"name": "Shari Winters",
"company": "Powernet",
"dob": "1993-03-10",
"address": "89 Aviation Road, Leyner, Indiana",
"location": {
"lat": 40.404704,
"long": -141.216235
},
"about": "Occaecat sit laboris elit laboris do anim culpa dolore exercitation enim. Non veniam sint exercitation irure."
},
"apiKey": "2b869ce9-3431-4edb-944d-9d9336b1eb4a",
"roles": [
"guest",
"admin"
],
"createdAt": "2014-10-15T15:56:55.873Z",
"updatedAt": "2014-10-16T15:56:55.873Z"
}
},
{
"json": {
"id": "654cfa050df18b4798ec95be",
"email": "rena_beasley@bitrex.ma",
"username": "rena90",
"profile": {
"name": "Rena Beasley",
"company": "Bitrex",
"dob": "1990-01-09",
"address": "78 Forbell Street, Homeland, Maine",
"location": {
"lat": 46.047548,
"long": 4.128049
},
"about": "Lorem aliqua veniam duis ut cillum ad sunt mollit incididunt elit. Ipsum incididunt et magna incididunt quis duis amet duis occaecat laborum nulla et commodo nisi."
},
"apiKey": "17e350f8-1020-4344-bbd7-ceb62cd44edb",
"roles": [
"member",
"owner"
],
"createdAt": "2010-04-22T13:35:24.838Z",
"updatedAt": "2010-04-23T13:35:24.838Z"
}
},
{
"json": {
"id": "654cfa0595243d2b7b1ea22a",
"email": "sally_gentry@eventex.maif",
"username": "sally93",
"profile": {
"name": "Sally Gentry",
"company": "Eventex",
"dob": "1993-04-03",
"address": "54 Plaza Street, Greenbackville, North Carolina",
"location": {
"lat": -20.529121,
"long": 73.533118
},
"about": "Laborum sit exercitation sint laborum. Fugiat sit ipsum ullamco sint do dolore in sunt incididunt adipisicing magna ullamco aute."
},
"apiKey": "746b6ab3-c63f-44df-bb99-9de48f8e43c4",
"roles": [
"owner",
"guest"
],
"createdAt": "2011-09-18T13:18:49.655Z",
"updatedAt": "2011-09-19T13:18:49.655Z"
}
},
{
"json": {
"id": "654cfa05cdea66c87bb01439",
"email": "battle_duran@jasper.property",
"username": "battle88",
"profile": {
"name": "Battle Duran",
"company": "Jasper",
"dob": "1988-11-04",
"address": "34 Amherst Street, Corriganville, Nevada",
"location": {
"lat": 74.391489,
"long": -98.421464
},
"about": "Nostrud occaecat laborum aliquip sint est minim id aliquip adipisicing dolor. Aute velit amet officia anim sint anim aliquip."
},
"apiKey": "b22a3ddd-d540-4df0-9ce5-e837bc6a6a10",
"roles": [
"member"
],
"createdAt": "2012-08-31T19:14:37.463Z",
"updatedAt": "2012-09-01T19:14:37.463Z"
}
},
{
"json": {
"id": "654cfa05e9c13e25d41d4135",
"email": "petty_moore@neurocell.shriram",
"username": "petty91",
"profile": {
"name": "Petty Moore",
"company": "Neurocell",
"dob": "1991-03-10",
"address": "78 Interborough Parkway, Grill, Texas",
"location": {
"lat": -79.817761,
"long": -36.728201
},
"about": "Dolor occaecat anim est Lorem culpa fugiat id aliqua sint. Sit nisi do exercitation do voluptate exercitation in."
},
"apiKey": "4b341cfb-a83c-4f2a-9f4d-11cd747b8783",
"roles": [
"admin"
],
"createdAt": "2012-01-02T21:28:22.431Z",
"updatedAt": "2012-01-03T21:28:22.431Z"
}
},
{
"json": {
"id": "654cfa052890c7b4d510d3d4",
"email": "matilda_kelley@senmei.in",
"username": "matilda93",
"profile": {
"name": "Matilda Kelley",
"company": "Senmei",
"dob": "1993-02-04",
"address": "29 Stuart Street, Henrietta, New York",
"location": {
"lat": 40.788206,
"long": -135.821558
},
"about": "Dolor veniam ex ullamco deserunt reprehenderit nostrud sunt culpa cupidatat qui labore deserunt. In ad anim laboris amet labore duis consequat nostrud eiusmod."
},
"apiKey": "dcf40383-a00a-43ef-8bd0-4af7e70413bd",
"roles": [
"owner",
"guest"
],
"createdAt": "2014-03-28T22:07:39.636Z",
"updatedAt": "2014-03-29T22:07:39.636Z"
}
},
{
"json": {
"id": "654cfa05af129db469473bf1",
"email": "savannah_hardin@exoblue.kn",
"username": "savannah89",
"profile": {
"name": "Savannah Hardin",
"company": "Exoblue",
"dob": "1989-07-01",
"address": "44 Navy Walk, Fresno, Kentucky",
"location": {
"lat": 75.679679,
"long": -58.534947
},
"about": "Id eiusmod eu elit consequat quis anim veniam officia anim ipsum. Sunt ex sit ipsum id est eu."
},
"apiKey": "98d6abb7-e4aa-4b3b-8958-ff3c4d672f1d",
"roles": [
"guest",
"member"
],
"createdAt": "2011-04-15T00:55:02.325Z",
"updatedAt": "2011-04-16T00:55:02.325Z"
}
},
{
"json": {
"id": "654cfa055dfa731b01573a67",
"email": "abbott_gallegos@katakana.dad",
"username": "abbott91",
"profile": {
"name": "Abbott Gallegos",
"company": "Katakana",
"dob": "1991-03-04",
"address": "85 Indiana Place, Forestburg, Michigan",
"location": {
"lat": -5.417414,
"long": -4.557904
},
"about": "Adipisicing amet ullamco aliquip velit nostrud qui non pariatur Lorem. Culpa ut deserunt esse quis magna."
},
"apiKey": "3cf92c24-6193-4cc9-85fc-78e4ad9d6e13",
"roles": [
"guest",
"owner"
],
"createdAt": "2011-06-01T16:38:39.316Z",
"updatedAt": "2011-06-02T16:38:39.316Z"
}
},
{
"json": {
"id": "654cfa05386de2e6d75c1694",
"email": "short_brennan@hyplex.tc",
"username": "short92",
"profile": {
"name": "Short Brennan",
"company": "Hyplex",
"dob": "1992-04-19",
"address": "21 Irving Place, Hinsdale, Northern Mariana Islands",
"location": {
"lat": 57.340225,
"long": -7.021582
},
"about": "Mollit dolor dolore deserunt anim minim adipisicing eiusmod velit tempor id veniam cupidatat. Magna veniam consequat incididunt ut quis culpa excepteur tempor eiusmod consectetur excepteur."
},
"apiKey": "07bf533d-4a31-4e78-9d6e-d46160479069",
"roles": [
"admin",
"member"
],
"createdAt": "2014-03-10T19:25:02.217Z",
"updatedAt": "2014-03-11T19:25:02.217Z"
}
},
{
"json": {
"id": "654cfa05fd2a878d43bb45cd",
"email": "bowers_cooke@iplax.ci",
"username": "bowers92",
"profile": {
"name": "Bowers Cooke",
"company": "Iplax",
"dob": "1992-07-05",
"address": "83 Greenpoint Avenue, Marion, Georgia",
"location": {
"lat": 64.261022,
"long": -58.493714
},
"about": "Deserunt ipsum fugiat tempor sunt eu ea laboris ad magna ex laborum laboris. Ullamco nostrud qui exercitation aute consectetur irure."
},
"apiKey": "a3ecc58b-f292-4de1-b6e5-014345a76a7a",
"roles": [
"member",
"owner"
],
"createdAt": "2010-06-20T16:34:56.467Z",
"updatedAt": "2010-06-21T16:34:56.467Z"
}
},
{
"json": {
"id": "654cfa05a6de547367990f9c",
"email": "tara_rutledge@escenta.lc",
"username": "tara90",
"profile": {
"name": "Tara Rutledge",
"company": "Escenta",
"dob": "1990-08-11",
"address": "25 Butler Place, Frierson, Missouri",
"location": {
"lat": -32.176783,
"long": 67.345415
},
"about": "Aute sunt laborum anim ex non pariatur nisi minim tempor adipisicing. Excepteur irure non amet eiusmod et excepteur."
},
"apiKey": "22da9647-a7b7-4815-91bb-d5101fc90e55",
"roles": [
"member"
],
"createdAt": "2013-09-06T21:41:53.287Z",
"updatedAt": "2013-09-07T21:41:53.287Z"
}
},
{
"json": {
"id": "654cfa053778601ad57f22cd",
"email": "elva_chapman@bytrex.gg",
"username": "elva90",
"profile": {
"name": "Elva Chapman",
"company": "Bytrex",
"dob": "1990-05-31",
"address": "4 Royce Place, Advance, New Hampshire",
"location": {
"lat": -28.393464,
"long": -28.622091
},
"about": "Est sit deserunt Lorem amet voluptate elit reprehenderit occaecat est eiusmod eu reprehenderit laborum. Pariatur magna occaecat et excepteur est excepteur consectetur ad nulla."
},
"apiKey": "4d242fa4-ac69-42f1-8f12-ec19d9c6d632",
"roles": [
"owner",
"admin"
],
"createdAt": "2011-04-05T04:04:31.524Z",
"updatedAt": "2011-04-06T04:04:31.524Z"
}
},
{
"json": {
"id": "654cfa054c6abbc57efcb100",
"email": "pitts_meyer@unisure.tui",
"username": "pitts93",
"profile": {
"name": "Pitts Meyer",
"company": "Unisure",
"dob": "1993-06-12",
"address": "47 Columbus Place, Cade, Alaska",
"location": {
"lat": 56.723675,
"long": 158.093389
},
"about": "Non ea pariatur excepteur nostrud elit quis qui. Dolore aute velit ipsum officia ea pariatur incididunt non elit tempor duis consequat."
},
"apiKey": "82a88344-d289-447c-81b5-1ae10cd1994b",
"roles": [
"guest",
"admin"
],
"createdAt": "2014-05-15T06:38:59.269Z",
"updatedAt": "2014-05-16T06:38:59.269Z"
}
},
{
"json": {
"id": "654cfa0527e7ce14e421d9cd",
"email": "delia_figueroa@overplex.um",
"username": "delia89",
"profile": {
"name": "Delia Figueroa",
"company": "Overplex",
"dob": "1989-04-22",
"address": "12 Nova Court, Taft, Ohio",
"location": {
"lat": -32.990583,
"long": -4.598863
},
"about": "Cupidatat fugiat veniam eu proident excepteur deserunt ad esse fugiat deserunt. Non velit cillum velit veniam ex minim eiusmod tempor excepteur voluptate adipisicing nostrud."
},
"apiKey": "b3a7747b-24a0-4039-8a21-56e83441a660",
"roles": [
"admin",
"guest"
],
"createdAt": "2014-09-20T03:40:10.190Z",
"updatedAt": "2014-09-21T03:40:10.190Z"
}
},
{
"json": {
"id": "654cfa05cf60000cbca6dca4",
"email": "kristina_fulton@portaline.engineer",
"username": "kristina88",
"profile": {
"name": "Kristina Fulton",
"company": "Portaline",
"dob": "1988-07-25",
"address": "50 Laurel Avenue, Greenwich, Palau",
"location": {
"lat": 44.118984,
"long": 41.518949
},
"about": "Id incididunt officia exercitation ipsum id cillum consectetur. Veniam enim voluptate ut proident ex."
},
"apiKey": "c106dbf0-bfc0-461d-b1d7-1840fe8e1cbc",
"roles": [
"admin",
"member"
],
"createdAt": "2010-04-10T08:06:27.028Z",
"updatedAt": "2010-04-11T08:06:27.028Z"
}
},
{
"json": {
"id": "654cfa0501fe5691d620f570",
"email": "gould_noel@gonkle.gmx",
"username": "gould91",
"profile": {
"name": "Gould Noel",
"company": "Gonkle",
"dob": "1991-10-08",
"address": "33 Crooke Avenue, Idamay, Oklahoma",
"location": {
"lat": -11.398731,
"long": 34.706948
},
"about": "Veniam esse tempor aute quis mollit consequat Lorem. Nostrud ea dolore laboris Lorem elit est do nisi Lorem minim reprehenderit culpa."
},
"apiKey": "1089783d-32ae-4102-8ac5-1e7f6cebe3c1",
"roles": [
"guest",
"admin"
],
"createdAt": "2011-12-30T20:24:19.620Z",
"updatedAt": "2011-12-31T20:24:19.620Z"
}
}
]
},
"connections": {
"When clicking \"Execute Workflow\"": {
"main": [
[
{
"node": "Edit Fields",
"type": "main",
"index": 0
}
]
]
},
"Edit Fields": {
"main": [
[
{
"node": "IF",
"type": "main",
"index": 0
}
]
]
},
"IF": {
"main": [
[
{
"node": "True",
"type": "main",
"index": 0
}
],
[
{
"node": "False",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "9812dda2-cc1b-4458-97d8-21ccb18c90d1",
"id": "WNq486x7DpV1MPRH",
"meta": {
"instanceId": "8a47b83b4479b11330fdf21ccc96d4a8117035a968612e452b4c87bfd09c16c7"
},
"tags": []
}

View file

@ -78,6 +78,8 @@ export class NDV extends BasePage {
cy.getByTestId('columns-parameter-input-options-container'), cy.getByTestId('columns-parameter-input-options-container'),
resourceMapperRemoveAllFieldsOption: () => cy.getByTestId('action-removeAllFields'), resourceMapperRemoveAllFieldsOption: () => cy.getByTestId('action-removeAllFields'),
sqlEditorContainer: () => cy.getByTestId('sql-editor-container'), sqlEditorContainer: () => cy.getByTestId('sql-editor-container'),
searchInput: () => cy.getByTestId('ndv-search'),
pagination: () => cy.getByTestId('ndv-data-pagination'),
}; };
actions = { actions = {

View file

@ -13,12 +13,15 @@
:mappingEnabled="isMappingEnabled" :mappingEnabled="isMappingEnabled"
:distanceFromActive="currentNodeDepth" :distanceFromActive="currentNodeDepth"
:isProductionExecutionPreview="isProductionExecutionPreview" :isProductionExecutionPreview="isProductionExecutionPreview"
:isPaneActive="isPaneActive"
@activatePane="activatePane"
paneType="input" paneType="input"
@itemHover="$emit('itemHover', $event)" @itemHover="$emit('itemHover', $event)"
@linkRun="onLinkRun" @linkRun="onLinkRun"
@unlinkRun="onUnlinkRun" @unlinkRun="onUnlinkRun"
@runChange="onRunIndexChange" @runChange="onRunIndexChange"
@tableMounted="$emit('tableMounted', $event)" @tableMounted="$emit('tableMounted', $event)"
@search="$emit('search', $event)"
data-test-id="ndv-input-panel" data-test-id="ndv-input-panel"
> >
<template #header> <template #header>
@ -209,6 +212,10 @@ export default defineComponent({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isPaneActive: {
type: Boolean,
default: false,
},
}, },
data() { data() {
return { return {
@ -455,6 +462,9 @@ export default defineComponent({
} }
return truncated; return truncated;
}, },
activatePane() {
this.$emit('activatePane');
},
}, },
watch: { watch: {
inputMode: { inputMode: {

View file

@ -65,6 +65,8 @@
:sessionId="sessionId" :sessionId="sessionId"
:readOnly="readOnly || hasForeignCredential" :readOnly="readOnly || hasForeignCredential"
:isProductionExecutionPreview="isProductionExecutionPreview" :isProductionExecutionPreview="isProductionExecutionPreview"
:isPaneActive="isInputPaneActive"
@activatePane="activateInputPane"
@linkRun="onLinkRunToInput" @linkRun="onLinkRunToInput"
@unlinkRun="() => onUnlinkRun('input')" @unlinkRun="() => onUnlinkRun('input')"
@runChange="onRunInputIndexChange" @runChange="onRunInputIndexChange"
@ -73,6 +75,7 @@
@execute="onNodeExecute" @execute="onNodeExecute"
@tableMounted="onInputTableMounted" @tableMounted="onInputTableMounted"
@itemHover="onInputItemHover" @itemHover="onInputItemHover"
@search="onSearch"
/> />
</template> </template>
<template #output> <template #output>
@ -85,12 +88,15 @@
:isReadOnly="readOnly || hasForeignCredential" :isReadOnly="readOnly || hasForeignCredential"
:blockUI="blockUi && isTriggerNode && !isExecutableTriggerNode" :blockUI="blockUi && isTriggerNode && !isExecutableTriggerNode"
:isProductionExecutionPreview="isProductionExecutionPreview" :isProductionExecutionPreview="isProductionExecutionPreview"
:isPaneActive="isOutputPaneActive"
@activatePane="activateOutputPane"
@linkRun="onLinkRunToOutput" @linkRun="onLinkRunToOutput"
@unlinkRun="() => onUnlinkRun('output')" @unlinkRun="() => onUnlinkRun('output')"
@runChange="onRunOutputIndexChange" @runChange="onRunOutputIndexChange"
@openSettings="openSettings" @openSettings="openSettings"
@tableMounted="onOutputTableMounted" @tableMounted="onOutputTableMounted"
@itemHover="onOutputItemHover" @itemHover="onOutputItemHover"
@search="onSearch"
/> />
</template> </template>
<template #main> <template #main>
@ -211,6 +217,9 @@ export default defineComponent({
pinDataDiscoveryTooltipVisible: false, pinDataDiscoveryTooltipVisible: false,
avgInputRowHeight: 0, avgInputRowHeight: 0,
avgOutputRowHeight: 0, avgOutputRowHeight: 0,
isInputPaneActive: false,
isOutputPaneActive: false,
isPairedItemHoveringEnabled: true,
}; };
}, },
mounted() { mounted() {
@ -516,10 +525,7 @@ export default defineComponent({
} }
}, },
onInputItemHover(e: { itemIndex: number; outputIndex: number } | null) { onInputItemHover(e: { itemIndex: number; outputIndex: number } | null) {
if (!this.inputNodeName) { if (e === null || !this.inputNodeName || !this.isPairedItemHoveringEnabled) {
return;
}
if (e === null) {
this.ndvStore.setHoveringItem(null); this.ndvStore.setHoveringItem(null);
return; return;
} }
@ -533,7 +539,7 @@ export default defineComponent({
this.ndvStore.setHoveringItem(item); this.ndvStore.setHoveringItem(item);
}, },
onOutputItemHover(e: { itemIndex: number; outputIndex: number } | null) { onOutputItemHover(e: { itemIndex: number; outputIndex: number } | null) {
if (e === null || !this.activeNode) { if (e === null || !this.activeNode || !this.isPairedItemHoveringEnabled) {
this.ndvStore.setHoveringItem(null); this.ndvStore.setHoveringItem(null);
return; return;
} }
@ -717,6 +723,17 @@ export default defineComponent({
onStopExecution() { onStopExecution() {
this.$emit('stopExecution'); this.$emit('stopExecution');
}, },
activateInputPane() {
this.isInputPaneActive = true;
this.isOutputPaneActive = false;
},
activateOutputPane() {
this.isInputPaneActive = false;
this.isOutputPaneActive = true;
},
onSearch(search: string) {
this.isPairedItemHoveringEnabled = !search;
},
}, },
}); });
</script> </script>

View file

@ -11,12 +11,15 @@
:sessionId="sessionId" :sessionId="sessionId"
:blockUI="blockUI" :blockUI="blockUI"
:isProductionExecutionPreview="isProductionExecutionPreview" :isProductionExecutionPreview="isProductionExecutionPreview"
:isPaneActive="isPaneActive"
@activatePane="activatePane"
paneType="output" paneType="output"
@runChange="onRunIndexChange" @runChange="onRunIndexChange"
@linkRun="onLinkRun" @linkRun="onLinkRun"
@unlinkRun="onUnlinkRun" @unlinkRun="onUnlinkRun"
@tableMounted="$emit('tableMounted', $event)" @tableMounted="$emit('tableMounted', $event)"
@itemHover="$emit('itemHover', $event)" @itemHover="$emit('itemHover', $event)"
@search="$emit('search', $event)"
ref="runData" ref="runData"
:data-output-type="outputMode" :data-output-type="outputMode"
> >
@ -166,6 +169,10 @@ export default defineComponent({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isPaneActive: {
type: Boolean,
default: false,
},
}, },
computed: { computed: {
...mapStores(useNodeTypesStore, useNDVStore, useUIStore, useWorkflowsStore), ...mapStores(useNodeTypesStore, useNDVStore, useUIStore, useWorkflowsStore),
@ -320,6 +327,9 @@ export default defineComponent({
ndvEventBus.emit('setPositionByName', 'initial'); ndvEventBus.emit('setPositionByName', 'initial');
} }
}, },
activatePane() {
this.$emit('activatePane');
},
}, },
}); });
</script> </script>

View file

@ -1,5 +1,5 @@
<template> <template>
<div :class="['run-data', $style.container]"> <div :class="['run-data', $style.container]" @mouseover="activatePane">
<n8n-callout <n8n-callout
v-if="canPinData && hasPinData && !editMode.enabled && !isProductionExecutionPreview" v-if="canPinData && hasPinData && !editMode.enabled && !isProductionExecutionPreview"
theme="secondary" theme="secondary"
@ -49,9 +49,7 @@
> >
<n8n-radio-buttons <n8n-radio-buttons
v-show=" v-show="
hasNodeRun && hasNodeRun && (inputData.length || binaryData.length || search) && !editMode.enabled
((jsonData && jsonData.length > 0) || (binaryData && binaryData.length > 0)) &&
!editMode.enabled
" "
:modelValue="displayMode" :modelValue="displayMode"
:options="buttons" :options="buttons"
@ -72,7 +70,7 @@
/> />
<n8n-tooltip <n8n-tooltip
placement="bottom-end" placement="bottom-end"
v-if="canPinData && jsonData && jsonData.length > 0" v-if="canPinData && rawInputData.length"
v-show="!editMode.enabled" v-show="!editMode.enabled"
:visible=" :visible="
isControlledPinDataTooltip isControlledPinDataTooltip
@ -135,6 +133,7 @@
v-show="!editMode.enabled" v-show="!editMode.enabled"
data-test-id="run-selector" data-test-id="run-selector"
> >
<div :class="$style.runSelectorWrapper">
<n8n-select <n8n-select
size="small" size="small"
:modelValue="runIndex" :modelValue="runIndex"
@ -150,7 +149,6 @@
:key="option" :key="option"
></n8n-option> ></n8n-option>
</n8n-select> </n8n-select>
<n8n-tooltip placement="right" v-if="canLinkRuns"> <n8n-tooltip placement="right" v-if="canLinkRuns">
<template #content> <template #content>
{{ $locale.baseText(linkedRuns ? 'runData.unlinking.hint' : 'runData.linking.hint') }} {{ $locale.baseText(linkedRuns ? 'runData.unlinking.hint' : 'runData.linking.hint') }}
@ -164,9 +162,16 @@
@click="toggleLinkRuns" @click="toggleLinkRuns"
/> />
</n8n-tooltip> </n8n-tooltip>
<slot name="run-info"></slot> <slot name="run-info"></slot>
</div> </div>
<run-data-search
v-if="showIOSearch"
v-model="search"
:paneType="paneType"
:isAreaActive="isPaneActive"
@focus="activatePane"
/>
</div>
<slot name="before-data" /> <slot name="before-data" />
<div <div
@ -179,18 +184,48 @@
:options="branches" :options="branches"
@update:modelValue="onBranchChange" @update:modelValue="onBranchChange"
/> />
<run-data-search
v-if="showIOSearch"
v-model="search"
:paneType="paneType"
:isAreaActive="isPaneActive"
@focus="activatePane"
/>
</div> </div>
<div <div
v-else-if=" v-else-if="
hasNodeRun && dataCount > 0 && maxRunIndex === 0 && !isArtificialRecoveredEventItem hasNodeRun &&
((dataCount > 0 && maxRunIndex === 0) || search) &&
!isArtificialRecoveredEventItem
" "
v-show="!editMode.enabled" v-show="!editMode.enabled"
:class="$style.itemsCount" :class="$style.itemsCount"
data-test-id="ndv-items-count"
> >
<n8n-text> <n8n-text v-if="search">
{{ dataCount }} {{ $locale.baseText('ndv.output.items', { adjustToNumber: dataCount }) }} {{
$locale.baseText('ndv.search.items', {
adjustToNumber: unfilteredDataCount,
interpolate: { matched: dataCount, total: unfilteredDataCount },
})
}}
</n8n-text> </n8n-text>
<n8n-text v-else>
{{
$locale.baseText('ndv.output.items', {
adjustToNumber: dataCount,
interpolate: { count: dataCount },
})
}}
</n8n-text>
<run-data-search
v-if="showIOSearch"
v-model="search"
:paneType="paneType"
:isAreaActive="isPaneActive"
@focus="activatePane"
/>
</div> </div>
<div :class="$style.dataContainer" ref="dataContainer" data-test-id="ndv-data-container"> <div :class="$style.dataContainer" ref="dataContainer" data-test-id="ndv-data-container">
@ -258,15 +293,31 @@
</div> </div>
<div <div
v-else-if="hasNodeRun && jsonData && jsonData.length === 0 && branches.length > 1" v-else-if="
hasNodeRun && (!unfilteredDataCount || (search && !dataCount)) && branches.length > 1
"
:class="$style.center" :class="$style.center"
> >
<div v-if="search">
<n8n-text tag="h3" size="large">{{
$locale.baseText('ndv.search.noMatch.title')
}}</n8n-text>
<n8n-text> <n8n-text>
<i18n-t keypath="ndv.search.noMatch.description" tag="span">
<template #link>
<a href="#" @click="onSearchClear">
{{ $locale.baseText('ndv.search.noMatch.description.link') }}
</a>
</template>
</i18n-t>
</n8n-text>
</div>
<n8n-text v-else>
{{ noDataInBranchMessage }} {{ noDataInBranchMessage }}
</n8n-text> </n8n-text>
</div> </div>
<div v-else-if="hasNodeRun && jsonData && jsonData.length === 0" :class="$style.center"> <div v-else-if="hasNodeRun && !inputData.length && !search" :class="$style.center">
<slot name="no-output-data">xxx</slot> <slot name="no-output-data">xxx</slot>
</div> </div>
@ -303,7 +354,7 @@
hasNodeRun && hasNodeRun &&
displayMode === 'table' && displayMode === 'table' &&
binaryData.length > 0 && binaryData.length > 0 &&
jsonData.length === 1 && inputData.length === 1 &&
Object.keys(jsonData[0] || {}).length === 0 Object.keys(jsonData[0] || {}).length === 0
" "
:class="$style.center" :class="$style.center"
@ -316,6 +367,21 @@
</n8n-text> </n8n-text>
</div> </div>
<div v-else-if="showIoSearchNoMatchContent" :class="$style.center">
<n8n-text tag="h3" size="large">{{
$locale.baseText('ndv.search.noMatch.title')
}}</n8n-text>
<n8n-text>
<i18n-t keypath="ndv.search.noMatch.description" tag="span">
<template #link>
<a href="#" @click="onSearchClear">
{{ $locale.baseText('ndv.search.noMatch.description.link') }}
</a>
</template>
</i18n-t>
</n8n-text>
</div>
<Suspense v-else-if="hasNodeRun && displayMode === 'table'"> <Suspense v-else-if="hasNodeRun && displayMode === 'table'">
<run-data-table <run-data-table
:node="node" :node="node"
@ -325,7 +391,8 @@
:runIndex="runIndex" :runIndex="runIndex"
:pageOffset="currentPageOffset" :pageOffset="currentPageOffset"
:totalRuns="maxRunIndex" :totalRuns="maxRunIndex"
:hasDefaultHoverState="paneType === 'input'" :hasDefaultHoverState="paneType === 'input' && !search"
:search="search"
@mounted="$emit('tableMounted', $event)" @mounted="$emit('tableMounted', $event)"
@activeRowChanged="onItemHover" @activeRowChanged="onItemHover"
@displayModeChange="onDisplayModeChange" @displayModeChange="onDisplayModeChange"
@ -343,6 +410,7 @@
:distanceFromActive="distanceFromActive" :distanceFromActive="distanceFromActive"
:runIndex="runIndex" :runIndex="runIndex"
:totalRuns="maxRunIndex" :totalRuns="maxRunIndex"
:search="search"
/> />
</Suspense> </Suspense>
@ -359,6 +427,7 @@
:paneType="paneType" :paneType="paneType"
:runIndex="runIndex" :runIndex="runIndex"
:totalRuns="maxRunIndex" :totalRuns="maxRunIndex"
:search="search"
/> />
</Suspense> </Suspense>
@ -461,6 +530,7 @@
!isArtificialRecoveredEventItem !isArtificialRecoveredEventItem
" "
v-show="!editMode.enabled" v-show="!editMode.enabled"
data-test-id="ndv-data-pagination"
> >
<el-pagination <el-pagination
background background
@ -542,7 +612,7 @@ import { pinData } from '@/mixins/pinData';
import type { PinDataSource } from '@/mixins/pinData'; import type { PinDataSource } from '@/mixins/pinData';
import CodeNodeEditor from '@/components/CodeNodeEditor/CodeNodeEditor.vue'; import CodeNodeEditor from '@/components/CodeNodeEditor/CodeNodeEditor.vue';
import { dataPinningEventBus } from '@/event-bus'; import { dataPinningEventBus } from '@/event-bus';
import { clearJsonKey, executionDataToJson, isEmpty } from '@/utils'; import { clearJsonKey, executionDataToJson, isEmpty, searchInObject } from '@/utils';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
@ -553,6 +623,7 @@ const RunDataTable = defineAsyncComponent(async () => import('@/components/RunDa
const RunDataJson = defineAsyncComponent(async () => import('@/components/RunDataJson.vue')); const RunDataJson = defineAsyncComponent(async () => import('@/components/RunDataJson.vue'));
const RunDataSchema = defineAsyncComponent(async () => import('@/components/RunDataSchema.vue')); const RunDataSchema = defineAsyncComponent(async () => import('@/components/RunDataSchema.vue'));
const RunDataHtml = defineAsyncComponent(async () => import('@/components/RunDataHtml.vue')); const RunDataHtml = defineAsyncComponent(async () => import('@/components/RunDataHtml.vue'));
const RunDataSearch = defineAsyncComponent(async () => import('@/components/RunDataSearch.vue'));
export type EnterEditModeArgs = { export type EnterEditModeArgs = {
origin: 'editIconButton' | 'insertTestDataLink'; origin: 'editIconButton' | 'insertTestDataLink';
@ -569,6 +640,7 @@ export default defineComponent({
RunDataJson, RunDataJson,
RunDataSchema, RunDataSchema,
RunDataHtml, RunDataHtml,
RunDataSearch,
}, },
props: { props: {
nodeUi: { nodeUi: {
@ -619,6 +691,10 @@ export default defineComponent({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isPaneActive: {
type: Boolean,
default: false,
},
}, },
setup() { setup() {
return { return {
@ -643,6 +719,7 @@ export default defineComponent({
pinDataDiscoveryTooltipVisible: false, pinDataDiscoveryTooltipVisible: false,
isControlledPinDataTooltip: false, isControlledPinDataTooltip: false,
search: '',
}; };
}, },
mounted() { mounted() {
@ -656,7 +733,10 @@ export default defineComponent({
branchIndex: this.currentOutputIndex, branchIndex: this.currentOutputIndex,
}); });
if (this.paneType === 'output') this.setDisplayMode(); if (this.paneType === 'output') {
this.setDisplayMode();
this.activatePane();
}
}, },
beforeUnmount() { beforeUnmount() {
this.hidePinDataDiscoveryTooltip(); this.hidePinDataDiscoveryTooltip();
@ -777,6 +857,9 @@ export default defineComponent({
dataCount(): number { dataCount(): number {
return this.getDataCount(this.runIndex, this.currentOutputIndex); return this.getDataCount(this.runIndex, this.currentOutputIndex);
}, },
unfilteredDataCount(): number {
return this.pinData ? this.pinData.length : this.rawInputData.length;
},
dataSizeInMB(): string { dataSizeInMB(): string {
return (this.dataSize / 1024 / 1000).toLocaleString(); return (this.dataSize / 1024 / 1000).toLocaleString();
}, },
@ -828,7 +911,8 @@ export default defineComponent({
return this.getRawInputData(this.runIndex, this.currentOutputIndex, this.connectionType); return this.getRawInputData(this.runIndex, this.currentOutputIndex, this.connectionType);
}, },
inputData(): INodeExecutionData[] { inputData(): INodeExecutionData[] {
return this.getPinDataOrLiveData(this.rawInputData); const pinOrLiveData = this.getPinDataOrLiveData(this.rawInputData);
return this.getFilteredData(pinOrLiveData);
}, },
inputDataPage(): INodeExecutionData[] { inputDataPage(): INodeExecutionData[] {
const offset = this.pageSize * (this.currentPage - 1); const offset = this.pageSize * (this.currentPage - 1);
@ -866,8 +950,17 @@ export default defineComponent({
if (this.overrideOutputs && !this.overrideOutputs.includes(i)) { if (this.overrideOutputs && !this.overrideOutputs.includes(i)) {
continue; continue;
} }
const totalItemsCount = this.getRawInputData(this.runIndex, i).length;
const itemsCount = this.getDataCount(this.runIndex, i); const itemsCount = this.getDataCount(this.runIndex, i);
const items = this.$locale.baseText('ndv.output.items', { adjustToNumber: itemsCount }); const items = this.search
? this.$locale.baseText('ndv.search.items', {
adjustToNumber: totalItemsCount,
interpolate: { matched: itemsCount, total: totalItemsCount },
})
: this.$locale.baseText('ndv.output.items', {
adjustToNumber: itemsCount,
interpolate: { count: itemsCount },
});
let outputName = this.getOutputName(i); let outputName = this.getOutputName(i);
if (`${outputName}` === `${i}`) { if (`${outputName}` === `${i}`) {
@ -881,7 +974,10 @@ export default defineComponent({
outputName = capitalize(`${this.getOutputName(i)}${appendBranchWord}`); outputName = capitalize(`${this.getOutputName(i)}${appendBranchWord}`);
} }
branches.push({ branches.push({
label: itemsCount ? `${outputName} (${itemsCount} ${items})` : outputName, label:
(this.search && itemsCount) || totalItemsCount
? `${outputName} (${items})`
: outputName,
value: i, value: i,
}); });
} }
@ -901,6 +997,12 @@ export default defineComponent({
readOnlyEnv(): boolean { readOnlyEnv(): boolean {
return this.sourceControlStore.preferences.branchReadOnly; return this.sourceControlStore.preferences.branchReadOnly;
}, },
showIOSearch(): boolean {
return this.hasNodeRun && !this.hasRunError;
},
showIoSearchNoMatchContent(): boolean {
return this.hasNodeRun && !this.inputData.length && this.search;
},
}, },
methods: { methods: {
getResolvedNodeOutputs() { getResolvedNodeOutputs() {
@ -1158,10 +1260,13 @@ export default defineComponent({
getRunLabel(option: number) { getRunLabel(option: number) {
let itemsCount = 0; let itemsCount = 0;
for (let i = 0; i <= this.maxOutputIndex; i++) { for (let i = 0; i <= this.maxOutputIndex; i++) {
itemsCount += this.getDataCount(option - 1, i); itemsCount += this.getPinDataOrLiveData(this.getRawInputData(option - 1, i)).length;
} }
const items = this.$locale.baseText('ndv.output.items', { adjustToNumber: itemsCount }); const items = this.$locale.baseText('ndv.output.items', {
const itemsLabel = itemsCount > 0 ? ` (${itemsCount} ${items})` : ''; adjustToNumber: itemsCount,
interpolate: { count: itemsCount },
});
const itemsLabel = itemsCount > 0 ? ` (${items})` : '';
return option + this.$locale.baseText('ndv.output.of') + (this.maxRunIndex + 1) + itemsLabel; return option + this.$locale.baseText('ndv.output.of') + (this.maxRunIndex + 1) + itemsLabel;
}, },
getRawInputData( getRawInputData(
@ -1201,6 +1306,14 @@ export default defineComponent({
} }
return inputData; return inputData;
}, },
getFilteredData(inputData: INodeExecutionData[]): INodeExecutionData[] {
if (!this.search) {
return inputData;
}
this.currentPage = 1;
return inputData.filter(({ json }) => searchInObject(json, this.search));
},
getDataCount( getDataCount(
runIndex: number, runIndex: number,
outputIndex: number, outputIndex: number,
@ -1215,7 +1328,8 @@ export default defineComponent({
} }
const rawInputData = this.getRawInputData(runIndex, outputIndex, connectionType); const rawInputData = this.getRawInputData(runIndex, outputIndex, connectionType);
return this.getPinDataOrLiveData(rawInputData).length; const pinOrLiveData = this.getPinDataOrLiveData(rawInputData);
return this.getFilteredData(pinOrLiveData).length;
}, },
init() { init() {
// Reset the selected output index every time another node gets selected // Reset the selected output index every time another node gets selected
@ -1347,6 +1461,13 @@ export default defineComponent({
}); });
} }
}, },
activatePane() {
this.$emit('activatePane');
},
onSearchClear() {
this.search = '';
document.dispatchEvent(new KeyboardEvent('keyup', { key: '/' }));
},
}, },
watch: { watch: {
node() { node() {
@ -1384,6 +1505,9 @@ export default defineComponent({
branchIndex, branchIndex,
}); });
}, },
search(newSearch: string) {
this.$emit('search', newSearch);
},
}, },
}); });
</script> </script>
@ -1465,24 +1589,32 @@ export default defineComponent({
} }
.tabs { .tabs {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-s); margin-bottom: var(--spacing-s);
} }
.itemsCount { .itemsCount {
display: flex;
justify-content: space-between;
align-items: center;
margin-left: var(--spacing-s); margin-left: var(--spacing-s);
margin-bottom: var(--spacing-s); margin-bottom: var(--spacing-s);
} }
.runSelector { .runSelector {
max-width: 210px; padding-left: var(--spacing-s);
margin-left: var(--spacing-s); padding-bottom: var(--spacing-s);
margin-bottom: var(--spacing-s); display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
}
.runSelectorWrapper {
display: flex; display: flex;
align-items: center; align-items: center;
> * {
margin-right: var(--spacing-4xs);
}
} }
.pagination { .pagination {
@ -1645,3 +1777,14 @@ export default defineComponent({
} }
} }
</style> </style>
<style lang="scss" scoped>
:deep(.highlight) {
background-color: #f7dc55;
color: black;
border-radius: var(--border-radius-base);
padding: 0 1px;
font-weight: normal;
font-style: normal;
}
</style>

View file

@ -43,11 +43,11 @@
[$style.mappable]: mappingEnabled, [$style.mappable]: mappingEnabled,
[$style.dragged]: draggingPath === node.path, [$style.dragged]: draggingPath === node.path,
}" }"
>"{{ node.key }}"</span v-html="highlightSearchTerm(node.key)"
> />
</template> </template>
<template #renderNodeValue="{ node }"> <template #renderNodeValue="{ node }">
<span v-if="isNaN(node.index)">{{ getContent(node.content) }}</span> <span v-if="isNaN(node.index)" v-html="highlightSearchTerm(node.content)" />
<span <span
v-else v-else
data-target="mappable" data-target="mappable"
@ -60,8 +60,8 @@
[$style.dragged]: draggingPath === node.path, [$style.dragged]: draggingPath === node.path,
}" }"
class="ph-no-capture" class="ph-no-capture"
>{{ getContent(node.content) }}</span v-html="highlightSearchTerm(node.content)"
> />
</template> </template>
</vue-json-pretty> </vue-json-pretty>
</draggable> </draggable>
@ -74,7 +74,7 @@ import type { PropType } from 'vue';
import VueJsonPretty from 'vue-json-pretty'; import VueJsonPretty from 'vue-json-pretty';
import type { IDataObject, INodeExecutionData } from 'n8n-workflow'; import type { IDataObject, INodeExecutionData } from 'n8n-workflow';
import Draggable from '@/components/Draggable.vue'; import Draggable from '@/components/Draggable.vue';
import { executionDataToJson, isString, shorten } from '@/utils'; import { executionDataToJson, highlightText, isString, sanitizeHtml, shorten } from '@/utils';
import type { INodeUi } from '@/Interface'; import type { INodeUi } from '@/Interface';
import { externalHooks } from '@/mixins/externalHooks'; import { externalHooks } from '@/mixins/externalHooks';
import { mapStores } from 'pinia'; import { mapStores } from 'pinia';
@ -125,6 +125,9 @@ export default defineComponent({
totalRuns: { totalRuns: {
type: Number, type: Number,
}, },
search: {
type: String,
},
}, },
setup() { setup() {
const selectedJsonPath = ref(nonExistingJsonPath); const selectedJsonPath = ref(nonExistingJsonPath);
@ -194,6 +197,9 @@ export default defineComponent({
getListItemName(path: string): string { getListItemName(path: string): string {
return path.replace(/^(\["?\d"?]\.?)/g, ''); return path.replace(/^(\["?\d"?]\.?)/g, '');
}, },
highlightSearchTerm(value: string): string {
return sanitizeHtml(highlightText(this.getContent(value), this.search));
},
}, },
}); });
</script> </script>

View file

@ -18,6 +18,7 @@ type Props = {
totalRuns: number; totalRuns: number;
paneType: 'input' | 'output'; paneType: 'input' | 'output';
node: INodeUi | null; node: INodeUi | null;
search: string;
}; };
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@ -91,6 +92,7 @@ const onDragEnd = (el: HTMLElement) => {
:draggingPath="draggingPath" :draggingPath="draggingPath"
:distanceFromActive="distanceFromActive" :distanceFromActive="distanceFromActive"
:node="node" :node="node"
:search="search"
/> />
</div> </div>
</draggable> </draggable>

View file

@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue'; import { computed } from 'vue';
import type { INodeUi, Schema } from '@/Interface'; import type { INodeUi, Schema } from '@/Interface';
import { checkExhaustive, shorten } from '@/utils'; import { checkExhaustive, highlightText, sanitizeHtml, shorten } from '@/utils';
import { getMappedExpression } from '@/utils/mappingUtils'; import { getMappedExpression } from '@/utils/mappingUtils';
type Props = { type Props = {
@ -14,6 +14,7 @@ type Props = {
draggingPath: string; draggingPath: string;
distanceFromActive: number; distanceFromActive: number;
node: INodeUi | null; node: INodeUi | null;
search: string;
}; };
const props = defineProps<Props>(); const props = defineProps<Props>();
@ -26,8 +27,12 @@ const isFlat = computed(
Array.isArray(props.schema.value) && Array.isArray(props.schema.value) &&
props.schema.value.every((v) => !Array.isArray(v.value)), props.schema.value.every((v) => !Array.isArray(v.value)),
); );
const key = computed((): string | undefined => const key = computed((): string | undefined => {
isSchemaParentTypeArray.value ? `[${props.schema.key}]` : props.schema.key, const highlightedKey = sanitizeHtml(highlightText(props.schema.key, props.search));
return isSchemaParentTypeArray.value ? `[${highlightedKey}]` : highlightedKey;
});
const parentKey = computed((): string | undefined =>
sanitizeHtml(highlightText(props.parent.key, props.search)),
); );
const schemaName = computed(() => const schemaName = computed(() =>
isSchemaParentTypeArray.value ? `${props.schema.type}[${props.schema.key}]` : props.schema.key, isSchemaParentTypeArray.value ? `${props.schema.type}[${props.schema.key}]` : props.schema.key,
@ -92,8 +97,8 @@ const getIconBySchemaType = (type: Schema['type']): string => {
data-target="mappable" data-target="mappable"
> >
<font-awesome-icon :icon="getIconBySchemaType(schema.type)" size="sm" /> <font-awesome-icon :icon="getIconBySchemaType(schema.type)" size="sm" />
<span v-if="isSchemaParentTypeArray">{{ parent.key }}</span> <span v-if="isSchemaParentTypeArray" v-html="parentKey" />
<span v-if="key" :class="{ [$style.arrayIndex]: isSchemaParentTypeArray }">{{ key }}</span> <span v-if="key" :class="{ [$style.arrayIndex]: isSchemaParentTypeArray }" v-html="key" />
</span> </span>
</div> </div>
<span v-if="text" :class="$style.text">{{ text }}</span> <span v-if="text" :class="$style.text">{{ text }}</span>
@ -115,6 +120,7 @@ const getIconBySchemaType = (type: Schema['type']): string => {
:distanceFromActive="distanceFromActive" :distanceFromActive="distanceFromActive"
:node="node" :node="node"
:style="{ transitionDelay: transitionDelay(i) }" :style="{ transitionDelay: transitionDelay(i) }"
:search="search"
/> />
</div> </div>
</div> </div>

View file

@ -0,0 +1,124 @@
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue';
import { useI18n } from '@/composables';
import type { NodePanelType } from '@/Interface';
type Props = {
modelValue: string;
paneType?: NodePanelType;
isAreaActive?: boolean;
};
const INITIAL_WIDTH = '34px';
const emit = defineEmits<{
(event: 'update:modelValue', value: Props['modelValue']): void;
(event: 'focus'): void;
}>();
const props = withDefaults(defineProps<Props>(), {
paneType: 'output',
isAreaActive: false,
});
const locale = useI18n();
const inputRef = ref<HTMLInputElement | null>(null);
const maxWidth = ref(INITIAL_WIDTH);
const opened = ref(false);
const focused = ref(false);
const placeholder = computed(() =>
props.paneType === 'input'
? locale.baseText('ndv.search.placeholder.input')
: locale.baseText('ndv.search.placeholder.output'),
);
const documentKeyHandler = (event: KeyboardEvent) => {
const isTargetAnyFormElement =
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement ||
event.target instanceof HTMLSelectElement;
if (event.key === '/' && !focused.value && props.isAreaActive && !isTargetAnyFormElement) {
inputRef.value?.focus();
inputRef.value?.select();
}
};
const onSearchUpdate = (value: string) => {
emit('update:modelValue', value);
};
const onFocus = () => {
opened.value = true;
focused.value = true;
maxWidth.value = '30%';
inputRef.value?.select();
emit('focus');
};
const onBlur = () => {
focused.value = false;
if (!props.modelValue) {
opened.value = false;
maxWidth.value = INITIAL_WIDTH;
}
};
onMounted(() => {
document.addEventListener('keyup', documentKeyHandler);
});
onUnmounted(() => {
document.removeEventListener('keyup', documentKeyHandler);
});
</script>
<template>
<n8n-input
ref="inputRef"
data-test-id="ndv-search"
:class="{
[$style.ioSearch]: true,
[$style.ioSearchOpened]: opened,
}"
:style="{ maxWidth }"
:modelValue="modelValue"
:placeholder="placeholder"
size="small"
@update:modelValue="onSearchUpdate"
@focus="onFocus"
@blur="onBlur"
>
<template #prefix>
<n8n-icon :class="$style.ioSearchIcon" icon="search" />
</template>
</n8n-input>
</template>
<style lang="scss" module>
@import '@/styles/css-animation-helpers.scss';
.ioSearch {
margin-right: var(--spacing-s);
transition: max-width 0.3s $ease-out-expo;
.ioSearchIcon {
color: var(--color-foreground-xdark);
cursor: pointer;
}
input {
border: 0;
background: transparent;
cursor: pointer;
}
}
.ioSearchOpened {
.ioSearchIcon {
cursor: default;
}
input {
border: var(--input-border-color, var(--border-color-base))
var(--input-border-style, var(--border-style-base)) var(--border-width-base);
background: var(--input-background-color, var(--color-foreground-xlight));
cursor: text;
}
}
</style>

View file

@ -52,7 +52,7 @@
[$style.draggingHeader]: isDragging, [$style.draggingHeader]: isDragging,
}" }"
> >
<span>{{ column || '&nbsp;' }}</span> <span v-html="highlightSearchTerm(column || '')" />
<div :class="$style.dragButton"> <div :class="$style.dragButton">
<font-awesome-icon icon="grip-vertical" /> <font-awesome-icon icon="grip-vertical" />
</div> </div>
@ -120,8 +120,8 @@
<span <span
v-if="isSimple(data)" v-if="isSimple(data)"
:class="{ [$style.value]: true, [$style.empty]: isEmpty(data) }" :class="{ [$style.value]: true, [$style.empty]: isEmpty(data) }"
>{{ getValueToRender(data) }}</span v-html="highlightSearchTerm(data)"
> />
<n8n-tree :nodeClass="$style.nodeClass" v-else :value="data"> <n8n-tree :nodeClass="$style.nodeClass" v-else :value="data">
<template #label="{ label, path }"> <template #label="{ label, path }">
<span <span
@ -141,9 +141,10 @@
> >
</template> </template>
<template #value="{ value }"> <template #value="{ value }">
<span :class="{ [$style.nestedValue]: true, [$style.empty]: isEmpty(value) }"> <span
{{ getValueToRender(value) }} :class="{ [$style.nestedValue]: true, [$style.empty]: isEmpty(value) }"
</span> v-html="highlightSearchTerm(value)"
/>
</template> </template>
</n8n-tree> </n8n-tree>
</td> </td>
@ -160,7 +161,7 @@ import { defineComponent } from 'vue';
import type { PropType } from 'vue'; import type { PropType } from 'vue';
import { mapStores } from 'pinia'; import { mapStores } from 'pinia';
import type { INodeUi, ITableData, NDVState } from '@/Interface'; import type { INodeUi, ITableData, NDVState } from '@/Interface';
import { getPairedItemId, shorten } from '@/utils'; import { getPairedItemId, highlightText, sanitizeHtml, shorten } from '@/utils';
import type { GenericValue, IDataObject, INodeExecutionData } from 'n8n-workflow'; import type { GenericValue, IDataObject, INodeExecutionData } from 'n8n-workflow';
import Draggable from './Draggable.vue'; import Draggable from './Draggable.vue';
import { externalHooks } from '@/mixins/externalHooks'; import { externalHooks } from '@/mixins/externalHooks';
@ -205,6 +206,9 @@ export default defineComponent({
hasDefaultHoverState: { hasDefaultHoverState: {
type: Boolean, type: Boolean,
}, },
search: {
type: String,
},
}, },
data() { data() {
return { return {
@ -360,7 +364,7 @@ export default defineComponent({
value === undefined value === undefined
); );
}, },
getValueToRender(value: unknown) { getValueToRender(value: unknown): string {
if (value === '') { if (value === '') {
return this.$locale.baseText('runData.emptyString'); return this.$locale.baseText('runData.emptyString');
} }
@ -376,8 +380,14 @@ export default defineComponent({
if (value === null || value === undefined) { if (value === null || value === undefined) {
return `[${value}]`; return `[${value}]`;
} }
if (value === true || value === false || typeof value === 'number') {
return value.toString();
}
return value; return value;
}, },
highlightSearchTerm(value: string): string {
return sanitizeHtml(highlightText(this.getValueToRender(value), this.search));
},
onDragStart() { onDragStart() {
this.draggedColumn = true; this.draggedColumn = true;
this.ndvStore.resetMappingTelemetry(); this.ndvStore.resetMappingTelemetry();

View file

@ -0,0 +1,93 @@
import userEvent from '@testing-library/user-event';
import { createPinia, setActivePinia } from 'pinia';
import { createComponentRenderer } from '@/__tests__/render';
import RunDataSearch from '@/components/RunDataSearch.vue';
import { useSettingsStore, useUIStore } from '@/stores';
const renderComponent = createComponentRenderer(RunDataSearch);
let pinia: ReturnType<typeof createPinia>;
let uiStore: ReturnType<typeof useUIStore>;
let settingsStore: ReturnType<typeof useSettingsStore>;
describe('RunDataSearch', () => {
beforeEach(() => {
pinia = createPinia();
setActivePinia(pinia);
uiStore = useUIStore();
settingsStore = useSettingsStore();
});
it('should not be focused on keyboard shortcut when area is not active', async () => {
const { emitted } = renderComponent({
pinia,
props: {
modelValue: '',
},
});
await userEvent.keyboard('/');
expect(emitted().focus).not.toBeDefined();
});
it('should be focused on click regardless of active area and keyboard shortcut should work after', async () => {
const { getByRole, emitted, rerender } = renderComponent({
pinia,
props: {
modelValue: '',
},
});
await userEvent.click(getByRole('textbox'));
expect(emitted().focus).toHaveLength(1);
await userEvent.click(document.body);
await rerender({ isAreaActive: true });
await userEvent.keyboard('/');
expect(emitted().focus).toHaveLength(2);
});
it('should be focused twice if area is already active', async () => {
const { getByRole, emitted } = renderComponent({
pinia,
props: {
modelValue: '',
isAreaActive: true,
},
});
await userEvent.click(getByRole('textbox'));
expect(emitted().focus).toHaveLength(1);
await userEvent.click(document.body);
await userEvent.keyboard('/');
expect(emitted().focus).toHaveLength(2);
});
it('should select all text when focused', async () => {
vi.spyOn(settingsStore, 'isEnterpriseFeatureEnabled', 'get').mockReturnValue(() => true);
const { getByRole, emitted } = renderComponent({
pinia,
props: {
modelValue: '',
isAreaActive: true,
},
});
const input = getByRole('textbox');
await userEvent.click(input);
expect(emitted().focus).toHaveLength(1);
await userEvent.type(input, 'test');
await userEvent.click(document.body);
await userEvent.click(input);
expect(emitted().focus).toHaveLength(2);
const selectionStart = input.selectionStart;
const selectionEnd = input.selectionEnd;
const isSelected = selectionStart === 0 && selectionEnd === input.value.length;
expect(isSelected).toBe(true);
});
});

View file

@ -621,6 +621,7 @@ export const ALLOWED_HTML_TAGS = [
'small', 'small',
'details', 'details',
'summary', 'summary',
'mark',
]; ];
export const CLOUD_CHANGE_PLAN_PAGE = window.location.host.includes('stage-app.n8n.cloud') export const CLOUD_CHANGE_PLAN_PAGE = window.location.host.includes('stage-app.n8n.cloud')

View file

@ -779,7 +779,7 @@
"ndv.output.all": "all", "ndv.output.all": "all",
"ndv.output.branch": "Branch", "ndv.output.branch": "Branch",
"ndv.output.executing": "Executing node...", "ndv.output.executing": "Executing node...",
"ndv.output.items": "item | items", "ndv.output.items": "{count} item | {count} items",
"ndv.output.noOutputData.message": "n8n stops executing the workflow when a node has no output data. You can change this default behaviour via", "ndv.output.noOutputData.message": "n8n stops executing the workflow when a node has no output data. You can change this default behaviour via",
"ndv.output.noOutputData.message.settings": "Settings", "ndv.output.noOutputData.message.settings": "Settings",
"ndv.output.noOutputData.message.settingsOption": "> “Always Output Data”.", "ndv.output.noOutputData.message.settingsOption": "> “Always Output Data”.",
@ -1756,6 +1756,12 @@
"ndv.trigger.pollingNode.executionsHelp.inactive": "<b>While building your workflow</b>, click the 'fetch' button to fetch a single mock event. It will show up in this editor.<br /><br /><b>Once you're happy with your workflow</b>, <a data-key=\"activate\">activate</a> it. Then n8n will regularly check {service} for new events, and execute this workflow if it finds any. These executions will show up in the <a data-key=\"executions\">executions list</a>, but not in the editor.", "ndv.trigger.pollingNode.executionsHelp.inactive": "<b>While building your workflow</b>, click the 'fetch' button to fetch a single mock event. It will show up in this editor.<br /><br /><b>Once you're happy with your workflow</b>, <a data-key=\"activate\">activate</a> it. Then n8n will regularly check {service} for new events, and execute this workflow if it finds any. These executions will show up in the <a data-key=\"executions\">executions list</a>, but not in the editor.",
"ndv.trigger.pollingNode.executionsHelp.active": "<b>While building your workflow</b>, click the 'fetch' button to fetch a single mock event. It will show up in this editor.<br /><br /><b>Your workflow will also execute automatically</b>, since it's activated. n8n will regularly check {app_name} for new events, and execute this workflow if it finds any. These executions will show up in the <a data-key=\"executions\">executions list</a>, but not in the editor.", "ndv.trigger.pollingNode.executionsHelp.active": "<b>While building your workflow</b>, click the 'fetch' button to fetch a single mock event. It will show up in this editor.<br /><br /><b>Your workflow will also execute automatically</b>, since it's activated. n8n will regularly check {app_name} for new events, and execute this workflow if it finds any. These executions will show up in the <a data-key=\"executions\">executions list</a>, but not in the editor.",
"ndv.trigger.webhookBasedNode.action": "Pull in events from {name}", "ndv.trigger.webhookBasedNode.action": "Pull in events from {name}",
"ndv.search.placeholder.output": "Filter output",
"ndv.search.placeholder.input": "Filter input",
"ndv.search.noMatch.title": "No matching items",
"ndv.search.noMatch.description": "Try changing or {link} the filter to see more",
"ndv.search.noMatch.description.link": "clearing",
"ndv.search.items": "{matched} of {total} item | {matched} of {total} items",
"updatesPanel.andIs": "and is", "updatesPanel.andIs": "and is",
"updatesPanel.behindTheLatest": "behind the latest and greatest n8n", "updatesPanel.behindTheLatest": "behind the latest and greatest n8n",
"updatesPanel.howToUpdateYourN8nVersion": "How to update your n8n version", "updatesPanel.howToUpdateYourN8nVersion": "How to update your n8n version",

View file

@ -0,0 +1,41 @@
import { highlightText } from '@/utils';
describe('highlightText', () => {
it('should return original text if search parameter is an empty string', () => {
const text = 'some text';
const result = highlightText(text);
expect(result).toBe(text);
});
it('should return original text if it is an empty string', () => {
const text = '';
const result = highlightText(text, 'search');
expect(result).toBe(text);
});
it('should escape special characters in the search string', () => {
const text = 'some text [example]';
const result = highlightText(text, '[example]');
expect(result).toBe('some text <mark class="highlight">[example]</mark>');
});
it('should escape other special characters in the search string', () => {
const text = 'phone number: +123-456-7890';
const result = highlightText(text, '+123-456-7890');
expect(result).toBe('phone number: <mark class="highlight">+123-456-7890</mark>');
});
it('should highlight occurrences of the search string in text', () => {
const text = 'example text example';
const result = highlightText(text, 'example');
expect(result).toBe(
'<mark class="highlight">example</mark> text <mark class="highlight">example</mark>',
);
});
it('should return original text if the search string is not found', () => {
const text = 'some text';
const result = highlightText(text, 'notfound');
expect(result).toBe(text);
});
});

View file

@ -63,3 +63,9 @@ export const getBannerRowHeight = async (): Promise<number> => {
}, 0); }, 0);
}); });
}; };
export const highlightText = (text: string, search = ''): string => {
const pattern = search.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&');
const regex = new RegExp(`(${pattern})`, 'gi');
return search ? text?.replace(regex, '<mark class="highlight">$1</mark>') : text;
};

View file

@ -16,5 +16,5 @@ export const searchInObject = (obj: ObjectOrArray, searchString: string): boolea
(Array.isArray(obj) ? obj : Object.entries(obj)).some((entry) => (Array.isArray(obj) ? obj : Object.entries(obj)).some((entry) =>
isObjectOrArray(entry) isObjectOrArray(entry)
? searchInObject(entry, searchString) ? searchInObject(entry, searchString)
: entry?.toString().includes(searchString), : entry?.toString().toLowerCase().includes(searchString.toLowerCase()),
); );