From 998fc85bfa1b360da2b84e57e34cef7fbdb5a681 Mon Sep 17 00:00:00 2001 From: Kai Date: Fri, 14 Aug 2026 12:15:22 +0000 Subject: [PATCH] feat(editor): Show character counters on review title and description inputs (#36177) --- .../frontend/@n8n/i18n/src/locales/en.json | 1 + .../src/app/components/CharacterCount.vue | 29 +++++++++++++++++++ .../components/WorkflowVersionForm.test.ts | 11 +++++-- .../app/components/WorkflowVersionForm.vue | 13 ++++++++- .../WorkflowReviewDetailMetadata.vue | 2 +- .../components/WorkflowReviewStatusBanner.vue | 2 +- .../WorkflowSubmitForReviewDialog.test.ts | 11 +++++-- .../WorkflowSubmitForReviewDialog.vue | 13 ++++++++- .../WorkflowUpdateReviewDialog.test.ts | 8 ++++- .../components/WorkflowUpdateReviewDialog.vue | 6 ++++ .../WorkflowReviewActivityComment.vue | 2 +- .../workflow-reviews/formatUserDisplayName.ts | 9 ------ .../workflow-reviews/workflowReviews.utils.ts | 10 +++++++ 13 files changed, 97 insertions(+), 20 deletions(-) create mode 100644 packages/frontend/editor-ui/src/app/components/CharacterCount.vue delete mode 100644 packages/frontend/editor-ui/src/features/workflow-reviews/formatUserDisplayName.ts 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 @@ + + + + + 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" /> +