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 {