Fix off-by-one and whitespace bugs in the text truncation utilities

What does this MR do and why?

This fixes two bugs in the truncation helpers of app/assets/javascripts/lib/utils/text_utility.js.

truncate() keeps a string that is one character over the limit. It only shortens a string when string.length - 1 > maxLength, so a string exactly one character longer than maxLength comes back unchanged, longer than the limit, while longer strings are cut to exactly maxLength characters: truncate('abcdef', 5) returns 'abcdef', and truncate('abcdefg', 5) returns 'abcd\u2026'. The check now compares the length itself.

Two callers show it. topic_badges.vue limits topic names to 15 characters, so a 16-character topic such as machine-learning is shown in full and without a tooltip, while a 17-character topic is cut to 15 characters and shows the full name in a tooltip. Pasting an image into a Markdown field calls truncate(filename, 246), which lets a 247-character file name through.

truncateWidth() relied on the old check: when the whole string fits, it passes the index of the last character as maxLength, which the old check let through. With only the one-line change, its existing specs for a string narrower than, or exactly as wide as, maxWidth fail. It now returns the string unchanged when its estimated width fits, and otherwise calls truncate() as before. That also fixes the same off-by-one in truncateWidth(): a string exactly one character too wide is now shortened, where it used to be returned as is.

truncateByWords() adds an ellipsis to text it did not cut. It decides whether it cut the text by comparing the length of the kept words, joined with single spaces, with the length of the trimmed input. Any repeated whitespace (two spaces, a tab after a space, a blank line between paragraphs) makes the joined text shorter even when there are no more than maxWords words, so truncateByWords('one two', 7) returns 'one two\u2026'. It now compares the word count with maxWords and returns the trimmed text unchanged when it is within the limit. Text over the limit is truncated as before, with single spaces between the kept words. Its only caller, note_actions.vue, adds the result to the aria labels of the note actions, so for a comment whose first line is Done. Thanks, with two spaces after the period, the label of the edit button ends in : 'Done. Thanks\u2026' instead of : 'Done. Thanks'.

The spec of text_utility.js gets an example for each bug, and the specs of topic_badges.vue and note_actions.vue get one each for what those two components show.

Behaviour change: every caller of truncate() now gets a shortened string, ending in the ellipsis, for input exactly one character over its limit, where it used to get the input back. The existing specs of all of them pass unchanged (list below).

References

There is no issue for this; the new specs reproduce both bugs on master. truncateByWords() came with !225106 (merged).

Screenshots or screen recordings

Explore > Projects on GitLab CE 19.5.0 (the official Docker image), signed out, with one public project whose topics are web-development (15 characters), machine-learning (16) and kubernetes-config (17), and the pointer on the machine-learning badge. That release has the same text_utility.js, topic_badges.vue and projects_list_item.vue as the base of this MR. The After image is the same page with the served bundle's compiled truncate() check changed in the browser the way this MR changes the source; it is not a build of this branch.

Before, machine-learning is shown in full and has no tooltip, while kubernetes-config is cut to 15 characters. After, machine-learning is cut to 15 characters too and shows its full name in a tooltip.

Before After
Topic badges on master: machine-learning in full, no tooltip Topic badges with the fix: machine-learning shortened, with its tooltip

How to set up and validate locally

  1. Run yarn jest spec/frontend/lib/utils/text_utility_spec.js spec/frontend/vue_shared/components/topic_badges_spec.js spec/frontend/notes/components/note_actions_spec.js.
  2. The new examples are truncate › when str length is one more than maxLength › returns truncated string of maxLength characters, truncateWidth › when rendered width is one character more than maxWidth › returns truncated string, the two under truncateByWords › with whitespace handling (when words are separated by repeated whitespace › returns the original string and when lines are separated by blank lines › returns the original string), the topic badges example truncation › when topic name is one character over the limit › truncates name and shows tooltip with full name, and the note actions example when note preview is short and has repeated whitespace › should not add an ellipsis. Each of them fails with the code on master, with the values below, and passes on this branch.
What I ran and what it printed

All runs used Node 22.23.3 and yarn install --frozen-lockfile on master at f0464c04. In the values below the ellipsis is written as \u2026.

Red, the new examples against the text_utility.js of master (Tests: 4 failed, 128 passed, 132 total for text_utility_spec.js, and Tests: 2 failed, 88 passed, 90 total for the topic badges and note actions specs):

Example Expected Received on master
truncate one more than maxLength "abcd\u2026" "abcdef"
truncateWidth one character more than maxWidth "Twelve cha\u2026" "Twelve chars"
truncateByWords repeated whitespace "one two" "one two\u2026"
truncateByWords blank lines "first line\n\nsecond line" "first line second line\u2026"
Topic badges, the topic machine-learning "machine-learni\u2026" "machine-learning"
Note actions, the preview Done. Thanks aria label ending in : 'Done. Thanks' aria label ending in : 'Done. Thanks\u2026'

Green:

  • yarn jest spec/frontend/lib/utils/text_utility_spec.js: 132 passed. The topic badges and note actions specs: 90 passed. Every spec under spec/frontend/lib/utils/: 109 suites passed.
  • The specs of the callers of truncate(), truncateWidth() and truncateByWords(), with this branch, and with master and its own specs: 19 suites passed both times. CE: behaviors/markdown/accessibility, ci/catalog/components/list/ci_resources_list_item, content_editor/extensions/task_item, diffs/components/diff_gutter_avatars, dropzone_input, environments/new_environment_item, import_entities/components/import_target_dropdown, merge_conflicts/utils, notes/components/note_actions, rapid_diffs/app/discussions/diff_gutter_toggle, vue_shared/components/topic_badges, vue_shared/components/users_table/user_avatar. EE: ai/catalog/router/ai_catalog_breadcrumbs, ai/duo_agentic_chat/plugins/start_flow/components/message_tool_start_flow, analytics/contribution_analytics/components/column_chart (the truncateWidth() caller), environments/new_environment_item, explore/analytics_dashboards/data_sources/code_suggestions_acceptance_by_language, oncall_schedule/rotations/components/rotation_assignee, packages_and_registries/artifact_registry/repositories/components/tag_badges. The two that import generated fixtures ran with the frontend fixtures package published for 7cf59326, two commits before the base.
  • prettier --check and eslint --max-warnings 0 on the four changed files: clean. scripts/lint/commit_linter.rb on the commit: no findings.
  • To check each assertion, I reverted the truncate() comparison, changed width <= maxWidth to <, removed the early return from truncateWidth(), and changed the word count check from > to >=, one at a time; text_utility_spec.js failed each time.

I did not run the whole Jest suite: jest --findRelatedTests lists 3588 spec files that reach text_utility.js, and I left those beyond the ones above to the pipeline.

I found this while maintaining gitlab-mcp-server; its upstream-bugs.md records everything done on its dependencies and sibling projects.

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Edited by José M. Requena Plens

Merge request reports

Loading
Loading