Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions playwright/generate-screenshots.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
Expand Down
10 changes: 5 additions & 5 deletions src/components/admin/AdminSelectOptionsSection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later
<h4>{{ t('profile_fields', 'Options') }}</h4>
</div>
<div class="profile-fields-admin-options__meta">
<strong>{{ normalizedOptionCount }}</strong>
<span>{{ optionsCountLabel }}</span>
<span>{{ optionsCountLabel.before }}<strong>{{ normalizedOptionCount }}</strong>{{ optionsCountLabel.after }}</span>
</div>
</div>

Expand Down Expand Up @@ -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[],
Expand All @@ -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 }))

Expand Down Expand Up @@ -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;
Expand All @@ -335,6 +334,7 @@ const applyBulkOptions = async() => {
strong {
font-size: 14px;
line-height: 1;
color: var(--color-main-text);
}

span {
Expand Down
51 changes: 49 additions & 2 deletions src/tests/components/AdminSettings.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -15,9 +15,16 @@ Object.defineProperty(window, 'matchMedia', {
})),
})

const { pluralOverrides } = vi.hoisted(() => ({ pluralOverrides: new Map<string, string>() }))

vi.mock('@nextcloud/l10n', () => ({
n: (_app: string, singular: string, plural: string, count: number, parameters?: Record<string, string | number>) => {
const template = count === 1 ? singular : plural
const override = pluralOverrides.get(template)
if (override !== undefined) {
return override
}

if (parameters === undefined) {
return `tr:${template}`
}
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -135,4 +146,40 @@ 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: '<div><slot /></div>' }),
},
},
})

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: '<div><slot /></div>' }),
},
},
})

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')
})
})
42 changes: 40 additions & 2 deletions src/tests/components/admin/AdminSelectOptionsSection.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>() }))

vi.mock('@nextcloud/l10n', () => ({
n: (_app: string, singular: string, plural: string, count: number, parameters?: Record<string, string | number>) => {
const template = count === 1 ? singular : plural
const override = pluralOverrides.get(template)
if (override !== undefined) {
return override
}

if (parameters === undefined) {
return `tr:${template}`
}
Expand Down Expand Up @@ -56,6 +63,10 @@ const DraggableStub = defineComponent({
})

describe('AdminSelectOptionsSection', () => {
afterEach(() => {
pluralOverrides.clear()
})

it('renders translated headings and pluralized meta', () => {
const wrapper = mount(AdminSelectOptionsSection, {
props: {
Expand All @@ -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: {
Expand Down
50 changes: 50 additions & 0 deletions src/tests/utils/countLabel.spec.ts
Original file line number Diff line number Diff line change
@@ -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}`,
})
})
})
21 changes: 21 additions & 0 deletions src/utils/countLabel.ts
Original file line number Diff line number Diff line change
@@ -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),
}
}
14 changes: 8 additions & 6 deletions src/views/AdminSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later
</p>
</div>
<div class="profile-fields-admin__hero-meta">
<strong>{{ definitions.length }}</strong>
<span>{{ configuredFieldsCountLabel }}</span>
<span>{{ configuredFieldsCountLabel.before }}<strong>{{ definitions.length }}</strong>{{ configuredFieldsCountLabel.after }}</span>
</div>
</header>

Expand Down Expand Up @@ -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') },
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
}

Expand Down
Loading