diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json
index f076b479717..b8710be94b5 100644
--- a/packages/frontend/@n8n/i18n/src/locales/en.json
+++ b/packages/frontend/@n8n/i18n/src/locales/en.json
@@ -48,6 +48,7 @@
"generic.back": "Back",
"generic.cancel": "Cancel",
"generic.connect": "Connect",
+ "generic.characterCount": "{count}/{max}",
"generic.save": "Save",
"generic.open": "Open",
"generic.openResource": "Open {resource}",
diff --git a/packages/frontend/editor-ui/src/app/components/CharacterCount.vue b/packages/frontend/editor-ui/src/app/components/CharacterCount.vue
new file mode 100644
index 00000000000..d7185e8ceac
--- /dev/null
+++ b/packages/frontend/editor-ui/src/app/components/CharacterCount.vue
@@ -0,0 +1,29 @@
+
+
+
+
+ {{
+ i18n.baseText('generic.characterCount', {
+ interpolate: { count: String(value.length), max: String(max) },
+ })
+ }}
+
+
+
+
diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowVersionForm.test.ts b/packages/frontend/editor-ui/src/app/components/WorkflowVersionForm.test.ts
index 6aaf0d257dd..29aad5a2ff5 100644
--- a/packages/frontend/editor-ui/src/app/components/WorkflowVersionForm.test.ts
+++ b/packages/frontend/editor-ui/src/app/components/WorkflowVersionForm.test.ts
@@ -10,7 +10,7 @@ describe('WorkflowVersionForm', () => {
});
it('should render version name and description inputs', () => {
- const { getByTestId } = renderComponent({
+ const { getByTestId, queryByTestId } = renderComponent({
props: {
versionName: '',
description: '',
@@ -21,6 +21,9 @@ describe('WorkflowVersionForm', () => {
expect(getByTestId('version-name-input')).toBeInTheDocument();
expect(getByTestId('description-input')).toBeInTheDocument();
+ // character counters stay hidden while the fields are empty
+ expect(queryByTestId('workflow-version-name-character-count')).not.toBeInTheDocument();
+ expect(queryByTestId('workflow-version-description-character-count')).not.toBeInTheDocument();
});
it('should update versionName model when input changes', async () => {
@@ -88,8 +91,8 @@ describe('WorkflowVersionForm', () => {
it('should respect maxlength constraints', () => {
const { getByTestId } = renderComponent({
props: {
- versionName: '',
- description: '',
+ versionName: 'v1.0.0',
+ description: 'Test',
versionNameTestId: 'version-name-input',
descriptionTestId: 'description-input',
},
@@ -100,6 +103,8 @@ describe('WorkflowVersionForm', () => {
expect(versionInput).toHaveAttribute('maxlength', '128');
expect(descriptionInput).toHaveAttribute('maxlength', '2048');
+ expect(getByTestId('workflow-version-name-character-count')).toHaveTextContent('6/128');
+ expect(getByTestId('workflow-version-description-character-count')).toHaveTextContent('4/2048');
});
it('should expose focusInput method', () => {
diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowVersionForm.vue b/packages/frontend/editor-ui/src/app/components/WorkflowVersionForm.vue
index 0692df3e4ef..054b2f6b5f8 100644
--- a/packages/frontend/editor-ui/src/app/components/WorkflowVersionForm.vue
+++ b/packages/frontend/editor-ui/src/app/components/WorkflowVersionForm.vue
@@ -7,6 +7,8 @@ import { N8nInput, N8nInputLabel } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useTemplateRef } from 'vue';
+import CharacterCount from '@/app/components/CharacterCount.vue';
+
defineProps<{
disabled?: boolean;
versionNameTestId?: string;
@@ -24,7 +26,6 @@ const emit = defineEmits<{
}>();
const focusInput = () => {
- // highlight the value in the input
nameInputRef.value?.select();
};
@@ -55,6 +56,11 @@ defineExpose({
:data-test-id="versionNameTestId"
@keydown.enter.prevent="handleEnterKey"
/>
+
+
diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewDetailMetadata.vue b/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewDetailMetadata.vue
index e1f5e4ea0fe..f5f75640fd0 100644
--- a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewDetailMetadata.vue
+++ b/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewDetailMetadata.vue
@@ -5,7 +5,7 @@ import { type BaseTextKey, useI18n } from '@n8n/i18n';
import { computed } from 'vue';
import { VIEWS } from '@/app/constants';
-import { formatUserDisplayName } from '../formatUserDisplayName';
+import { formatUserDisplayName } from '../workflowReviews.utils';
import WorkflowReviewStatusDot from './WorkflowReviewStatusDot.vue';
const props = defineProps<{
diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewStatusBanner.vue b/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewStatusBanner.vue
index 7642d5537ad..6a734e0928a 100644
--- a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewStatusBanner.vue
+++ b/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowReviewStatusBanner.vue
@@ -4,7 +4,7 @@ import { N8nButton, N8nHeading, N8nPopover, N8nText, N8nTooltip } from '@n8n/des
import { useI18n } from '@n8n/i18n';
import { computed, ref } from 'vue';
-import { formatUserDisplayName } from '../formatUserDisplayName';
+import { formatUserDisplayName } from '../workflowReviews.utils';
type BannerAction = 'submit-changes' | 'retry-publish';
diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowSubmitForReviewDialog.test.ts b/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowSubmitForReviewDialog.test.ts
index ee211003fc2..b8e97c452da 100644
--- a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowSubmitForReviewDialog.test.ts
+++ b/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowSubmitForReviewDialog.test.ts
@@ -139,8 +139,12 @@ describe('WorkflowSubmitForReviewDialog', () => {
const versionDescriptionInput = getByTestId('workflow-review-version-description-input');
await userEvent.type(versionDescriptionInput, 'What changed');
+ expect(getByTestId('workflow-version-description-character-count')).toHaveTextContent(
+ '12/2048',
+ );
await goToStep2();
await userEvent.type(getByTestId('workflow-review-title-input'), 'Review payments');
+ expect(getByTestId('workflow-review-title-character-count')).toHaveTextContent('15/128');
await userEvent.click(getByTestId('workflow-review-back-button'));
@@ -187,7 +191,7 @@ describe('WorkflowSubmitForReviewDialog', () => {
});
it('requires a non-empty title and cancel creates nothing', async () => {
- const { getByTestId, emitted, goToStep2 } = await renderDialog();
+ const { getByTestId, queryByTestId, emitted, goToStep2 } = await renderDialog();
await goToStep2();
const titleInput = getByTestId('workflow-review-title-input');
@@ -195,6 +199,9 @@ describe('WorkflowSubmitForReviewDialog', () => {
expect(titleInput).toHaveAttribute('maxlength', '128');
expect(getByTestId('workflow-review-description-input')).toHaveAttribute('maxlength', '512');
expect(submitButton).toBeDisabled();
+ // counters only appear once there is something to count
+ expect(queryByTestId('workflow-review-title-character-count')).not.toBeInTheDocument();
+ expect(queryByTestId('workflow-review-description-character-count')).not.toBeInTheDocument();
await userEvent.type(titleInput, ' ');
expect(submitButton).toBeDisabled();
@@ -219,6 +226,7 @@ describe('WorkflowSubmitForReviewDialog', () => {
await userEvent.type(getByTestId('workflow-review-title-input'), ' Review payments ');
await userEvent.type(getByTestId('workflow-review-description-input'), ' Check retries ');
+ expect(getByTestId('workflow-review-description-character-count')).toHaveTextContent('17/512');
await selectReviewer();
await userEvent.click(getByTestId('workflow-review-submit-button'));
@@ -231,7 +239,6 @@ describe('WorkflowSubmitForReviewDialog', () => {
workflowId: 'workflow-1',
workflowVersionId: SAVED_VERSION_ID,
workflowVersionName: GENERATED_VERSION_NAME,
- // R3 (P3): omitted while untouched — see LIGO-939_review.md.
workflowVersionDescription: undefined,
},
],
diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowSubmitForReviewDialog.vue b/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowSubmitForReviewDialog.vue
index 90b1613a961..c87e38f793b 100644
--- a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowSubmitForReviewDialog.vue
+++ b/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowSubmitForReviewDialog.vue
@@ -19,9 +19,10 @@ import { useI18n } from '@n8n/i18n';
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
import { useToast } from '@n8n/composables/useToast';
+import CharacterCount from '@/app/components/CharacterCount.vue';
import WorkflowVersionForm from '@/app/components/WorkflowVersionForm.vue';
import { useReviewVersionName } from '@/features/workflow-reviews/composables/useReviewVersionName';
-import { formatUserDisplayName } from '@/features/workflow-reviews/formatUserDisplayName';
+import { formatUserDisplayName } from '@/features/workflow-reviews/workflowReviews.utils';
import { useReviewRequiredStore } from '@/features/workflow-reviews/reviewRequired.store';
import { useWorkflowReviewStatusStore } from '@/features/workflow-reviews/reviewStatus.store';
import {
@@ -278,6 +279,11 @@ const submit = async () => {
:disabled="isSubmitting"
data-test-id="workflow-review-title-input"
/>
+
{
:disabled="isSubmitting"
data-test-id="workflow-review-description-input"
/>
+
{
'maxlength',
'512',
);
+ expect(getByTestId('workflow-update-review-description-character-count')).toHaveTextContent(
+ '27/512',
+ );
});
it('returns to the version step without losing the review description', async () => {
@@ -314,9 +317,12 @@ describe('WorkflowUpdateReviewDialog', () => {
});
it('sends an empty review description when the prefilled value is cleared', async () => {
- const { getByTestId, goToStep2 } = await renderDialog();
+ const { getByTestId, queryByTestId, goToStep2 } = await renderDialog();
await goToStep2();
await userEvent.clear(getByTestId('workflow-update-review-description-input'));
+ expect(
+ queryByTestId('workflow-update-review-description-character-count'),
+ ).not.toBeInTheDocument();
await userEvent.click(getByTestId('workflow-update-review-submit-button'));
diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowUpdateReviewDialog.vue b/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowUpdateReviewDialog.vue
index e9b3d87d293..b16f7100146 100644
--- a/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowUpdateReviewDialog.vue
+++ b/packages/frontend/editor-ui/src/features/workflow-reviews/components/WorkflowUpdateReviewDialog.vue
@@ -18,6 +18,7 @@ import { computed, ref, watch } from 'vue';
import { I18nT } from 'vue-i18n';
import { useToast } from '@n8n/composables/useToast';
+import CharacterCount from '@/app/components/CharacterCount.vue';
import WorkflowVersionForm from '@/app/components/WorkflowVersionForm.vue';
import { useLatestFetch } from '@/app/composables/useLatestFetch';
import { useReviewVersionName } from '@/features/workflow-reviews/composables/useReviewVersionName';
@@ -295,6 +296,11 @@ const submit = async () => {
:disabled="isSubmitting"
data-test-id="workflow-update-review-description-input"
/>
+
diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/components/activity-entries/WorkflowReviewActivityComment.vue b/packages/frontend/editor-ui/src/features/workflow-reviews/components/activity-entries/WorkflowReviewActivityComment.vue
index 9c0678a7af6..e9a1e068014 100644
--- a/packages/frontend/editor-ui/src/features/workflow-reviews/components/activity-entries/WorkflowReviewActivityComment.vue
+++ b/packages/frontend/editor-ui/src/features/workflow-reviews/components/activity-entries/WorkflowReviewActivityComment.vue
@@ -5,7 +5,7 @@ import { useI18n } from '@n8n/i18n';
import TimeAgo from '@/app/components/TimeAgo.vue';
-import { formatUserDisplayName } from '../../formatUserDisplayName';
+import { formatUserDisplayName } from '../../workflowReviews.utils';
defineProps<{
entry: Extract;
diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/formatUserDisplayName.ts b/packages/frontend/editor-ui/src/features/workflow-reviews/formatUserDisplayName.ts
deleted file mode 100644
index 7e3d5c4d2ae..00000000000
--- a/packages/frontend/editor-ui/src/features/workflow-reviews/formatUserDisplayName.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-type UserNameParts = {
- firstName?: string | null;
- lastName?: string | null;
- email?: string | null;
-};
-
-export function formatUserDisplayName(user: UserNameParts): string {
- return [user.firstName, user.lastName].filter(Boolean).join(' ') || user.email || '';
-}
diff --git a/packages/frontend/editor-ui/src/features/workflow-reviews/workflowReviews.utils.ts b/packages/frontend/editor-ui/src/features/workflow-reviews/workflowReviews.utils.ts
index 82fa8fb4e7a..33818bd1905 100644
--- a/packages/frontend/editor-ui/src/features/workflow-reviews/workflowReviews.utils.ts
+++ b/packages/frontend/editor-ui/src/features/workflow-reviews/workflowReviews.utils.ts
@@ -1,3 +1,13 @@
export function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
+
+type UserNameParts = {
+ firstName?: string | null;
+ lastName?: string | null;
+ email?: string | null;
+};
+
+export function formatUserDisplayName(user: UserNameParts): string {
+ return [user.firstName, user.lastName].filter(Boolean).join(' ') || user.email || '';
+}