From 97046114baee80b9b0a042fcb9b0f8c190fb3227 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Sat, 5 Sep 2026 11:52:10 -0400 Subject: [PATCH 1/3] fix(l10n): include the count as a placeholder in the catalog labels Signed-off-by: YvesCesar --- .../admin/AdminSelectOptionsSection.vue | 8 ++-- src/tests/components/AdminSettings.spec.ts | 17 +++++++ .../admin/AdminSelectOptionsSection.spec.ts | 2 +- src/tests/utils/countLabel.spec.ts | 48 +++++++++++++++++++ src/utils/countLabel.ts | 24 ++++++++++ src/views/AdminSettings.vue | 17 ++++--- 6 files changed, 105 insertions(+), 11 deletions(-) create mode 100644 src/tests/utils/countLabel.spec.ts create mode 100644 src/utils/countLabel.ts diff --git a/src/components/admin/AdminSelectOptionsSection.vue b/src/components/admin/AdminSelectOptionsSection.vue index 5c1ecb36..88902e40 100644 --- a/src/components/admin/AdminSelectOptionsSection.vue +++ b/src/components/admin/AdminSelectOptionsSection.vue @@ -10,8 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later

{{ t('profile_fields', 'Options') }}

- {{ normalizedOptionCount }} - {{ optionsCountLabel }} + {{ optionsCountLabel.before }}{{ normalizedOptionCount }}{{ optionsCountLabel.after }}
@@ -136,6 +135,7 @@ import NcTextArea from '@nextcloud/vue/components/NcTextArea' import { NcActionButton, NcActions, NcButton, NcIconSvgWrapper, NcInputField } from '@nextcloud/vue' import { createEditableSelectOptions, extractEditableSelectOptionValues, moveEditableSelectOption, normalizeEditableSelectOptionValue, parseEditableSelectOptionValues } from '../../utils/selectFieldOptions.js' import type { EditableSelectOption } from '../../utils/selectFieldOptions.js' +import { COUNT_PLACEHOLDER, splitCountLabel } from '../../utils/countLabel.js' const props = defineProps<{ modelValue: EditableSelectOption[], @@ -156,7 +156,7 @@ const options = computed(() => props.modelValue) const bulkOptionValues = computed(() => parseEditableSelectOptionValues(bulkOptionInput.value)) const normalizedOptionCount = computed(() => extractEditableSelectOptionValues(options.value).filter((optionValue: string) => optionValue.trim() !== '').length) // TRANSLATORS "Option/Options" here means selectable field values, not application settings. -const optionsCountLabel = computed(() => n('profile_fields', 'Option', 'Options', normalizedOptionCount.value, { count: normalizedOptionCount.value })) +const optionsCountLabel = computed(() => splitCountLabel(n('profile_fields', '{count} option', '{count} options', normalizedOptionCount.value, { count: COUNT_PLACEHOLDER }))) // TRANSLATORS "{count}" is the number of parsed selectable values ready to be added. const bulkOptionsSummary = computed(() => n('profile_fields', '{count} option ready.', '{count} options ready.', bulkOptionValues.value.length, { count: bulkOptionValues.value.length })) @@ -325,7 +325,6 @@ const applyBulkOptions = async() => { &__meta { display: inline-flex; align-items: center; - gap: 8px; min-width: auto; padding: 6px 10px; border-radius: 999px; @@ -335,6 +334,7 @@ const applyBulkOptions = async() => { strong { font-size: 14px; line-height: 1; + color: var(--color-main-text); } span { diff --git a/src/tests/components/AdminSettings.spec.ts b/src/tests/components/AdminSettings.spec.ts index 5e888da7..60ad5252 100644 --- a/src/tests/components/AdminSettings.spec.ts +++ b/src/tests/components/AdminSettings.spec.ts @@ -135,4 +135,21 @@ describe('AdminSettings', () => { expect(wrapper.text()).toContain('tr:Email') }) + + it('renders the configured fields count inside the translated sentence', async() => { + const wrapper = mount(AdminSettings, { + global: { + stubs: { + Draggable: defineComponent({ template: '
' }), + }, + }, + }) + + await flushPromises() + + const heroMeta = wrapper.get('.profile-fields-admin__hero-meta') + + expect(heroMeta.get('strong').text()).toBe('0') + expect(heroMeta.text()).toBe('tr:0 fields configured') + }) }) \ No newline at end of file diff --git a/src/tests/components/admin/AdminSelectOptionsSection.spec.ts b/src/tests/components/admin/AdminSelectOptionsSection.spec.ts index 713beaf3..196e8323 100644 --- a/src/tests/components/admin/AdminSelectOptionsSection.spec.ts +++ b/src/tests/components/admin/AdminSelectOptionsSection.spec.ts @@ -76,7 +76,7 @@ describe('AdminSelectOptionsSection', () => { }) expect(wrapper.text()).toContain('tr:Options') - expect(wrapper.text()).toContain('tr:Option') + expect(wrapper.text()).toContain('tr:1 option') expect(wrapper.text()).toContain('tr:Add single option') }) diff --git a/src/tests/utils/countLabel.spec.ts b/src/tests/utils/countLabel.spec.ts new file mode 100644 index 00000000..08ac6abf --- /dev/null +++ b/src/tests/utils/countLabel.spec.ts @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { describe, expect, it } from 'vitest' + +import { COUNT_PLACEHOLDER, splitCountLabel } from '../../utils/countLabel.ts' + +describe('splitCountLabel', () => { + it('splits a label that starts with the count', () => { + expect(splitCountLabel(`${COUNT_PLACEHOLDER} fields configured`)).toEqual({ + before: '', + after: ' fields configured', + hasPlaceholder: true, + }) + }) + + it('splits a label that ends with the count, as right-to-left languages may order it', () => { + expect(splitCountLabel(`حقول مكونة ${COUNT_PLACEHOLDER}`)).toEqual({ + before: 'حقول مكونة ', + after: '', + hasPlaceholder: true, + }) + }) + + it('splits a label that places the count in the middle', () => { + expect(splitCountLabel(`son ${COUNT_PLACEHOLDER} campos configurados`)).toEqual({ + before: 'son ', + after: ' campos configurados', + hasPlaceholder: true, + }) + }) + + it('keeps the whole label when the translation dropped the placeholder', () => { + expect(splitCountLabel('3 fields configured')).toEqual({ + before: '', + after: '3 fields configured', + hasPlaceholder: false, + }) + }) + + it('splits on the first placeholder only', () => { + expect(splitCountLabel(`${COUNT_PLACEHOLDER} of ${COUNT_PLACEHOLDER}`)).toEqual({ + before: '', + after: ` of ${COUNT_PLACEHOLDER}`, + hasPlaceholder: true, + }) + }) +}) diff --git a/src/utils/countLabel.ts b/src/utils/countLabel.ts new file mode 100644 index 00000000..2c066194 --- /dev/null +++ b/src/utils/countLabel.ts @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +export interface CountLabelParts { + before: string + after: string + hasPlaceholder: boolean +} + +export const COUNT_PLACEHOLDER = '\uFFFC' + +export const splitCountLabel = (label: string): CountLabelParts => { + const placeholderIndex = label.indexOf(COUNT_PLACEHOLDER) + if (placeholderIndex === -1) { + return { before: '', after: label, hasPlaceholder: false } + } + + return { + before: label.slice(0, placeholderIndex), + after: label.slice(placeholderIndex + COUNT_PLACEHOLDER.length), + hasPlaceholder: true, + } +} diff --git a/src/views/AdminSettings.vue b/src/views/AdminSettings.vue index 90eb4211..318720bc 100644 --- a/src/views/AdminSettings.vue +++ b/src/views/AdminSettings.vue @@ -14,8 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later

- {{ definitions.length }} - {{ configuredFieldsCountLabel }} + {{ configuredFieldsCountLabel.before }}{{ definitions.length }}{{ configuredFieldsCountLabel.after }}
@@ -285,6 +284,7 @@ import { NcActionButton, NcActions, NcButton, NcCheckboxRadioSwitch, NcChip, NcE import { createDefinition, deleteDefinition, listDefinitions, updateDefinition } from '../api' import type { FieldDefinition, FieldEditPolicy, FieldExposurePolicy, FieldType } from '../types' import { createEditableSelectOptions, extractEditableSelectOptionValues } from '../utils/selectFieldOptions.js' +import { COUNT_PLACEHOLDER, splitCountLabel } from '../utils/countLabel.js' const fieldTypeOptions: Array<{ value: FieldType, label: string }> = [ { value: 'text', label: t('profile_fields', 'Text') }, @@ -398,7 +398,8 @@ const editorEmptyState = computed(() => sortedDefinitions.value.length === 0 title: t('profile_fields', 'No field selected'), description: t('profile_fields', 'Select a field from the list, or create a new one.'), }) -const configuredFieldsCountLabel = computed(() => n('profile_fields', 'field configured', 'fields configured', definitions.value.length, { count: definitions.value.length })) +// TRANSLATORS "{count}" is the number of configured fields. +const configuredFieldsCountLabel = computed(() => splitCountLabel(n('profile_fields', '{count} field configured', '{count} fields configured', definitions.value.length, { count: COUNT_PLACEHOLDER }))) // TRANSLATORS "\u00A0" keeps the ellipsis attached to the previous word for correct typography and avoids awkward line breaks. const saveActionLabel = computed(() => isSaving.value ? t('profile_fields', 'Saving changes\u00A0…') : (isEditing.value ? t('profile_fields', 'Save changes') : t('profile_fields', 'Create field'))) const editFieldAriaLabel = (label: string) => t('profile_fields', 'Edit field {label}', { label }) @@ -830,9 +831,7 @@ onBeforeUnmount(() => { &__hero-meta { display: flex; - flex-direction: column; - align-items: flex-end; - justify-content: center; + justify-content: flex-end; min-width: 120px; padding: 12px 14px; border-radius: 14px; @@ -841,11 +840,17 @@ onBeforeUnmount(() => { strong { font-size: 32px; line-height: 1; + color: var(--color-main-text); } span { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; font-size: 12px; color: var(--color-text-maxcontrast); + text-align: center; } } From 6a3e4c7d9f74a9bbff55545435cc5b29aa51d6db Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Tue, 8 Sep 2026 18:30:02 -0400 Subject: [PATCH 2/3] fix(l10n): keep the count visible inside the catalog label sentence Signed-off-by: YvesCesar --- playwright/generate-screenshots.mjs | 8 ++-- .../admin/AdminSelectOptionsSection.vue | 4 +- src/tests/components/AdminSettings.spec.ts | 34 +++++++++++++++- .../admin/AdminSelectOptionsSection.spec.ts | 40 ++++++++++++++++++- src/tests/utils/countLabel.spec.ts | 18 +++++---- src/utils/countLabel.ts | 4 +- src/views/AdminSettings.vue | 7 +--- 7 files changed, 89 insertions(+), 26 deletions(-) diff --git a/playwright/generate-screenshots.mjs b/playwright/generate-screenshots.mjs index b34f1ef3..1e89e2c2 100644 --- a/playwright/generate-screenshots.mjs +++ b/playwright/generate-screenshots.mjs @@ -310,13 +310,11 @@ const hideNonShowcaseAdminDefinitions = async(page) => { row.style.display = 'none' } }) - const heroCount = document.querySelector('.profile-fields-admin__hero-meta strong') const heroLabel = document.querySelector('.profile-fields-admin__hero-meta span') - if (heroCount instanceof HTMLElement) { - heroCount.textContent = String(keys.length) - } if (heroLabel instanceof HTMLElement) { - heroLabel.textContent = 'showcase fields' + const heroCount = heroLabel.querySelector('strong') ?? document.createElement('strong') + heroCount.textContent = String(keys.length) + heroLabel.replaceChildren(heroCount, ' showcase fields') } }, [...showcaseKeys]) } diff --git a/src/components/admin/AdminSelectOptionsSection.vue b/src/components/admin/AdminSelectOptionsSection.vue index 88902e40..3b7bd204 100644 --- a/src/components/admin/AdminSelectOptionsSection.vue +++ b/src/components/admin/AdminSelectOptionsSection.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later

{{ t('profile_fields', 'Options') }}

- {{ optionsCountLabel.before }}{{ normalizedOptionCount }}{{ optionsCountLabel.after }} + {{ optionsCountLabel.before }}{{ normalizedOptionCount }}{{ optionsCountLabel.after }}
@@ -155,7 +155,7 @@ const createOptionId = () => `option-local-${nextOptionId++}` const options = computed(() => props.modelValue) const bulkOptionValues = computed(() => parseEditableSelectOptionValues(bulkOptionInput.value)) const normalizedOptionCount = computed(() => extractEditableSelectOptionValues(options.value).filter((optionValue: string) => optionValue.trim() !== '').length) -// TRANSLATORS "Option/Options" here means selectable field values, not application settings. +// TRANSLATORS "option/options" here means selectable field values, not application settings. const optionsCountLabel = computed(() => splitCountLabel(n('profile_fields', '{count} option', '{count} options', normalizedOptionCount.value, { count: COUNT_PLACEHOLDER }))) // TRANSLATORS "{count}" is the number of parsed selectable values ready to be added. const bulkOptionsSummary = computed(() => n('profile_fields', '{count} option ready.', '{count} options ready.', bulkOptionValues.value.length, { count: bulkOptionValues.value.length })) diff --git a/src/tests/components/AdminSettings.spec.ts b/src/tests/components/AdminSettings.spec.ts index 60ad5252..02af8e38 100644 --- a/src/tests/components/AdminSettings.spec.ts +++ b/src/tests/components/AdminSettings.spec.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors // SPDX-License-Identifier: AGPL-3.0-or-later -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' import { defineComponent } from 'vue' import AdminSettings from '../../views/AdminSettings.vue' @@ -15,9 +15,16 @@ Object.defineProperty(window, 'matchMedia', { })), }) +const { pluralOverrides } = vi.hoisted(() => ({ pluralOverrides: new Map() })) + vi.mock('@nextcloud/l10n', () => ({ n: (_app: string, singular: string, plural: string, count: number, parameters?: Record) => { const template = count === 1 ? singular : plural + const override = pluralOverrides.get(template) + if (override !== undefined) { + return override + } + if (parameters === undefined) { return `tr:${template}` } @@ -76,6 +83,10 @@ vi.mock('../../components/admin/AdminSelectOptionsSection.vue', () => ({ })) describe('AdminSettings', () => { + afterEach(() => { + pluralOverrides.clear() + }) + it('offers the Date field type in the editor', async() => { const wrapper = mount(AdminSettings, { global: { @@ -152,4 +163,23 @@ describe('AdminSettings', () => { expect(heroMeta.get('strong').text()).toBe('0') expect(heroMeta.text()).toBe('tr:0 fields configured') }) -}) \ No newline at end of file + + it('keeps the count visible when the translation dropped the placeholder', async() => { + pluralOverrides.set('{count} fields configured', 'kolonky nastaveny') + + const wrapper = mount(AdminSettings, { + global: { + stubs: { + Draggable: defineComponent({ template: '
' }), + }, + }, + }) + + await flushPromises() + + const heroMeta = wrapper.get('.profile-fields-admin__hero-meta') + + expect(heroMeta.get('strong').text()).toBe('0') + expect(heroMeta.text()).toBe('0 kolonky nastaveny') + }) +}) diff --git a/src/tests/components/admin/AdminSelectOptionsSection.spec.ts b/src/tests/components/admin/AdminSelectOptionsSection.spec.ts index 196e8323..2fa50550 100644 --- a/src/tests/components/admin/AdminSelectOptionsSection.spec.ts +++ b/src/tests/components/admin/AdminSelectOptionsSection.spec.ts @@ -1,14 +1,21 @@ // SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors // SPDX-License-Identifier: AGPL-3.0-or-later -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { mount } from '@vue/test-utils' import { defineComponent } from 'vue' import AdminSelectOptionsSection from '../../../components/admin/AdminSelectOptionsSection.vue' +const { pluralOverrides } = vi.hoisted(() => ({ pluralOverrides: new Map() })) + vi.mock('@nextcloud/l10n', () => ({ n: (_app: string, singular: string, plural: string, count: number, parameters?: Record) => { const template = count === 1 ? singular : plural + const override = pluralOverrides.get(template) + if (override !== undefined) { + return override + } + if (parameters === undefined) { return `tr:${template}` } @@ -56,6 +63,10 @@ const DraggableStub = defineComponent({ }) describe('AdminSelectOptionsSection', () => { + afterEach(() => { + pluralOverrides.clear() + }) + it('renders translated headings and pluralized meta', () => { const wrapper = mount(AdminSelectOptionsSection, { props: { @@ -80,6 +91,33 @@ describe('AdminSelectOptionsSection', () => { expect(wrapper.text()).toContain('tr:Add single option') }) + it('keeps the count visible when the translation dropped the placeholder', () => { + pluralOverrides.set('{count} option', 'volba nastavena') + + const wrapper = mount(AdminSelectOptionsSection, { + props: { + modelValue: [{ id: 'option-0', value: 'Alpha' }], + isSaving: false, + }, + global: { + stubs: { + Draggable: DraggableStub, + NcDialog: false, + NcTextArea: false, + NcActionButton: false, + NcActions: false, + NcIconSvgWrapper: false, + NcInputField: false, + }, + }, + }) + + const meta = wrapper.get('.profile-fields-admin-options__meta') + + expect(meta.get('strong').text()).toBe('1') + expect(meta.text()).toBe('1 volba nastavena') + }) + it('emits updated model when adding a new option', async() => { const wrapper = mount(AdminSelectOptionsSection, { props: { diff --git a/src/tests/utils/countLabel.spec.ts b/src/tests/utils/countLabel.spec.ts index 08ac6abf..2ba2eaee 100644 --- a/src/tests/utils/countLabel.spec.ts +++ b/src/tests/utils/countLabel.spec.ts @@ -10,7 +10,6 @@ describe('splitCountLabel', () => { expect(splitCountLabel(`${COUNT_PLACEHOLDER} fields configured`)).toEqual({ before: '', after: ' fields configured', - hasPlaceholder: true, }) }) @@ -18,7 +17,6 @@ describe('splitCountLabel', () => { expect(splitCountLabel(`حقول مكونة ${COUNT_PLACEHOLDER}`)).toEqual({ before: 'حقول مكونة ', after: '', - hasPlaceholder: true, }) }) @@ -26,15 +24,20 @@ describe('splitCountLabel', () => { expect(splitCountLabel(`son ${COUNT_PLACEHOLDER} campos configurados`)).toEqual({ before: 'son ', after: ' campos configurados', - hasPlaceholder: true, }) }) - it('keeps the whole label when the translation dropped the placeholder', () => { - expect(splitCountLabel('3 fields configured')).toEqual({ + it('renders the count in front of the label when the translation dropped the placeholder', () => { + expect(splitCountLabel('kolonky nastaveny')).toEqual({ before: '', - after: '3 fields configured', - hasPlaceholder: false, + after: ' kolonky nastaveny', + }) + }) + + it('keeps the label empty when the translation is empty', () => { + expect(splitCountLabel('')).toEqual({ + before: '', + after: '', }) }) @@ -42,7 +45,6 @@ describe('splitCountLabel', () => { expect(splitCountLabel(`${COUNT_PLACEHOLDER} of ${COUNT_PLACEHOLDER}`)).toEqual({ before: '', after: ` of ${COUNT_PLACEHOLDER}`, - hasPlaceholder: true, }) }) }) diff --git a/src/utils/countLabel.ts b/src/utils/countLabel.ts index 2c066194..1363708f 100644 --- a/src/utils/countLabel.ts +++ b/src/utils/countLabel.ts @@ -5,7 +5,6 @@ export interface CountLabelParts { before: string after: string - hasPlaceholder: boolean } export const COUNT_PLACEHOLDER = '\uFFFC' @@ -13,12 +12,11 @@ export const COUNT_PLACEHOLDER = '\uFFFC' export const splitCountLabel = (label: string): CountLabelParts => { const placeholderIndex = label.indexOf(COUNT_PLACEHOLDER) if (placeholderIndex === -1) { - return { before: '', after: label, hasPlaceholder: false } + return { before: '', after: label === '' ? '' : ` ${label}` } } return { before: label.slice(0, placeholderIndex), after: label.slice(placeholderIndex + COUNT_PLACEHOLDER.length), - hasPlaceholder: true, } } diff --git a/src/views/AdminSettings.vue b/src/views/AdminSettings.vue index 318720bc..8194c4c0 100644 --- a/src/views/AdminSettings.vue +++ b/src/views/AdminSettings.vue @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later

- {{ configuredFieldsCountLabel.before }}{{ definitions.length }}{{ configuredFieldsCountLabel.after }} + {{ configuredFieldsCountLabel.before }}{{ definitions.length }}{{ configuredFieldsCountLabel.after }}
@@ -831,6 +831,7 @@ onBeforeUnmount(() => { &__hero-meta { display: flex; + align-items: center; justify-content: flex-end; min-width: 120px; padding: 12px 14px; @@ -844,10 +845,6 @@ onBeforeUnmount(() => { } span { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; font-size: 12px; color: var(--color-text-maxcontrast); text-align: center; From d783765880dd377ac7fa82f4e18c7f0e53d41f65 Mon Sep 17 00:00:00 2001 From: Vitor Mattos Date: Tue, 8 Sep 2026 21:21:11 -0300 Subject: [PATCH 3/3] Update src/utils/countLabel.ts Signed-off-by: Vitor Mattos --- src/utils/countLabel.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/utils/countLabel.ts b/src/utils/countLabel.ts index 1363708f..a86267f7 100644 --- a/src/utils/countLabel.ts +++ b/src/utils/countLabel.ts @@ -1,5 +1,4 @@ // SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors -// // SPDX-License-Identifier: AGPL-3.0-or-later export interface CountLabelParts {