mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-19 16:55:02 +02:00
feat(editor): Show character counters on review title and description inputs (#36177)
This commit is contained in:
parent
51b0de1875
commit
998fc85bfa
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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<{
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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' }>;
|
||||
|
|
|
|||
|
|
@ -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 || '';
|
||||
}
|
||||
|
|
@ -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 || '';
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user