feat(editor): Show character counters on review title and description inputs (#36177)

This commit is contained in:
Kai 2026-08-14 12:15:22 +00:00 committed by GitHub
parent 51b0de1875
commit 998fc85bfa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 97 additions and 20 deletions

View File

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

View File

@ -0,0 +1,29 @@
<script setup lang="ts">
import { N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
defineProps<{
value: string;
max: number;
}>();
const i18n = useI18n();
</script>
<template>
<N8nText v-if="value" size="xsmall" color="text-light" :class="$style.characterCount">
{{
i18n.baseText('generic.characterCount', {
interpolate: { count: String(value.length), max: String(max) },
})
}}
</N8nText>
</template>
<style lang="scss" module>
.characterCount {
display: flex;
justify-content: flex-end;
margin-top: var(--spacing--2xs);
}
</style>

View File

@ -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', () => {

View File

@ -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"
/>
<CharacterCount
:value="versionName"
:max="WORKFLOW_VERSION_NAME_MAX_LENGTH"
data-test-id="workflow-version-name-character-count"
/>
</N8nInputLabel>
<N8nInputLabel
input-name="workflow-version-description"
@ -70,6 +76,11 @@ defineExpose({
:maxlength="WORKFLOW_VERSION_DESCRIPTION_MAX_LENGTH"
:data-test-id="descriptionTestId"
/>
<CharacterCount
:value="description"
:max="WORKFLOW_VERSION_DESCRIPTION_MAX_LENGTH"
data-test-id="workflow-version-description-character-count"
/>
</N8nInputLabel>
</div>
</template>

View File

@ -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<{

View File

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

View File

@ -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,
},
],

View File

@ -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"
/>
<CharacterCount
:value="reviewTitle"
:max="REVIEW_TITLE_MAX_LENGTH"
data-test-id="workflow-review-title-character-count"
/>
</N8nInputLabel>
<N8nInputLabel
input-name="workflow-review-description"
@ -292,6 +298,11 @@ const submit = async () => {
:disabled="isSubmitting"
data-test-id="workflow-review-description-input"
/>
<CharacterCount
:value="description"
:max="REVIEW_DESCRIPTION_MAX_LENGTH"
data-test-id="workflow-review-description-character-count"
/>
</N8nInputLabel>
<N8nInputLabel
input-name="workflow-review-reviewer"

View File

@ -258,6 +258,9 @@ describe('WorkflowUpdateReviewDialog', () => {
'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'));

View File

@ -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"
/>
<CharacterCount
:value="reviewDescription"
:max="REVIEW_DESCRIPTION_MAX_LENGTH"
data-test-id="workflow-update-review-description-character-count"
/>
</N8nInputLabel>
<N8nDialogFooter>
<template v-if="step === 1">

View File

@ -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<WorkflowReviewActivityEntry, { type: 'comment.created' }>;

View File

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

View File

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