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 5c1ecb36..3b7bd204 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[],
@@ -155,8 +155,8 @@ 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.
-const optionsCountLabel = computed(() => n('profile_fields', 'Option', 'Options', normalizedOptionCount.value, { count: normalizedOptionCount.value }))
+// 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 }))
@@ -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..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: {
@@ -135,4 +146,40 @@ describe('AdminSettings', () => {
expect(wrapper.text()).toContain('tr:Email')
})
-})
\ No newline at end of file
+
+ 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')
+ })
+
+ 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 713beaf3..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: {
@@ -76,10 +87,37 @@ 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')
})
+ 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
new file mode 100644
index 00000000..2ba2eaee
--- /dev/null
+++ b/src/tests/utils/countLabel.spec.ts
@@ -0,0 +1,50 @@
+// 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',
+ })
+ })
+
+ it('splits a label that ends with the count, as right-to-left languages may order it', () => {
+ expect(splitCountLabel(`حقول مكونة ${COUNT_PLACEHOLDER}`)).toEqual({
+ before: 'حقول مكونة ',
+ after: '',
+ })
+ })
+
+ it('splits a label that places the count in the middle', () => {
+ expect(splitCountLabel(`son ${COUNT_PLACEHOLDER} campos configurados`)).toEqual({
+ before: 'son ',
+ after: ' campos configurados',
+ })
+ })
+
+ it('renders the count in front of the label when the translation dropped the placeholder', () => {
+ expect(splitCountLabel('kolonky nastaveny')).toEqual({
+ before: '',
+ after: ' kolonky nastaveny',
+ })
+ })
+
+ it('keeps the label empty when the translation is empty', () => {
+ expect(splitCountLabel('')).toEqual({
+ before: '',
+ after: '',
+ })
+ })
+
+ it('splits on the first placeholder only', () => {
+ expect(splitCountLabel(`${COUNT_PLACEHOLDER} of ${COUNT_PLACEHOLDER}`)).toEqual({
+ before: '',
+ after: ` of ${COUNT_PLACEHOLDER}`,
+ })
+ })
+})
diff --git a/src/utils/countLabel.ts b/src/utils/countLabel.ts
new file mode 100644
index 00000000..a86267f7
--- /dev/null
+++ b/src/utils/countLabel.ts
@@ -0,0 +1,21 @@
+// SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+export interface CountLabelParts {
+ before: string
+ after: string
+}
+
+export const COUNT_PLACEHOLDER = '\uFFFC'
+
+export const splitCountLabel = (label: string): CountLabelParts => {
+ const placeholderIndex = label.indexOf(COUNT_PLACEHOLDER)
+ if (placeholderIndex === -1) {
+ return { before: '', after: label === '' ? '' : ` ${label}` }
+ }
+
+ return {
+ before: label.slice(0, placeholderIndex),
+ after: label.slice(placeholderIndex + COUNT_PLACEHOLDER.length),
+ }
+}
diff --git a/src/views/AdminSettings.vue b/src/views/AdminSettings.vue
index 90eb4211..8194c4c0 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,8 @@ onBeforeUnmount(() => {
&__hero-meta {
display: flex;
- flex-direction: column;
- align-items: flex-end;
- justify-content: center;
+ align-items: center;
+ justify-content: flex-end;
min-width: 120px;
padding: 12px 14px;
border-radius: 14px;
@@ -841,11 +841,13 @@ onBeforeUnmount(() => {
strong {
font-size: 32px;
line-height: 1;
+ color: var(--color-main-text);
}
span {
font-size: 12px;
color: var(--color-text-maxcontrast);
+ text-align: center;
}
}