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; } }