refactor(editor): Reka UI inline text edit component (#15752)

This commit is contained in:
Robert Squires
2025-06-04 08:56:25 +01:00
committed by GitHub
parent 1335af05d5
commit 92cf3cedbb
27 changed files with 638 additions and 601 deletions

View File

@@ -19,3 +19,9 @@
body {
padding: 0 !important;
}
.story {
padding: 2rem;
display: flex;
gap: 1rem;
}

View File

@@ -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",

View File

@@ -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: `
<div class="story">
<N8nInlineTextEdit v-bind="args" />
</div>
`,
});
export const primary = Template.bind({});
primary.args = {
modelValue: 'Test',
};
export const placeholder = Template.bind({});
placeholder.args = {
modelValue: '',
placeholder: 'Enter workflow name',
};

View File

@@ -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');
});
});

View File

@@ -0,0 +1,193 @@
<script lang="ts" setup>
import { useElementSize } from '@vueuse/core';
import { EditableArea, EditableInput, EditablePreview, EditableRoot } from 'reka-ui';
import { computed, ref, useTemplateRef } from 'vue';
type Props = {
modelValue: string;
readOnly?: boolean;
maxLength?: number;
maxWidth?: number;
minWidth?: number;
placeholder?: string;
disabled?: boolean;
};
const props = withDefaults(defineProps<Props>(), {
readOnly: false,
maxLength: 100,
maxWidth: 200,
minWidth: 64,
placeholder: 'Enter text...',
});
const emit = defineEmits<{
'update:model-value': [value: string];
}>();
const editableRoot = useTemplateRef('editableRoot');
function forceFocus() {
if (editableRoot.value && !props.readOnly) {
editableRoot.value.edit();
}
}
function forceCancel() {
if (editableRoot.value) {
newValue.value = props.modelValue;
editableRoot.value.cancel();
}
}
defineExpose({ forceFocus, forceCancel });
function onSubmit() {
if (newValue.value === '') {
newValue.value = props.modelValue;
temp.value = props.modelValue;
return;
}
emit('update:model-value', newValue.value);
}
function onInput(value: string) {
newValue.value = value;
}
function onStateChange(state: string) {
if (state === 'cancel') {
temp.value = newValue.value;
}
}
// Resize logic
const newValue = ref(props.modelValue);
const temp = ref(props.modelValue || props.placeholder);
const measureSpan = useTemplateRef('measureSpan');
const { width: measuredWidth } = useElementSize(measureSpan);
const inputWidth = computed(() => {
return Math.max(props.minWidth, Math.min(measuredWidth.value + 1, props.maxWidth));
});
function onChange(event: Event) {
const { value } = event.target as HTMLInputElement;
const processedValue = value.replace(/\s/g, '.');
temp.value = processedValue.trim() !== '' ? processedValue : props.placeholder;
}
const computedInlineStyles = computed(() => {
return {
width: `${inputWidth.value}px`,
maxWidth: `${props.maxWidth}px`,
zIndex: 1,
};
});
</script>
<template>
<EditableRoot
ref="editableRoot"
:placeholder="placeholder"
:model-value="newValue"
submit-mode="both"
:class="$style.inlineRenameRoot"
:title="modelValue"
:disabled="disabled"
:max-length="maxLength"
:readonly="readOnly"
select-on-focus
auto-resize
@submit="onSubmit"
@update:model-value="onInput"
@update:state="onStateChange"
>
<EditableArea
:style="computedInlineStyles"
:class="$style.inlineRenameArea"
@click="forceFocus"
>
<span ref="measureSpan" :class="$style.measureSpan">
{{ temp }}
</span>
<EditablePreview
data-test-id="inline-edit-preview"
:class="$style.inlineRenamePreview"
:style="computedInlineStyles"
/>
<EditableInput
ref="input"
:class="$style.inlineRenameInput"
data-test-id="inline-edit-input"
:style="computedInlineStyles"
@input="onChange"
/>
</EditableArea>
</EditableRoot>
</template>
<style lang="scss" module>
.inlineRenameArea {
cursor: pointer;
width: fit-content;
position: relative;
&::after {
content: '';
position: absolute;
top: calc(var(--spacing-4xs) * -1);
left: calc(var(--spacing-3xs) * -1);
width: calc(100% + var(--spacing-xs));
height: calc(100% + var(--spacing-2xs));
border-radius: var(--border-radius-base);
background-color: var(--color-foreground-xlight);
opacity: 0;
z-index: 0;
transition: all 0.1s ease-in-out;
}
&[data-focused],
&:hover {
&::after {
border: 1px solid var(--color-foreground-base);
opacity: 1;
}
}
&[data-focused] {
cursor: text;
&::after {
border: 1px solid var(--color-secondary);
}
}
}
.inlineRenameArea[data-readonly] {
pointer-events: none;
&::after {
content: none;
}
}
.inlineRenamePreview {
width: fit-content;
transform: translateY(1.5px);
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
position: relative;
}
.measureSpan {
position: absolute;
top: 0;
visibility: hidden;
white-space: nowrap;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
letter-spacing: inherit;
}
</style>

