mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
refactor(editor): Reka UI inline text edit component (#15752)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user