From a62d00a4795d02a5905c5ddbae569f122a46a023 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 24 Jul 2023 11:12:52 +0200 Subject: [PATCH 1/5] fix(editor): Skip error line highlighting if out of range (#6721) --- .../CodeNodeEditor/CodeNodeEditor.vue | 19 +++++++++++++++---- .../src/components/SqlEditor/SqlEditor.vue | 18 +++++++++++++++--- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue b/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue index 54fe35687c..49b0a1d94c 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue +++ b/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue @@ -24,7 +24,7 @@ import type { PropType } from 'vue'; import { mapStores } from 'pinia'; import type { LanguageSupport } from '@codemirror/language'; -import type { Extension } from '@codemirror/state'; +import type { Extension, Line } from '@codemirror/state'; import { Compartment, EditorState } from '@codemirror/state'; import type { ViewUpdate } from '@codemirror/view'; import { EditorView } from '@codemirror/view'; @@ -154,18 +154,29 @@ export default defineComponent({ changes: { from: 0, to: this.content.length, insert: this.placeholder }, }); }, - highlightLine(line: number | 'final') { + line(lineNumber: number): Line | null { + try { + return this.editor?.state.doc.line(lineNumber) ?? null; + } catch { + return null; + } + }, + highlightLine(lineNumber: number | 'final') { if (!this.editor) return; - if (line === 'final') { + if (lineNumber === 'final') { this.editor.dispatch({ selection: { anchor: this.content.length }, }); return; } + const line = this.line(lineNumber); + + if (!line) return; + this.editor.dispatch({ - selection: { anchor: this.editor.state.doc.line(line).from }, + selection: { anchor: line.from }, }); }, trackCompletion(viewUpdate: ViewUpdate) { diff --git a/packages/editor-ui/src/components/SqlEditor/SqlEditor.vue b/packages/editor-ui/src/components/SqlEditor/SqlEditor.vue index 596cbdb908..77b5c9e8ff 100644 --- a/packages/editor-ui/src/components/SqlEditor/SqlEditor.vue +++ b/packages/editor-ui/src/components/SqlEditor/SqlEditor.vue @@ -17,6 +17,7 @@ import { acceptCompletion, autocompletion, ifNotIn } from '@codemirror/autocompl import { indentWithTab, history, redo, toggleComment } from '@codemirror/commands'; import { bracketMatching, foldGutter, indentOnInput, LanguageSupport } from '@codemirror/language'; import { EditorState } from '@codemirror/state'; +import type { Line } from '@codemirror/state'; import type { Extension } from '@codemirror/state'; import { dropCursor, @@ -189,18 +190,29 @@ export default defineComponent({ onBlur() { this.isFocused = false; }, - highlightLine(line: number | 'final') { + line(lineNumber: number): Line | null { + try { + return this.editor?.state.doc.line(lineNumber) ?? null; + } catch { + return null; + } + }, + highlightLine(lineNumber: number | 'final') { if (!this.editor) return; - if (line === 'final') { + if (lineNumber === 'final') { this.editor.dispatch({ selection: { anchor: this.query.length }, }); return; } + const line = this.line(lineNumber); + + if (!line) return; + this.editor.dispatch({ - selection: { anchor: this.editor.state.doc.line(line).from }, + selection: { anchor: line.from }, }); }, }, From 540d32dee4b8927199e047c77acf516d5b824bc3 Mon Sep 17 00:00:00 2001 From: Jordan Hall Date: Mon, 24 Jul 2023 11:35:05 +0100 Subject: [PATCH 2/5] fix(AwsS3 Node): Fix issue if bucket name contains a '.' (#6542) --- .../nodes/Aws/S3/V2/AwsS3V2.node.ts | 106 ++++++++++-------- 1 file changed, 62 insertions(+), 44 deletions(-) diff --git a/packages/nodes-base/nodes/Aws/S3/V2/AwsS3V2.node.ts b/packages/nodes-base/nodes/Aws/S3/V2/AwsS3V2.node.ts index 67352e1bdc..6c09d2a900 100644 --- a/packages/nodes-base/nodes/Aws/S3/V2/AwsS3V2.node.ts +++ b/packages/nodes-base/nodes/Aws/S3/V2/AwsS3V2.node.ts @@ -215,6 +215,8 @@ export class AwsS3V2 implements INodeType { //https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html if (operation === 'search') { const bucketName = this.getNodeParameter('bucketName', i) as string; + const servicePath = bucketName.includes('.') ? 's3' : `${bucketName}.s3`; + const basePath = bucketName.includes('.') ? `/${bucketName}` : ''; const returnAll = this.getNodeParameter('returnAll', 0); const additionalFields = this.getNodeParameter('additionalFields', 0); @@ -243,8 +245,7 @@ export class AwsS3V2 implements INodeType { } qs['list-type'] = 2; - - responseData = await awsApiRequestREST.call(this, `${bucketName}.s3`, 'GET', '', '', { + responseData = await awsApiRequestREST.call(this, servicePath, 'GET', basePath, '', { location: '', }); @@ -254,9 +255,9 @@ export class AwsS3V2 implements INodeType { responseData = await awsApiRequestRESTAllItems.call( this, 'ListBucketResult.Contents', - `${bucketName}.s3`, + servicePath, 'GET', - '', + basePath, '', qs, {}, @@ -267,9 +268,9 @@ export class AwsS3V2 implements INodeType { qs['max-keys'] = this.getNodeParameter('limit', 0); responseData = await awsApiRequestREST.call( this, - `${bucketName}.s3`, + servicePath, 'GET', - '', + basePath, '', qs, {}, @@ -282,6 +283,7 @@ export class AwsS3V2 implements INodeType { this.helpers.returnJsonArray(responseData as IDataObject[]), { itemData: { item: i } }, ); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument returnData.push(...executionData); } } @@ -289,9 +291,11 @@ export class AwsS3V2 implements INodeType { //https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html if (operation === 'create') { const bucketName = this.getNodeParameter('bucketName', i) as string; + const servicePath = bucketName.includes('.') ? 's3' : `${bucketName}.s3`; + const basePath = bucketName.includes('.') ? `/${bucketName}` : ''; const folderName = this.getNodeParameter('folderName', i) as string; const additionalFields = this.getNodeParameter('additionalFields', i); - let path = `/${folderName}/`; + let path = `${basePath}/${folderName}/`; if (additionalFields.requesterPays) { headers['x-amz-request-payer'] = 'requester'; @@ -304,7 +308,7 @@ export class AwsS3V2 implements INodeType { additionalFields.storageClass as string, ).toUpperCase(); } - responseData = await awsApiRequestREST.call(this, `${bucketName}.s3`, 'GET', '', '', { + responseData = await awsApiRequestREST.call(this, servicePath, 'GET', basePath, '', { location: '', }); @@ -312,7 +316,7 @@ export class AwsS3V2 implements INodeType { responseData = await awsApiRequestREST.call( this, - `${bucketName}.s3`, + servicePath, 'PUT', path, '', @@ -330,9 +334,11 @@ export class AwsS3V2 implements INodeType { //https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html if (operation === 'delete') { const bucketName = this.getNodeParameter('bucketName', i) as string; + const servicePath = bucketName.includes('.') ? 's3' : `${bucketName}.s3`; + const basePath = bucketName.includes('.') ? `/${bucketName}` : ''; const folderKey = this.getNodeParameter('folderKey', i) as string; - responseData = await awsApiRequestREST.call(this, `${bucketName}.s3`, 'GET', '', '', { + responseData = await awsApiRequestREST.call(this, servicePath, 'GET', basePath, '', { location: '', }); @@ -341,9 +347,9 @@ export class AwsS3V2 implements INodeType { responseData = await awsApiRequestRESTAllItems.call( this, 'ListBucketResult.Contents', - `${bucketName}.s3`, + servicePath, 'GET', - '/', + basePath, '', { 'list-type': 2, prefix: folderKey }, {}, @@ -355,9 +361,9 @@ export class AwsS3V2 implements INodeType { if (responseData.length === 0) { responseData = await awsApiRequestREST.call( this, - `${bucketName}.s3`, + servicePath, 'DELETE', - `/${folderKey}`, + `${basePath}/${folderKey}`, '', qs, {}, @@ -393,9 +399,9 @@ export class AwsS3V2 implements INodeType { responseData = await awsApiRequestREST.call( this, - `${bucketName}.s3`, + servicePath, 'POST', - '/', + `${basePath}/`, data, { delete: '' }, headers, @@ -414,6 +420,8 @@ export class AwsS3V2 implements INodeType { //https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html if (operation === 'getAll') { const bucketName = this.getNodeParameter('bucketName', i) as string; + const servicePath = bucketName.includes('.') ? 's3' : `${bucketName}.s3`; + const basePath = bucketName.includes('.') ? `/${bucketName}` : ''; const returnAll = this.getNodeParameter('returnAll', 0); const options = this.getNodeParameter('options', 0); @@ -427,7 +435,7 @@ export class AwsS3V2 implements INodeType { qs['list-type'] = 2; - responseData = await awsApiRequestREST.call(this, `${bucketName}.s3`, 'GET', '', '', { + responseData = await awsApiRequestREST.call(this, servicePath, 'GET', basePath, '', { location: '', }); @@ -437,9 +445,9 @@ export class AwsS3V2 implements INodeType { responseData = await awsApiRequestRESTAllItems.call( this, 'ListBucketResult.Contents', - `${bucketName}.s3`, + servicePath, 'GET', - '', + basePath, '', qs, {}, @@ -451,9 +459,9 @@ export class AwsS3V2 implements INodeType { responseData = await awsApiRequestRESTAllItems.call( this, 'ListBucketResult.Contents', - `${bucketName}.s3`, + servicePath, 'GET', - '', + basePath, '', qs, {}, @@ -561,10 +569,12 @@ export class AwsS3V2 implements INodeType { const destinationParts = destinationPath.split('/'); const bucketName = destinationParts[1]; + const servicePath = bucketName.includes('.') ? 's3' : `${bucketName}.s3`; + const basePath = bucketName.includes('.') ? `/${bucketName}` : ''; - const destination = `/${destinationParts.slice(2, destinationParts.length).join('/')}`; + const destination = `${basePath}/${destinationParts.slice(2, destinationParts.length).join('/')}`; - responseData = await awsApiRequestREST.call(this, `${bucketName}.s3`, 'GET', '', '', { + responseData = await awsApiRequestREST.call(this, servicePath, 'GET', basePath, '', { location: '', }); @@ -572,7 +582,7 @@ export class AwsS3V2 implements INodeType { responseData = await awsApiRequestREST.call( this, - `${bucketName}.s3`, + servicePath, 'PUT', destination, '', @@ -590,6 +600,8 @@ export class AwsS3V2 implements INodeType { //https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html if (operation === 'download') { const bucketName = this.getNodeParameter('bucketName', i) as string; + const servicePath = bucketName.includes('.') ? 's3' : `${bucketName}.s3`; + const basePath = bucketName.includes('.') ? `/${bucketName}` : ''; const fileKey = this.getNodeParameter('fileKey', i) as string; @@ -602,7 +614,7 @@ export class AwsS3V2 implements INodeType { ); } - let region = await awsApiRequestREST.call(this, `${bucketName}.s3`, 'GET', '', '', { + let region = await awsApiRequestREST.call(this, servicePath, 'GET', basePath, '', { location: '', }); @@ -610,9 +622,9 @@ export class AwsS3V2 implements INodeType { const response = await awsApiRequestREST.call( this, - `${bucketName}.s3`, + servicePath, 'GET', - `/${fileKey}`, + `${basePath}/${fileKey}`, '', qs, {}, @@ -652,6 +664,8 @@ export class AwsS3V2 implements INodeType { //https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObject.html if (operation === 'delete') { const bucketName = this.getNodeParameter('bucketName', i) as string; + const servicePath = bucketName.includes('.') ? 's3' : `${bucketName}.s3`; + const basePath = bucketName.includes('.') ? `/${bucketName}` : ''; const fileKey = this.getNodeParameter('fileKey', i) as string; @@ -661,7 +675,7 @@ export class AwsS3V2 implements INodeType { qs.versionId = options.versionId as string; } - responseData = await awsApiRequestREST.call(this, `${bucketName}.s3`, 'GET', '', '', { + responseData = await awsApiRequestREST.call(this, servicePath, 'GET', basePath, '', { location: '', }); @@ -669,9 +683,9 @@ export class AwsS3V2 implements INodeType { responseData = await awsApiRequestREST.call( this, - `${bucketName}.s3`, + servicePath, 'DELETE', - `/${fileKey}`, + `${basePath}/${fileKey}`, '', qs, {}, @@ -687,6 +701,8 @@ export class AwsS3V2 implements INodeType { //https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html if (operation === 'getAll') { const bucketName = this.getNodeParameter('bucketName', i) as string; + const servicePath = bucketName.includes('.') ? 's3' : `${bucketName}.s3`; + const basePath = bucketName.includes('.') ? `/${bucketName}` : ''; const returnAll = this.getNodeParameter('returnAll', 0); const options = this.getNodeParameter('options', 0); @@ -702,7 +718,7 @@ export class AwsS3V2 implements INodeType { qs['list-type'] = 2; - responseData = await awsApiRequestREST.call(this, `${bucketName}.s3`, 'GET', '', '', { + responseData = await awsApiRequestREST.call(this, servicePath, 'GET', basePath, '', { location: '', }); @@ -712,9 +728,9 @@ export class AwsS3V2 implements INodeType { responseData = await awsApiRequestRESTAllItems.call( this, 'ListBucketResult.Contents', - `${bucketName}.s3`, + servicePath, 'GET', - '', + basePath, '', qs, {}, @@ -726,9 +742,9 @@ export class AwsS3V2 implements INodeType { responseData = await awsApiRequestRESTAllItems.call( this, 'ListBucketResult.Contents', - `${bucketName}.s3`, + servicePath, 'GET', - '', + basePath, '', qs, {}, @@ -754,12 +770,14 @@ export class AwsS3V2 implements INodeType { //https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html if (operation === 'upload') { const bucketName = this.getNodeParameter('bucketName', i) as string; + const servicePath = bucketName.includes('.') ? 's3' : `${bucketName}.s3`; + const basePath = bucketName.includes('.') ? `/${bucketName}` : ''; const fileName = this.getNodeParameter('fileName', i) as string; const isBinaryData = this.getNodeParameter('binaryData', i); const additionalFields = this.getNodeParameter('additionalFields', i); const tagsValues = (this.getNodeParameter('tagsUi', i) as IDataObject) .tagsValues as IDataObject[]; - let path = ''; + let path = `${basePath}/`; let body; const multipartHeaders: IDataObject = {}; @@ -839,7 +857,7 @@ export class AwsS3V2 implements INodeType { multipartHeaders['x-amz-tagging'] = tags.join('&'); } // Get the region of the bucket - responseData = await awsApiRequestREST.call(this, `${bucketName}.s3`, 'GET', '', '', { + responseData = await awsApiRequestREST.call(this, servicePath, 'GET', basePath, '', { location: '', }); const region = responseData.LocationConstraint._; @@ -853,7 +871,7 @@ export class AwsS3V2 implements INodeType { uploadData = this.helpers.getBinaryStream(binaryPropertyData.id, UPLOAD_CHUNK_SIZE); const createMultiPartUpload = await awsApiRequestREST.call( this, - `${bucketName}.s3`, + servicePath, 'POST', `/${path}?uploads`, body, @@ -875,7 +893,7 @@ export class AwsS3V2 implements INodeType { try { await awsApiRequestREST.call( this, - `${bucketName}.s3`, + servicePath, 'PUT', `/${path}?partNumber=${part}&uploadId=${uploadId}`, chunk, @@ -889,7 +907,7 @@ export class AwsS3V2 implements INodeType { try { await awsApiRequestREST.call( this, - `${bucketName}.s3`, + servicePath, 'DELETE', `/${path}?uploadId=${uploadId}`, ); @@ -902,7 +920,7 @@ export class AwsS3V2 implements INodeType { const listParts = (await awsApiRequestREST.call( this, - `${bucketName}.s3`, + servicePath, 'GET', `/${path}?max-parts=${900}&part-number-marker=0&uploadId=${uploadId}`, '', @@ -954,7 +972,7 @@ export class AwsS3V2 implements INodeType { const data = builder.buildObject(body); const completeUpload = (await awsApiRequestREST.call( this, - `${bucketName}.s3`, + servicePath, 'POST', `/${path}?uploadId=${uploadId}`, data, @@ -991,7 +1009,7 @@ export class AwsS3V2 implements INodeType { responseData = await awsApiRequestREST.call( this, - `${bucketName}.s3`, + servicePath, 'PUT', `/${path || binaryPropertyData.fileName}`, body, @@ -1019,7 +1037,7 @@ export class AwsS3V2 implements INodeType { responseData = await awsApiRequestREST.call( this, - `${bucketName}.s3`, + servicePath, 'PUT', `/${path}`, body, From 052d82b2208c1b2e6f62c6004822c6278c15278b Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Mon, 24 Jul 2023 15:38:37 +0200 Subject: [PATCH 3/5] test(editor): Add canvas actions E2E tests (#6723) * test(editor): Add canvas actions E2E tests * test(editor): Open category items in node creator when category dropped on canvas * test(editor): Have new position counted only once in drag * test(editor): rename test --- cypress/e2e/12-canvas-actions.cy.ts | 36 +++++++++++++++++++ cypress/pages/workflow.ts | 3 ++ cypress/support/commands.ts | 30 +++++++++++----- cypress/support/index.ts | 2 +- .../Node/NodeCreator/ItemTypes/NodeItem.vue | 4 +++ 5 files changed, 65 insertions(+), 10 deletions(-) diff --git a/cypress/e2e/12-canvas-actions.cy.ts b/cypress/e2e/12-canvas-actions.cy.ts index d336294f48..3f23f90484 100644 --- a/cypress/e2e/12-canvas-actions.cy.ts +++ b/cypress/e2e/12-canvas-actions.cy.ts @@ -78,6 +78,42 @@ describe('Canvas Actions', () => { WorkflowPage.getters.nodeConnections().should('have.length', 1); }); + it('should add a connected node dragging from node creator', () => { + WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); + cy.get('.plus-endpoint').should('be.visible').click(); + WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible'); + WorkflowPage.getters.nodeCreatorSearchBar().type(CODE_NODE_NAME); + cy.drag( + WorkflowPage.getters.nodeCreatorNodeItems().first(), + [100, 100], + { + realMouse: true, + abs: true + } + ); + cy.get('body').type('{esc}'); + WorkflowPage.getters.canvasNodes().should('have.length', 2); + WorkflowPage.getters.nodeConnections().should('have.length', 1); + }); + + it('should open a category when trying to drag and drop it on the canvas', () => { + WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); + cy.get('.plus-endpoint').should('be.visible').click(); + WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible'); + WorkflowPage.getters.nodeCreatorSearchBar().type(CODE_NODE_NAME); + cy.drag( + WorkflowPage.getters.nodeCreatorActionItems().first(), + [100, 100], + { + realMouse: true, + abs: true + } + ); + WorkflowPage.getters.nodeCreatorCategoryItems().its('length').should('be.gt', 0); + WorkflowPage.getters.canvasNodes().should('have.length', 1); + WorkflowPage.getters.nodeConnections().should('have.length', 0); + }); + it('should add disconnected node if nothing is selected', () => { WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); // Deselect nodes diff --git a/cypress/pages/workflow.ts b/cypress/pages/workflow.ts index d324314dcc..e269ce09fd 100644 --- a/cypress/pages/workflow.ts +++ b/cypress/pages/workflow.ts @@ -96,6 +96,9 @@ export class WorkflowPage extends BasePage { nodeCredentialsSelect: () => cy.getByTestId('node-credentials-select'), nodeCredentialsEditButton: () => cy.getByTestId('credential-edit-button'), nodeCreatorItems: () => cy.getByTestId('item-iterator-item'), + nodeCreatorNodeItems: () => cy.getByTestId('node-creator-node-item'), + nodeCreatorActionItems: () => cy.getByTestId('node-creator-action-item'), + nodeCreatorCategoryItems: () => cy.getByTestId('node-creator-category-item'), ndvParameters: () => cy.getByTestId('parameter-item'), nodeCredentialsLabel: () => cy.getByTestId('credentials-label'), getConnectionBetweenNodes: (sourceNodeName: string, targetNodeName: string) => diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 6c7adaea8f..812404ff7a 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -103,19 +103,31 @@ Cypress.Commands.add('paste', { prevSubject: true }, (selector, pastePayload) => Cypress.Commands.add('drag', (selector, pos, options) => { const index = options?.index || 0; const [xDiff, yDiff] = pos; - const element = cy.get(selector).eq(index); + const element = typeof selector === 'string' ? cy.get(selector).eq(index) : selector; element.should('exist'); - const originalLocation = Cypress.$(selector)[index].getBoundingClientRect(); + element.then(([$el]) => { + const originalLocation = $el.getBoundingClientRect(); + const newPosition = { + x: options?.abs ? xDiff : originalLocation.x + xDiff, + y: options?.abs ? yDiff : originalLocation.y + yDiff, + } - element.trigger('mousedown', { force: true }); - element.trigger('mousemove', { - which: 1, - pageX: options?.abs ? xDiff : originalLocation.right + xDiff, - pageY: options?.abs ? yDiff : originalLocation.top + yDiff, - force: true, + if(options?.realMouse) { + element.realMouseDown(); + element.realMouseMove(newPosition.x, newPosition.y); + element.realMouseUp(); + } else { + element.trigger('mousedown', {force: true}); + element.trigger('mousemove', { + which: 1, + pageX: newPosition.x, + pageY: newPosition.y, + force: true, + }); + element.trigger('mouseup', {force: true}); + } }); - element.trigger('mouseup', { force: true }); }); Cypress.Commands.add('draganddrop', (draggableSelector, droppableSelector) => { diff --git a/cypress/support/index.ts b/cypress/support/index.ts index 196a14d9ec..f1602b3e06 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -31,7 +31,7 @@ declare global { grantBrowserPermissions(...permissions: string[]): void; readClipboard(): Chainable; paste(pastePayload: string): void; - drag(selector: string, target: [number, number], options?: {abs?: true, index?: number}): void; + drag(selector: string | Cypress.Chainable>, target: [number, number], options?: {abs?: boolean, index?: number, realMouse?: boolean}): void; draganddrop(draggableSelector: string, droppableSelector: string): void; } } diff --git a/packages/editor-ui/src/components/Node/NodeCreator/ItemTypes/NodeItem.vue b/packages/editor-ui/src/components/Node/NodeCreator/ItemTypes/NodeItem.vue index dd5a427f17..8554a1a080 100644 --- a/packages/editor-ui/src/components/Node/NodeCreator/ItemTypes/NodeItem.vue +++ b/packages/editor-ui/src/components/Node/NodeCreator/ItemTypes/NodeItem.vue @@ -9,6 +9,7 @@ :title="displayName" :show-action-arrow="showActionArrow" :is-trigger="isTrigger" + :data-test-id="dataTestId" >