View File

@@ -0,0 +1,8 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`N8nInlineTextEdit > should render correctly 1`] = `
"<div class="inlineRenameRoot" title="Test Value" dir="ltr" data-dismissable-layer="">
<div data-placeholder-shown="" style="display: inline-grid; width: 64px; max-width: 200px; z-index: 1;" class="inlineRenameArea"><span class="measureSpan">Test Value</span><span tabindex="0" data-placeholder-shown="" style="white-space: pre; user-select: none; grid-area: 1 / 1 / auto / auto; overflow: hidden; text-overflow: ellipsis; width: 64px; max-width: 200px; z-index: 1;" data-test-id="inline-edit-preview" class="inlineRenamePreview">Test Value</span><input placeholder="Enter text..." maxlength="100" aria-label="editable input" style="all: unset; grid-area: 1 / 1 / auto / auto; visibility: hidden; width: 64px; max-width: 200px; z-index: 1;" class="inlineRenameInput" data-test-id="inline-edit-input" value="Test Value"></div>
<!---->
</div>"
`;

View File

@@ -0,0 +1,3 @@
import InlineRename from './InlineTextEdit.vue';
export default InlineRename;

View File

@@ -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';

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { computed, onMounted, ref, useTemplateRef } from 'vue';
import type {
ICredentialsDecryptedResponse,
@@ -22,7 +22,6 @@ import { NodeHelpers } from 'n8n-workflow';
import CredentialConfig from '@/components/CredentialEdit/CredentialConfig.vue';
import CredentialInfo from '@/components/CredentialEdit/CredentialInfo.vue';
import CredentialSharing from '@/components/CredentialEdit/CredentialSharing.ee.vue';
import InlineNameEdit from '@/components/InlineNameEdit.vue';
import Modal from '@/components/Modal.vue';
import SaveButton from '@/components/SaveButton.vue';
import { useMessage } from '@/composables/useMessage';
@@ -37,7 +36,7 @@ import { useSettingsStore } from '@/stores/settings.store';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import type { Project, ProjectSharingData } from '@/types/projects.types';
import type { IMenuItem } from '@n8n/design-system';
import { N8nInlineTextEdit, N8nText, type IMenuItem } from '@n8n/design-system';
import { assert } from '@n8n/utils/assert';
import { createEventBus } from '@n8n/utils/event-bus';
@@ -52,6 +51,7 @@ import {
updateNodeAuthType,
} from '@/utils/nodeTypesUtils';
import { isCredentialModalState, isValidCredentialResponse } from '@/utils/typeGuards';
import { useElementSize } from '@vueuse/core';
type Props = {
modalName: string;
@@ -1065,6 +1065,9 @@ function resetCredentialData(): void {
homeProject,
};
}
const credNameRef = useTemplateRef('credNameRef');
const { width } = useElementSize(credNameRef);
</script>
<template>
@@ -1083,16 +1086,21 @@ function resetCredentialData(): void {
<div :class="$style.credIcon">
<CredentialIcon :credential-type-name="defaultCredentialTypeName" />
</div>
<InlineNameEdit
:model-value="credentialName"
:subtitle="credentialType ? credentialType.displayName : ''"
:readonly="
!credentialPermissions.update || !credentialType || isEditingManagedCredential
"
type="Credential"
data-test-id="credential-name"
@update:model-value="onNameEdit"
/>
<div ref="credNameRef" :class="$style.credName">
<N8nInlineTextEdit
v-if="credentialName"
data-test-id="credential-name"
:model-value="credentialName"
:max-width="width - 10"
:readonly="
!credentialPermissions.update || !credentialType || isEditingManagedCredential
"
@update:model-value="onNameEdit"
/>
<N8nText v-if="credentialType" size="small" tag="p" color="text-light">{{
credentialType.displayName
}}</N8nText>
</div>
</div>
<div :class="$style.credActions">
<n8n-icon-button
@@ -1202,6 +1210,13 @@ function resetCredentialData(): void {
padding-bottom: 100px;
}
.credName {
display: flex;
width: 100%;
flex-direction: column;
gap: var(--spacing-4xs);
}
.sidebar {
max-width: 170px;
min-width: 170px;

View File

@@ -1,65 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
type Props = {
modelValue: string;
placeholder?: string;
staticSize?: boolean;
};
const props = withDefaults(defineProps<Props>(), { staticSize: false, placeholder: '' });
const hiddenValue = computed(() => {
let value = props.modelValue.replace(/\s/g, '.'); // force input to expand on space chars
if (!value) {
value = props.placeholder;
}
return `${value}`; // adjust for padding
});
</script>
<template>
<!-- mock el-input element to apply styles -->
<div :class="{ 'el-input': true, 'static-size': staticSize }" :data-value="hiddenValue">
<slot></slot>
</div>
</template>
<style lang="scss" scoped>
.el-input {
display: inline-grid;
font: inherit;
:deep(input) {
border: 1px solid transparent;
padding: var(--spacing-3xs) calc(var(--spacing-3xs) - 2px); // -2px for borders
width: 100%;
grid-area: 1 / 2;
font: inherit;
}
&::after {
grid-area: 1 / 2;
font: inherit;
content: attr(data-value) ' ';
visibility: hidden;
white-space: nowrap;
padding: var(--spacing-3xs);
}
&:not(.static-size)::after {
overflow: hidden;
}
&:hover {
:deep(input):not(:focus) {
border: 1px solid var(--color-text-lighter);
}
}
:deep(input):focus {
border: 1px solid var(--color-secondary);
}
}
</style>

View File

@@ -1,88 +0,0 @@
<script setup lang="ts">
import type { EventBus } from '@n8n/utils/event-bus';
import { onBeforeUnmount, onMounted, ref } from 'vue';
import ExpandableInputBase from './ExpandableInputBase.vue';
import { onClickOutside } from '@vueuse/core';
type Props = {
modelValue: string;
placeholder: string;
maxlength?: number;
autofocus?: boolean;
eventBus?: EventBus;
};
const props = defineProps<Props>();
const emit = defineEmits<{
'update:model-value': [value: string];
enter: [value: string];
blur: [value: string];
esc: [];
}>();
const inputRef = ref<HTMLInputElement>();
onMounted(() => {
// autofocus on input element is not reliable
if (props.autofocus && inputRef.value) {
focus();
}
props.eventBus?.on('focus', focus);
});
onBeforeUnmount(() => {
props.eventBus?.off('focus', focus);
});
function focus() {
if (inputRef.value) {
inputRef.value.focus();
inputRef.value.select();
}
}
function onInput() {
if (inputRef.value) {
emit('update:model-value', inputRef.value.value);
}
}
function onEnter() {
if (inputRef.value) {
emit('enter', inputRef.value.value);
}
}
onClickOutside(inputRef, () => {
if (inputRef.value) {
emit('blur', inputRef.value.value);
}
});
function onEscape() {
emit('esc');
}
</script>
<template>
<ExpandableInputBase :model-value="modelValue" :placeholder="placeholder">
<input
ref="inputRef"
:class="['el-input__inner', $style.input]"
:value="modelValue"
:placeholder="placeholder"
:maxlength="maxlength"
size="4"
@input="onInput"
@keydown.enter="onEnter"
@keydown.esc="onEscape"
/>
</ExpandableInputBase>
</template>
<style module lang="scss">
.input {
padding: var(--spacing-4xs);
font-size: var(--font-size-s);
}
</style>

View File

@@ -1,37 +0,0 @@
<script setup lang="ts">
import ExpandableInputBase from './ExpandableInputBase.vue';
type Props = {
modelValue: string;
};
defineProps<Props>();
</script>
<template>
<ExpandableInputBase :model-value="modelValue" :static-size="true">
<input class="clickable preview" :value="modelValue" :disabled="true" size="4" />
</ExpandableInputBase>
</template>
<style lang="scss" scoped>
input.preview {
padding: var(--spacing-4xs);
border-radius: var(--border-radius-base);
}
.preview,
.preview:hover {
background-color: unset;
transition: unset;
pointer-events: none; // fix firefox bug
}
input[disabled] {
color: $custom-font-black;
// override safari colors
-webkit-text-fill-color: $custom-font-black;
-webkit-opacity: 1;
}
</style>

View File

@@ -1,129 +0,0 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import ExpandableInputEdit from '@/components/ExpandableInput/ExpandableInputEdit.vue';
import ExpandableInputPreview from '@/components/ExpandableInput/ExpandableInputPreview.vue';
import { createEventBus } from '@n8n/utils/event-bus';
const props = withDefaults(
defineProps<{
isEditEnabled: boolean;
modelValue: string;
placeholder: string;
maxLength: number;
previewValue: string;
disabled: boolean;
}>(),
{
isEditEnabled: false,
modelValue: '',
placeholder: '',
maxLength: 0,
previewValue: '',
disabled: false,
},
);
const emit = defineEmits<{
toggle: [];
submit: [payload: { name: string; onSubmit: (updated: boolean) => void }];
}>();
const isDisabled = ref(props.disabled);
const newValue = ref('');
const escPressed = ref(false);
const inputBus = ref(createEventBus());
watch(
() => props.disabled,
(value) => {
isDisabled.value = value;
},
);
watch(
() => props.modelValue,
(value) => {
if (isDisabled.value) return;
newValue.value = value;
},
{ immediate: true },
);
function onInput(val: string) {
if (isDisabled.value) return;
newValue.value = val;
}
function onClick() {
if (isDisabled.value) return;
newValue.value = props.modelValue;
emit('toggle');
}
function onBlur() {
if (isDisabled.value) return;
if (!escPressed.value) {
submit();
}
escPressed.value = false;
}
function submit() {
if (isDisabled.value) return;
const onSubmit = (updated: boolean) => {
isDisabled.value = false;
if (!updated) {
inputBus.value.emit('focus');
}
};
isDisabled.value = true;
emit('submit', { name: newValue.value, onSubmit });
}
function onEscape() {
if (isDisabled.value) return;
escPressed.value = true;
emit('toggle');
}
</script>
<template>
<span :class="$style['inline-edit']" @keydown.stop>
<span v-if="isEditEnabled && !isDisabled">
<ExpandableInputEdit
v-model="newValue"
:placeholder="placeholder"
:maxlength="maxLength"
:autofocus="true"
:event-bus="inputBus"
data-test-id="inline-edit-input"
@update:model-value="onInput"
@esc="onEscape"
@blur="onBlur"
@enter="submit"
/>
</span>
<span v-else :class="$style.preview" @click="onClick">
<ExpandableInputPreview :model-value="previewValue || modelValue" />
</span>
</span>
</template>
<style lang="scss" module>
/* Magic numbers here but this keeps preview and this input vertically aligned */
.inline-edit {
display: block;
height: 25px;
input {
display: block;
height: 27px;
min-height: 27px;
}
}
.preview {
cursor: pointer;
}
</style>

View File

@@ -135,8 +135,7 @@ describe('WorkflowDetails', () => {
},
});
const workflowName = getByTestId('workflow-name-input');
const workflowNameInput = workflowName.querySelector('input');
const workflowNameInput = getByTestId('inline-edit-input');
expect(workflowNameInput).toHaveValue('Test Workflow');
expect(getByText('tag1')).toBeInTheDocument();

View File

@@ -1,14 +1,4 @@
<script lang="ts" setup>
import BreakpointsObserver from '@/components/BreakpointsObserver.vue';
import InlineTextEdit from '@/components/InlineTextEdit.vue';
import CollaborationPane from '@/components/MainHeader/CollaborationPane.vue';
import WorkflowHistoryButton from '@/components/MainHeader/WorkflowHistoryButton.vue';
import PushConnectionTracker from '@/components/PushConnectionTracker.vue';
import SaveButton from '@/components/SaveButton.vue';
import ShortenName from '@/components/ShortenName.vue';
import WorkflowActivator from '@/components/WorkflowActivator.vue';
import WorkflowTagsContainer from '@/components/WorkflowTagsContainer.vue';
import WorkflowTagsDropdown from '@/components/WorkflowTagsDropdown.vue';
import {
DUPLICATE_MODAL_KEY,
EnterpriseEditionFeature,
@@ -23,6 +13,14 @@ import {
WORKFLOW_SETTINGS_MODAL_KEY,
WORKFLOW_SHARE_MODAL_KEY,
} from '@/constants';
import WorkflowTagsContainer from '@/components/WorkflowTagsContainer.vue';
import PushConnectionTracker from '@/components/PushConnectionTracker.vue';
import WorkflowActivator from '@/components/WorkflowActivator.vue';
import SaveButton from '@/components/SaveButton.vue';
import WorkflowTagsDropdown from '@/components/WorkflowTagsDropdown.vue';
import BreakpointsObserver from '@/components/BreakpointsObserver.vue';
import WorkflowHistoryButton from '@/components/MainHeader/WorkflowHistoryButton.vue';
import CollaborationPane from '@/components/MainHeader/CollaborationPane.vue';
import { ResourceType } from '@/utils/projects.utils';
import { useProjectsStore } from '@/stores/projects.store';
@@ -34,6 +32,18 @@ import { useUsersStore } from '@/stores/users.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useRootStore } from '@n8n/stores/useRootStore';
import { saveAs } from 'file-saver';
import { useDocumentTitle } from '@/composables/useDocumentTitle';
import { useMessage } from '@/composables/useMessage';
import { useToast } from '@/composables/useToast';
import { getResourcePermissions } from '@/permissions';
import { createEventBus } from '@n8n/utils/event-bus';
import { nodeViewEventBus } from '@/event-bus';
import { hasPermission } from '@/utils/rbac/permissions';
import { useCanvasStore } from '@/stores/canvas.store';
import { useRoute, useRouter } from 'vue-router';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { computed, ref, useCssModule, useTemplateRef, watch } from 'vue';
import type {
ActionDropdownItem,
FolderShortInfo,
@@ -41,26 +51,14 @@ import type {
IWorkflowDb,
IWorkflowToShare,
} from '@/Interface';
import { useDocumentTitle } from '@/composables/useDocumentTitle';
import { useMessage } from '@/composables/useMessage';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
import { useTelemetry } from '@/composables/useTelemetry';
import { useToast } from '@/composables/useToast';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { nodeViewEventBus } from '@/event-bus';
import { getResourcePermissions } from '@/permissions';
import { useCanvasStore } from '@/stores/canvas.store';
import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue';
import { N8nInlineTextEdit } from '@n8n/design-system';
import { useFoldersStore } from '@/stores/folders.store';
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
import { type BaseTextKey, useI18n } from '@n8n/i18n';
import { ProjectTypes } from '@/types/projects.types';
import { hasPermission } from '@/utils/rbac/permissions';
import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue';
import type { BaseTextKey } from '@n8n/i18n';
import { useI18n } from '@n8n/i18n';
import { createEventBus } from '@n8n/utils/event-bus';
import { saveAs } from 'file-saver';
import { computed, ref, useCssModule, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const props = defineProps<{
readOnly?: boolean;
@@ -101,7 +99,6 @@ const workflowHelpers = useWorkflowHelpers({ router });
const pageRedirectionHelper = usePageRedirectionHelper();
const isTagsEditEnabled = ref(false);
const isNameEditEnabled = ref(false);
const appliedTagIds = ref<string[]>([]);
const tagsSaving = ref(false);
const importFileRef = ref<HTMLInputElement | undefined>();
@@ -267,7 +264,7 @@ watch(
() => props.id,
() => {
isTagsEditEnabled.value = false;
isNameEditEnabled.value = false;
renameInput.value?.forceCancel();
},
);
@@ -332,7 +329,7 @@ function onTagsEditEnable() {
setTimeout(() => {
// allow name update to occur before disabling name edit
isNameEditEnabled.value = false;
renameInput.value?.forceCancel();
tagsEventBus.emit('focus');
}, 0);
}
@@ -366,24 +363,14 @@ function onTagsEditEsc() {
isTagsEditEnabled.value = false;
}
const renameInput = useTemplateRef('renameInput');
function onNameToggle() {
isNameEditEnabled.value = !isNameEditEnabled.value;
if (isNameEditEnabled.value) {
if (isTagsEditEnabled.value) {
void onTagsBlur();
}
isTagsEditEnabled.value = false;
if (renameInput.value?.forceFocus) {
renameInput.value.forceFocus();
}
}
async function onNameSubmit({
name,
onSubmit,
}: {
name: string;
onSubmit: (saved: boolean) => void;
}) {
async function onNameSubmit(name: string) {
const newName = name.trim();
if (!newName) {
toast.showMessage({
@@ -392,14 +379,12 @@ async function onNameSubmit({
type: 'error',
});
onSubmit(false);
renameInput.value?.forceCancel();
return;
}
if (newName === props.name) {
isNameEditEnabled.value = false;
onSubmit(true);
renameInput.value?.forceCancel();
return;
}
@@ -407,12 +392,11 @@ async function onNameSubmit({
const id = getWorkflowId();
const saved = await workflowHelpers.saveCurrentWorkflow({ name });
if (saved) {
isNameEditEnabled.value = false;
showCreateWorkflowSuccessToast(id);
workflowHelpers.setDocumentTitle(newName, 'IDLE');
}
uiStore.removeActiveAction('workflowSaving');
onSubmit(saved);
renameInput.value?.forceCancel();
}
async function handleFileImport(): Promise<void> {
@@ -704,7 +688,7 @@ const onBreadcrumbsItemSelected = (item: PathItem) => {
class="name-container"
data-test-id="canvas-breadcrumbs"
>
<template #default="{ value }">
<template #default>
<FolderBreadcrumbs
:current-folder="currentFolderForBreadcrumbs"
:current-folder-as-link="true"
@@ -716,28 +700,22 @@ const onBreadcrumbsItemSelected = (item: PathItem) => {
:class="$style['path-separator']"
>/</span
>
<ShortenName :name="name" :limit="value" :custom="true" test-id="workflow-name-input">
<template #default="{ shortenedName }">
<InlineTextEdit
:model-value="name"
:preview-value="shortenedName"
:is-edit-enabled="isNameEditEnabled"
:max-length="MAX_WORKFLOW_NAME_LENGTH"
:disabled="
readOnly || isArchived || (!isNewWorkflow && !workflowPermissions.update)
"
placeholder="Enter workflow name"
class="name"
@toggle="onNameToggle"
@submit="onNameSubmit"
/>
</template>
</ShortenName>
<N8nInlineTextEdit
ref="renameInput"
:key="id"
placeholder="Workflow name"
data-test-id="workflow-name-input"
class="name"
:model-value="name"
:max-length="MAX_WORKFLOW_NAME_LENGTH"
:read-only="readOnly || isArchived || (!isNewWorkflow && !workflowPermissions.update)"
:disabled="readOnly || isArchived || (!isNewWorkflow && !workflowPermissions.update)"
@update:model-value="onNameSubmit"
/>
</template>
</FolderBreadcrumbs>
</template>
</BreakpointsObserver>
<span class="tags" data-test-id="workflow-tags-container">
<template v-if="settingsStore.areTagsEnabled">
<WorkflowTagsDropdown
@@ -894,6 +872,7 @@ $--header-spacing: 20px;
.name {
color: $custom-font-dark;
font-size: var(--font-size-s);
padding: var(--spacing-3xs) var(--spacing-4xs) var(--spacing-4xs);
}
.activator {
@@ -971,7 +950,7 @@ $--header-spacing: 20px;
.path-separator {
font-size: var(--font-size-xl);
color: var(--color-foreground-base);
margin: var(--spacing-4xs);
padding: var(--spacing-3xs) var(--spacing-4xs) var(--spacing-4xs);
}
.group {

View File

@@ -1,7 +1,6 @@
import { describe, it, expect, vi } from 'vitest';
import { createComponentRenderer } from '@/__tests__/render';
import userEvent from '@testing-library/user-event';
import { fireEvent } from '@testing-library/vue';
import NodeTitle from '@/components/NodeTitle.vue';
import { createTestingPinia } from '@pinia/testing';
@@ -24,16 +23,17 @@ describe('NodeTitle', () => {
},
});
expect(getByTestId('node-title-container')).toBeInTheDocument();
expect(getByTestId('node-rename-input')).toBeInTheDocument();
expect(getByTestId('inline-edit-input')).toBeInTheDocument();
});
it('displays the node title', () => {
const { getByText } = renderComponent({
const { getByTestId } = renderComponent({
props: {
modelValue: 'My Test Node',
modelValue: 'Test Node',
},
});
expect(getByText('My Test Node')).toBeInTheDocument();
const renamePreview = getByTestId('inline-edit-preview');
expect(renamePreview).toHaveTextContent('Test Node');
});
it('shows the edit input when clicked', async () => {
@@ -43,53 +43,37 @@ describe('NodeTitle', () => {
},
});
await userEvent.click(getByTestId('node-title-container'));
expect(getByTestId('node-rename-input')).toHaveValue('Test Node');
expect(getByTestId('inline-edit-input')).toHaveValue('Test Node');
});
it('emits update:model-value when renaming', async () => {
const { getByTestId, getByRole, emitted } = renderComponent({
const { getByTestId, emitted } = renderComponent({
props: {
modelValue: 'Test Node',
},
});
await userEvent.click(getByTestId('node-title-container'));
const renameInput = getByTestId('node-rename-input');
const renameInput = getByTestId('inline-edit-input');
await userEvent.clear(renameInput);
await userEvent.type(renameInput, 'New Node Name');
expect(renameInput).toHaveValue('New Node Name');
await userEvent.click(getByRole('button', { name: 'Rename' }));
await userEvent.keyboard('{enter}');
expect(emitted('update:model-value')).toEqual([['New Node Name']]);
});
it('cancels renaming when cancel button is clicked', async () => {
const { getByTestId, getByRole, emitted } = renderComponent({
it('should not update if user cancels', async () => {
const { getByTestId, emitted } = renderComponent({
props: {
modelValue: 'Test Node',
},
});
await userEvent.click(getByTestId('node-title-container'));
await userEvent.click(getByRole('button', { name: 'Cancel' }));
expect(emitted('update:model-value')).toBeUndefined();
});
it('does not call onRename when Enter is pressed on cancel button', async () => {
const { getByTestId, getByRole, emitted } = renderComponent({
props: {
modelValue: 'Test Node',
},
});
await userEvent.click(getByTestId('node-title-container'));
const renameInput = getByTestId('node-rename-input');
await userEvent.clear(renameInput);
const renameInput = getByTestId('inline-edit-input');
await userEvent.type(renameInput, 'New Node Name');
expect(renameInput).toHaveValue('New Node Name');
const cancelButton = getByRole('button', { name: 'Cancel' });
await fireEvent.focus(cancelButton);
await fireEvent.keyDown(cancelButton, { key: 'Enter' });
await userEvent.keyboard('{Escape}');
await userEvent.click(renameInput);
expect(renameInput).toHaveValue('Test Node');
expect(emitted('update:model-value')).toBeUndefined();
});
});

View File

@@ -1,8 +1,9 @@
<script setup lang="ts">
import NodeIcon from '@/components/NodeIcon.vue';
import { useI18n } from '@n8n/i18n';
import { N8nInlineTextEdit } from '@n8n/design-system';
import { useElementSize } from '@vueuse/core';
import type { INodeTypeDescription } from 'n8n-workflow';
import { computed, nextTick, ref } from 'vue';
import { useTemplateRef } from 'vue';
type Props = {
modelValue: string;
@@ -10,81 +11,39 @@ type Props = {
readOnly?: boolean;
};
const props = withDefaults(defineProps<Props>(), {
withDefaults(defineProps<Props>(), {
modelValue: '',
nodeType: undefined,
readOnly: false,
});
const emit = defineEmits<{
'update:model-value': [value: string];
}>();
const editName = ref(false);
const newName = ref('');
const input = ref<HTMLInputElement>();
const i18n = useI18n();
const editable = computed(() => !props.readOnly && window === window.parent);
async function onEdit() {
newName.value = props.modelValue;
editName.value = true;
await nextTick();
if (input.value) {
input.value.focus();
function onRename(value: string) {
if (value.trim() !== '') {
emit('update:model-value', value.trim());
}
}
function onRename() {
if (newName.value.trim() !== '') {
emit('update:model-value', newName.value.trim());
}
editName.value = false;
}
const wrapperRef = useTemplateRef('wrapperRef');
const { width } = useElementSize(wrapperRef);
</script>
<template>
<span :class="$style.container" data-test-id="node-title-container" @click="onEdit">
<span :class="$style.container" data-test-id="node-title-container">
<span :class="$style.iconWrapper">
<NodeIcon :node-type="nodeType" :size="18" />
</span>
<n8n-popover placement="right" width="200" :visible="editName" :disabled="!editable">
<div
:class="$style.editContainer"
@keydown.enter="onRename"
@keydown.stop
@keydown.esc="editName = false"
>
<n8n-text :bold="true" color="text-base" tag="div">{{
i18n.baseText('ndv.title.renameNode')
}}</n8n-text>
<n8n-input ref="input" v-model="newName" size="small" data-test-id="node-rename-input" />
<div :class="$style.editButtons">
<n8n-button
type="secondary"
size="small"
:label="i18n.baseText('ndv.title.cancel')"
@click="editName = false"
@keydown.enter.stop
/>
<n8n-button
type="primary"
size="small"
:label="i18n.baseText('ndv.title.rename')"
@click="onRename"
/>
</div>
</div>
<template #reference>
<div :class="{ [$style.title]: true, [$style.hoverable]: editable }">
{{ modelValue }}
<div :class="$style.editIconContainer">
<font-awesome-icon v-if="editable" :class="$style.editIcon" icon="pencil-alt" />
</div>
</div>
</template>
</n8n-popover>
<div ref="wrapperRef" :class="$style.textWrapper">
<N8nInlineTextEdit
:max-width="width"
:model-value="modelValue"
:read-only="readOnly"
@update:model-value="onRename"
/>
</div>
</span>
</template>
@@ -93,62 +52,18 @@ function onRename() {
font-weight: var(--font-weight-medium);
display: flex;
font-size: var(--font-size-m);
line-height: var(--font-line-height-compact);
overflow-wrap: anywhere;
padding-right: var(--spacing-s);
overflow: hidden;
}
.title {
max-height: 100px;
display: -webkit-box;
-webkit-line-clamp: 5;
-webkit-box-orient: vertical;
margin-right: var(--spacing-s);
color: var(--color-text-dark);
width: 100%;
}
.hoverable {
&:hover {
cursor: pointer;
.editIcon {
display: inline-block;
}
}
.textWrapper {
display: flex;
flex-grow: 1;
}
.iconWrapper {
display: inline-flex;
margin-right: var(--spacing-2xs);
}
.editIcon {
display: none;
font-size: var(--font-size-xs);
color: var(--color-text-base);
position: absolute;
bottom: 0;
}
.editIconContainer {
display: inline-block;
position: relative;
width: 0;
}
.editButtons {
text-align: right;
margin-top: var(--spacing-s);
> * {
margin-left: var(--spacing-4xs);
}
}
.editContainer {
text-align: left;
> *:first-child {
margin-bottom: var(--spacing-4xs);
}
}
</style>

View File

@@ -1,8 +1,9 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { computed, onMounted, ref, useTemplateRef } from 'vue';
import get from 'lodash/get';
import set from 'lodash/set';
import unset from 'lodash/unset';
import type {
IDataObject,
NodeParameterValue,
@@ -38,7 +39,6 @@ import { useUIStore } from '@/stores/ui.store';
import { hasPermission } from '@/utils/rbac/permissions';
import { destinationToFakeINodeUi } from '@/components/SettingsLogStreaming/Helpers.ee';
import type { BaseTextKey } from '@n8n/i18n';
import InlineNameEdit from '@/components/InlineNameEdit.vue';
import SaveButton from '@/components/SaveButton.vue';
import EventSelection from '@/components/SettingsLogStreaming/EventSelection.ee.vue';
import { useTelemetry } from '@/composables/useTelemetry';
@@ -49,6 +49,8 @@ import {
sentryModalDescription,
syslogModalDescription,
} from './descriptions.ee';
import { useElementSize } from '@vueuse/core';
import { N8nInlineTextEdit, N8nText } from '@n8n/design-system';
defineOptions({ name: 'EventDestinationSettingsModal' });
@@ -353,6 +355,9 @@ function callEventBus(event: string, data: unknown) {
eventBus.emit(event, data);
}
}
const defNameRef = useTemplateRef('defNameRef');
const { width } = useElementSize(defNameRef);
</script>
<template>
@@ -377,15 +382,17 @@ function callEventBus(event: string, data: unknown) {
</template>
<template v-else>
<div :class="$style.header">
<div :class="$style.destinationInfo">
<InlineNameEdit
:model-value="headerLabel"
:subtitle="!isTypeAbstract ? i18n.baseText(typeLabelName) : 'Select type'"
:readonly="isTypeAbstract"
type="Credential"
<div ref="defNameRef" :class="$style.destinationInfo">
<N8nInlineTextEdit
:max-width="width - 10"
data-test-id="subtitle-showing-type"
:model-value="headerLabel"
:readonly="isTypeAbstract"
@update:model-value="onLabelChange"
/>
<N8nText size="small" tag="p" color="text-light">{{
!isTypeAbstract ? i18n.baseText(typeLabelName) : 'Select type'
}}</N8nText>
</div>
<div :class="$style.destinationActions">
<n8n-button
@@ -587,10 +594,11 @@ function callEventBus(event: string, data: unknown) {
}
.destinationInfo {
display: flex;
align-items: center;
flex-direction: row;
flex-grow: 1;
display: flex;
width: 100%;
flex-direction: column;
gap: var(--spacing-4xs);
margin-bottom: var(--spacing-l);
}

View File

@@ -857,6 +857,13 @@ function onRenameNode(parameterData: IUpdateInformation) {
async function onOpenRenameNodeModal(id: string) {
const currentName = workflowsStore.getNodeById(id)?.name ?? '';
const activeElement = document.activeElement;
if (activeElement && activeElement.tagName === 'INPUT') {
// If an input is focused, do not open the rename modal
return;
}
if (!keyBindingsEnabled.value || document.querySelector('.rename-prompt')) return;
try {

View File

@@ -55,6 +55,7 @@ import {
N8nCard,
N8nHeading,
N8nIcon,
N8nInlineTextEdit,
N8nInputLabel,
N8nOption,
N8nSelect,
@@ -64,7 +65,7 @@ import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Brea
import { createEventBus } from '@n8n/utils/event-bus';
import debounce from 'lodash/debounce';
import { PROJECT_ROOT } from 'n8n-workflow';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useTemplateRef, computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { type LocationQueryRaw, useRoute, useRouter } from 'vue-router';
const SEARCH_DEBOUNCE_TIME = 300;
@@ -140,8 +141,6 @@ const currentFolderId = ref<string | null>(null);
const showCardsBadge = ref(false);
const isNameEditEnabled = ref(false);
/**
* Folder actions
* These can appear on the list header, and then they are applied to current folder
@@ -1413,17 +1412,16 @@ const onCreateWorkflowClick = () => {
});
};
const onNameToggle = () => {
isNameEditEnabled.value = !isNameEditEnabled.value;
};
const renameInput = useTemplateRef('renameInput');
function onNameToggle() {
setTimeout(() => {
if (renameInput.value?.forceFocus) {
renameInput.value.forceFocus();
}
}, 0);
}
const onNameSubmit = async ({
name,
onSubmit,
}: {
name: string;
onSubmit: (saved: boolean) => void;
}) => {
const onNameSubmit = async (name: string) => {
if (!currentFolder.value || !currentProject.value) return;
const newName = name.trim();
@@ -1434,14 +1432,11 @@ const onNameSubmit = async ({
type: 'error',
});
onSubmit(false);
return;
}
if (newName === currentFolder.value.name) {
isNameEditEnabled.value = false;
onSubmit(true);
renameInput.value?.forceCancel();
return;
}
@@ -1452,7 +1447,7 @@ const onNameSubmit = async ({
message: validationResult,
type: 'error',
});
onSubmit(false);
renameInput.value?.forceCancel();
return;
} else {
try {
@@ -1467,11 +1462,9 @@ const onNameSubmit = async ({
telemetry.track('User renamed folder', {
folder_id: currentFolder.value.id,
});
isNameEditEnabled.value = false;
onSubmit(true);
} catch (error) {
toast.showError(error, i18n.baseText('folders.rename.error.title'));
onSubmit(false);
renameInput.value?.forceCancel();
}
}
};
@@ -1605,17 +1598,16 @@ const onNameSubmit = async ({
>
<template #append>
<span :class="$style['path-separator']">/</span>
<InlineTextEdit
<N8nInlineTextEdit
ref="renameInput"
:key="currentFolder?.id"
data-test-id="breadcrumbs-item-current"
:model-value="currentFolder.name"
:preview-value="currentFolder.name"
:is-edit-enabled="isNameEditEnabled"
:max-length="30"
:disabled="readOnlyEnv || !hasPermissionToUpdateFolders"
:class="{ [$style.name]: true, [$style['pointer-disabled']]: isDragging }"
:placeholder="i18n.baseText('folders.rename.placeholder')"
@toggle="onNameToggle"
@submit="onNameSubmit"
:model-value="currentFolder.name"
:max-length="30"
:read-only="readOnlyEnv || !hasPermissionToUpdateFolders"
:class="{ [$style.name]: true, [$style['pointer-disabled']]: isDragging }"
@update:model-value="onNameSubmit"
/>
</template>
</FolderBreadcrumbs>
@@ -1938,12 +1930,13 @@ const onNameSubmit = async ({
.path-separator {
font-size: var(--font-size-xl);
color: var(--color-foreground-base);
margin: var(--spacing-4xs);
padding: var(--spacing-3xs) var(--spacing-4xs) var(--spacing-4xs);
}
.name {
color: $custom-font-dark;
font-size: var(--font-size-s);
padding: var(--spacing-3xs) var(--spacing-4xs) var(--spacing-4xs);
}
.pointer-disabled {