From 92cf3cedbbe88f03ff5b79d749a4ffba3af565a4 Mon Sep 17 00:00:00 2001 From: Robert Squires Date: Wed, 4 Jun 2025 08:56:25 +0100 Subject: [PATCH] refactor(editor): Reka UI inline text edit component (#15752) --- cypress/composables/credentialsComposables.ts | 3 +- cypress/composables/folders.ts | 2 +- cypress/e2e/10-settings-log-streaming.cy.ts | 12 +- cypress/pages/modals/credentials-modal.ts | 6 +- cypress/pages/template-credential-setup.ts | 7 +- cypress/pages/workflow.ts | 1 - .../design-system/.storybook/storybook.scss | 6 + .../frontend/@n8n/design-system/package.json | 7 +- .../InlineTextEdit.stories.ts | 32 +++ .../N8nInlineTextEdit/InlineTextEdit.test.ts | 65 ++++++ .../N8nInlineTextEdit/InlineTextEdit.vue | 193 ++++++++++++++++++ .../__snapshots__/InlineTextEdit.test.ts.snap | 8 + .../src/components/N8nInlineTextEdit/index.ts | 3 + .../design-system/src/components/index.ts | 1 + .../CredentialEdit/CredentialEdit.vue | 41 ++-- .../ExpandableInput/ExpandableInputBase.vue | 65 ------ .../ExpandableInput/ExpandableInputEdit.vue | 88 -------- .../ExpandableInputPreview.vue | 37 ---- .../src/components/InlineTextEdit.vue | 129 ------------ .../MainHeader/WorkflowDetails.test.ts | 3 +- .../components/MainHeader/WorkflowDetails.vue | 115 +++++------ .../src/components/NodeTitle.test.ts | 48 ++--- .../editor-ui/src/components/NodeTitle.vue | 133 +++--------- .../EventDestinationSettingsModal.ee.vue | 30 ++- .../frontend/editor-ui/src/views/NodeView.vue | 7 + .../editor-ui/src/views/WorkflowsView.vue | 55 +++-- pnpm-lock.yaml | 142 +++++++++++++ 27 files changed, 638 insertions(+), 601 deletions(-) create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nInlineTextEdit/InlineTextEdit.stories.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nInlineTextEdit/InlineTextEdit.test.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nInlineTextEdit/InlineTextEdit.vue create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nInlineTextEdit/__snapshots__/InlineTextEdit.test.ts.snap create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nInlineTextEdit/index.ts delete mode 100644 packages/frontend/editor-ui/src/components/ExpandableInput/ExpandableInputBase.vue delete mode 100644 packages/frontend/editor-ui/src/components/ExpandableInput/ExpandableInputEdit.vue delete mode 100644 packages/frontend/editor-ui/src/components/ExpandableInput/ExpandableInputPreview.vue delete mode 100644 packages/frontend/editor-ui/src/components/InlineTextEdit.vue diff --git a/cypress/composables/credentialsComposables.ts b/cypress/composables/credentialsComposables.ts index 6f68e78343..52e2f714ff 100644 --- a/cypress/composables/credentialsComposables.ts +++ b/cypress/composables/credentialsComposables.ts @@ -68,8 +68,7 @@ export function getCredentialSaveButton() { */ export function setCredentialName(name: string) { - cy.getByTestId('credential-name').click(); - cy.getByTestId('credential-name').find('input').clear(); + cy.getByTestId('credential-name').find('span[data-test-id=inline-edit-preview]').click(); cy.getByTestId('credential-name').type(name); } export function saveCredential() { diff --git a/cypress/composables/folders.ts b/cypress/composables/folders.ts index ae53499d8b..04ec8ce6df 100644 --- a/cypress/composables/folders.ts +++ b/cypress/composables/folders.ts @@ -325,7 +325,7 @@ export function renameFolderFromListActions(folderName: string, newName: string) getListActionsToggle().click(); getListActionItem('rename').click(); getInlineEditInput().should('be.visible'); - getInlineEditInput().type(newName, { delay: 50 }); + getInlineEditInput().type(`${newName}{enter}`, { delay: 50 }); successToast().should('exist'); } diff --git a/cypress/e2e/10-settings-log-streaming.cy.ts b/cypress/e2e/10-settings-log-streaming.cy.ts index 1954543ca0..63f8d6823f 100644 --- a/cypress/e2e/10-settings-log-streaming.cy.ts +++ b/cypress/e2e/10-settings-log-streaming.cy.ts @@ -57,9 +57,9 @@ describe('Log Streaming Settings', () => { settingsLogStreamingPage.getters .getDestinationNameInput() - .find('input') - .clear() - .type('Destination 0'); + .find('span[data-test-id=inline-edit-preview]') + .click(); + cy.getByTestId('inline-edit-input').type('Destination 0'); settingsLogStreamingPage.getters.getDestinationSaveButton().click(); cy.wait(100); getVisibleModalOverlay().click(1, 1); @@ -84,9 +84,9 @@ describe('Log Streaming Settings', () => { settingsLogStreamingPage.getters.getDestinationNameInput().click(); settingsLogStreamingPage.getters .getDestinationNameInput() - .find('input') - .clear() - .type('Destination 1'); + .find('span[data-test-id=inline-edit-preview]') + .click(); + cy.getByTestId('inline-edit-input').type('Destination 1'); settingsLogStreamingPage.getters.getDestinationSaveButton().should('not.have.attr', 'disabled'); settingsLogStreamingPage.getters.getDestinationSaveButton().click(); cy.wait(100); diff --git a/cypress/pages/modals/credentials-modal.ts b/cypress/pages/modals/credentials-modal.ts index 592a396161..f296d5c3f5 100644 --- a/cypress/pages/modals/credentials-modal.ts +++ b/cypress/pages/modals/credentials-modal.ts @@ -21,6 +21,8 @@ export class CredentialsModal extends BasePage { connectionParameter: (fieldName: string) => this.getters.credentialInputs().find(`:contains('${fieldName}') .n8n-input input`), name: () => cy.getByTestId('credential-name'), + namePreview: () => + cy.getByTestId('credential-name').find('span[data-test-id=inline-edit-preview]'), nameInput: () => cy.getByTestId('credential-name').find('input'), deleteButton: () => cy.getByTestId('credential-delete-button'), closeButton: () => this.getters.editCredentialModal().find('.el-dialog__close').first(), @@ -43,7 +45,7 @@ export class CredentialsModal extends BasePage { getVisibleSelect().contains(email.toLowerCase()).click(); }, setName: (name: string) => { - this.getters.name().click(); + this.getters.name().getByTestId('inline-edit-preview').click(); this.getters.nameInput().clear().type(name); }, save: (test = false) => { @@ -93,7 +95,7 @@ export class CredentialsModal extends BasePage { this.actions.fillCredentialsForm(closeModal); }, renameCredential: (newName: string) => { - this.getters.nameInput().type('{selectall}'); + this.getters.namePreview().click(); this.getters.nameInput().type(newName); this.getters.nameInput().type('{enter}'); }, diff --git a/cypress/pages/template-credential-setup.ts b/cypress/pages/template-credential-setup.ts index 73b1b59911..70542ac75e 100644 --- a/cypress/pages/template-credential-setup.ts +++ b/cypress/pages/template-credential-setup.ts @@ -9,6 +9,10 @@ export const getters = { skipLink: () => cy.get('a:contains("Skip")'), title: (title: string) => cy.get(`h1:contains(${title})`), infoCallout: () => cy.getByTestId('info-callout'), + + namePreview: () => + cy.getByTestId('credential-name').find('span[data-test-id=inline-edit-preview]'), + nameInput: () => cy.getByTestId('credential-name').find('input'), }; export const visitTemplateCredentialSetupPage = (templateId: number) => { @@ -22,7 +26,8 @@ export const visitTemplateCredentialSetupPage = (templateId: number) => { */ export const fillInDummyCredentialsForApp = (appName: string) => { formStep.getCreateAppCredentialsButton(appName).click(); - credentialsModal.getters.editCredentialModal().find('input:first()').type('test'); + credentialsModal.getters.namePreview().click(); + credentialsModal.getters.nameInput().type('test'); credentialsModal.actions.save(false); credentialsModal.actions.close(); }; diff --git a/cypress/pages/workflow.ts b/cypress/pages/workflow.ts index af0a4dd2f2..91c3555ebf 100644 --- a/cypress/pages/workflow.ts +++ b/cypress/pages/workflow.ts @@ -319,7 +319,6 @@ export class WorkflowPage extends BasePage { cy.get('body').type('{del}'); }, setWorkflowName: (name: string) => { - this.getters.workflowNameInput().should('be.disabled'); this.getters.workflowNameInput().parent().click(); this.getters.workflowNameInput().should('be.enabled'); this.getters.workflowNameInput().clear().type(name).type('{enter}'); diff --git a/packages/frontend/@n8n/design-system/.storybook/storybook.scss b/packages/frontend/@n8n/design-system/.storybook/storybook.scss index 16aaf700f4..f5e9850528 100644 --- a/packages/frontend/@n8n/design-system/.storybook/storybook.scss +++ b/packages/frontend/@n8n/design-system/.storybook/storybook.scss @@ -19,3 +19,9 @@ body { padding: 0 !important; } + +.story { + padding: 2rem; + display: flex; + gap: 1rem; +} diff --git a/packages/frontend/@n8n/design-system/package.json b/packages/frontend/@n8n/design-system/package.json index 38a0789570..6140cf7ae8 100644 --- a/packages/frontend/@n8n/design-system/package.json +++ b/packages/frontend/@n8n/design-system/package.json @@ -20,9 +20,9 @@ }, "devDependencies": { "@n8n/eslint-config": "workspace:*", + "@n8n/storybook": "workspace:*", "@n8n/typescript-config": "workspace:*", "@n8n/vitest-config": "workspace:*", - "@n8n/storybook": "workspace:*", "@testing-library/jest-dom": "catalog:frontend", "@testing-library/user-event": "catalog:frontend", "@testing-library/vue": "catalog:frontend", @@ -45,11 +45,11 @@ "vue-tsc": "catalog:frontend" }, "dependencies": { - "@n8n/composables": "workspace:*", - "@n8n/utils": "workspace:*", "@fortawesome/fontawesome-svg-core": "^1.2.36", "@fortawesome/free-solid-svg-icons": "^5.15.4", "@fortawesome/vue-fontawesome": "^3.0.3", + "@n8n/composables": "workspace:*", + "@n8n/utils": "workspace:*", "@tanstack/vue-table": "^8.21.2", "element-plus": "catalog:frontend", "is-emoji-supported": "^0.0.5", @@ -59,6 +59,7 @@ "markdown-it-link-attributes": "^4.0.1", "markdown-it-task-lists": "^2.1.1", "parse-diff": "^0.11.1", + "reka-ui": "^2.2.1", "sanitize-html": "2.12.1", "vue": "catalog:frontend", "vue-boring-avatars": "^1.3.0", diff --git a/packages/frontend/@n8n/design-system/src/components/N8nInlineTextEdit/InlineTextEdit.stories.ts b/packages/frontend/@n8n/design-system/src/components/N8nInlineTextEdit/InlineTextEdit.stories.ts new file mode 100644 index 0000000000..4a4903476d --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nInlineTextEdit/InlineTextEdit.stories.ts @@ -0,0 +1,32 @@ +import type { StoryFn } from '@storybook/vue3'; + +import InlineTextEdit from './InlineTextEdit.vue'; + +export default { + title: 'Atoms/InlineTextEdit', + component: InlineTextEdit, +}; + +const Template: StoryFn = (args, { argTypes }) => ({ + setup: () => ({ args }), + props: Object.keys(argTypes), + components: { + InlineTextEdit, + }, + template: ` +
+ +
+ `, +}); + +export const primary = Template.bind({}); +primary.args = { + modelValue: 'Test', +}; + +export const placeholder = Template.bind({}); +placeholder.args = { + modelValue: '', + placeholder: 'Enter workflow name', +}; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nInlineTextEdit/InlineTextEdit.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nInlineTextEdit/InlineTextEdit.test.ts new file mode 100644 index 0000000000..94aef2b650 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nInlineTextEdit/InlineTextEdit.test.ts @@ -0,0 +1,65 @@ +import userEvent from '@testing-library/user-event'; + +import { createComponentRenderer } from '@n8n/design-system/__tests__/render'; + +import N8nInlineTextEdit from './InlineTextEdit.vue'; + +const renderComponent = createComponentRenderer(N8nInlineTextEdit); + +describe('N8nInlineTextEdit', () => { + it('should render correctly', () => { + const wrapper = renderComponent({ + props: { + modelValue: 'Test Value', + }, + }); + + expect(wrapper.html()).toMatchSnapshot(); + }); + + it('value should update on enter', async () => { + const wrapper = renderComponent({ + props: { + modelValue: 'Test Value', + }, + }); + const input = wrapper.getByTestId('inline-edit-input'); + const preview = wrapper.getByTestId('inline-edit-preview'); + + await wrapper.rerender({ modelValue: 'New Value' }); + await userEvent.type(input, 'Updated Value'); + await userEvent.keyboard('{Enter}'); + + expect(preview).toHaveTextContent('Updated Value'); + }); + + it('should not update value on blur if input is empty', async () => { + const wrapper = renderComponent({ + props: { + modelValue: 'Test Value', + }, + }); + const input = wrapper.getByTestId('inline-edit-input'); + const preview = wrapper.getByTestId('inline-edit-preview'); + + await userEvent.clear(input); + await userEvent.tab(); // Simulate blur + + expect(preview).toHaveTextContent('Test Value'); + }); + + it('should not update on escape key press', async () => { + const wrapper = renderComponent({ + props: { + modelValue: 'Test Value', + }, + }); + const input = wrapper.getByTestId('inline-edit-input'); + const preview = wrapper.getByTestId('inline-edit-preview'); + + await userEvent.type(input, 'Updated Value'); + await userEvent.keyboard('{Escape}'); + + expect(preview).toHaveTextContent('Test Value'); + }); +}); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nInlineTextEdit/InlineTextEdit.vue b/packages/frontend/@n8n/design-system/src/components/N8nInlineTextEdit/InlineTextEdit.vue new file mode 100644 index 0000000000..7f97e91e55 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nInlineTextEdit/InlineTextEdit.vue @@ -0,0 +1,193 @@ + + + + + diff --git a/packages/frontend/@n8n/design-system/src/components/N8nInlineTextEdit/__snapshots__/InlineTextEdit.test.ts.snap b/packages/frontend/@n8n/design-system/src/components/N8nInlineTextEdit/__snapshots__/InlineTextEdit.test.ts.snap new file mode 100644 index 0000000000..29d75692d7 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nInlineTextEdit/__snapshots__/InlineTextEdit.test.ts.snap @@ -0,0 +1,8 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`N8nInlineTextEdit > should render correctly 1`] = ` +"
+
Test ValueTest Value
+ +
" +`; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nInlineTextEdit/index.ts b/packages/frontend/@n8n/design-system/src/components/N8nInlineTextEdit/index.ts new file mode 100644 index 0000000000..3d877b4585 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nInlineTextEdit/index.ts @@ -0,0 +1,3 @@ +import InlineRename from './InlineTextEdit.vue'; + +export default InlineRename; diff --git a/packages/frontend/@n8n/design-system/src/components/index.ts b/packages/frontend/@n8n/design-system/src/components/index.ts index 796c657db0..8920758048 100644 --- a/packages/frontend/@n8n/design-system/src/components/index.ts +++ b/packages/frontend/@n8n/design-system/src/components/index.ts @@ -60,3 +60,4 @@ export { default as N8nIconPicker } from './N8nIconPicker'; export { default as N8nBreadcrumbs } from './N8nBreadcrumbs'; export { default as N8nTableBase } from './TableBase'; export { default as N8nDataTableServer } from './N8nDataTableServer'; +export { default as N8nInlineTextEdit } from './N8nInlineTextEdit'; diff --git a/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialEdit.vue b/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialEdit.vue index 9e315a07a3..012242bc61 100644 --- a/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialEdit.vue +++ b/packages/frontend/editor-ui/src/components/CredentialEdit/CredentialEdit.vue @@ -1,5 +1,5 @@