Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e815b37
fix(l10n): adjust field definition service messages
vitormattos Apr 14, 2026
2f3bc37
fix(l10n): capitalize field value limit validation message
vitormattos Apr 14, 2026
4bbd840
fix(l10n): update admin settings status strings
vitormattos Apr 14, 2026
9d87a99
fix(l10n): adjust admin user dialog loading and saving labels
vitormattos Apr 14, 2026
14abeda
fix(l10n): adjust personal settings saving label
vitormattos Apr 14, 2026
6f5140b
fix(l10n): improve option count and plural strings
vitormattos Apr 14, 2026
2af7493
fix(l10n): update workflow loading placeholder spacing
vitormattos Apr 14, 2026
319c463
test(php): sync field definition service message expectations
vitormattos Apr 14, 2026
cbbbb4c
test(php): sync api controller validation message expectation
vitormattos Apr 14, 2026
2e31738
test(frontend): update option label expectation
vitormattos Apr 14, 2026
f3f2638
docs(l10n): clarify translator notes for technical placeholders
vitormattos Apr 14, 2026
f0d0ca2
docs(l10n): justify NBSP usage in admin user dialog strings
vitormattos Apr 14, 2026
bd5abb8
docs(l10n): add translator context for option placeholders
vitormattos Apr 14, 2026
8ccb3d4
docs(l10n): explain field key and NBSP translation context
vitormattos Apr 14, 2026
2d53ef7
docs(l10n): justify NBSP note in personal settings
vitormattos Apr 14, 2026
201a750
docs(l10n): justify NBSP note in workflow placeholder
vitormattos Apr 14, 2026
c743d1a
test(e2e): sync success toast expectations
vitormattos Apr 14, 2026
51be846
docs(l10n): clarify field key translator context scope
vitormattos Apr 14, 2026
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
14 changes: 9 additions & 5 deletions lib/Service/FieldDefinitionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ public function __construct(
public function create(array $definition): FieldDefinition {
$validated = $this->validator->validate($definition);
if ($this->fieldDefinitionMapper->findByFieldKey($validated['field_key']) !== null) {
throw new InvalidArgumentException($this->l10n->t('field_key already exists'));
// TRANSLATORS "field_key" is a technical API field identifier and should remain unchanged.
throw new InvalidArgumentException($this->l10n->t('"field_key" already exists'));
}

$createdAt = $this->parseImportedDate($definition['created_at'] ?? null) ?? new DateTime();
Expand All @@ -48,7 +49,8 @@ public function create(array $definition): FieldDefinition {
try {
$entity->setOptions(isset($validated['options']) ? json_encode($validated['options'], JSON_THROW_ON_ERROR) : null);
} catch (JsonException $e) {
throw new InvalidArgumentException($this->l10n->t('options could not be encoded: %s', [$e->getMessage()]), 0, $e);
// TRANSLATORS %s is a low-level JSON encoder error detail.
throw new InvalidArgumentException($this->l10n->t('Options could not be encoded: %s', [$e->getMessage()]), 0, $e);
}
$entity->setCreatedAt($createdAt);
$entity->setUpdatedAt($updatedAt);
Expand All @@ -62,11 +64,12 @@ public function create(array $definition): FieldDefinition {
public function update(FieldDefinition $existing, array $definition): FieldDefinition {
$validated = $this->validator->validate($definition + ['field_key' => $existing->getFieldKey()]);
if (($definition['field_key'] ?? $existing->getFieldKey()) !== $existing->getFieldKey()) {
throw new InvalidArgumentException($this->l10n->t('field_key cannot be changed'));
// TRANSLATORS "field_key" is a technical API field identifier and should remain unchanged.
throw new InvalidArgumentException($this->l10n->t('"field_key" cannot be changed'));
}

if ($validated['type'] !== $existing->getType() && $this->fieldValueMapper->hasValuesForFieldDefinitionId($existing->getId())) {
throw new InvalidArgumentException($this->l10n->t('type cannot be changed after values exist'));
throw new InvalidArgumentException($this->l10n->t('Type cannot be changed after values exist'));
}

$existing->setLabel($validated['label']);
Expand All @@ -78,7 +81,8 @@ public function update(FieldDefinition $existing, array $definition): FieldDefin
try {
$existing->setOptions(isset($validated['options']) ? json_encode($validated['options'], JSON_THROW_ON_ERROR) : null);
} catch (JsonException $e) {
throw new InvalidArgumentException($this->l10n->t('options could not be encoded: %s', [$e->getMessage()]), 0, $e);
// TRANSLATORS %s is a low-level JSON encoder error detail.
throw new InvalidArgumentException($this->l10n->t('Options could not be encoded: %s', [$e->getMessage()]), 0, $e);
}
$existing->setUpdatedAt($this->parseImportedDate($definition['updated_at'] ?? null) ?? new DateTime());

Expand Down
2 changes: 1 addition & 1 deletion lib/Service/FieldValueService.php
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ public function searchByDefinition(
): array {
if ($limit < 1 || $limit > self::SEARCH_MAX_LIMIT) {
// TRANSLATORS %d is the maximum supported search limit.
throw new InvalidArgumentException($this->l10n->t('limit must be between 1 and %d', [self::SEARCH_MAX_LIMIT]));
throw new InvalidArgumentException($this->l10n->t('Limit must be between 1 and %d', [self::SEARCH_MAX_LIMIT]));
}

if ($offset < 0) {
Expand Down
16 changes: 8 additions & 8 deletions playwright/e2e/profile-fields.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,18 +95,18 @@ test('admin can create, update, and delete a field definition', async ({ page })
await page.locator('#profile-fields-admin-label').fill(createdLabel)
await page.getByTestId('profile-fields-admin-save').click()

await expect(page.getByTestId('profile-fields-admin-success')).toContainText('Field created successfully.')
await expect(page.getByTestId('profile-fields-admin-success')).toContainText('Field created.')
await expect(page.getByTestId(`profile-fields-admin-definition-${fieldKey}`)).toBeVisible()

await page.locator('#profile-fields-admin-label').fill(updatedLabel)
await page.getByTestId('profile-fields-admin-save').click()

await expect(page.getByTestId('profile-fields-admin-success')).toContainText('Field updated successfully.')
await expect(page.getByTestId('profile-fields-admin-success')).toContainText('Field updated.')
await expect(page.getByTestId(`profile-fields-admin-definition-${fieldKey}`)).toContainText(updatedLabel)

await page.getByTestId('profile-fields-admin-delete').click()

await expect(page.getByTestId('profile-fields-admin-success')).toContainText('Field deleted successfully.')
await expect(page.getByTestId('profile-fields-admin-success')).toContainText('Field deleted.')
await expect(page.getByTestId(`profile-fields-admin-definition-${fieldKey}`)).toHaveCount(0)
await deleteDefinitionByFieldKey(page.request, fieldKey)
})
Expand Down Expand Up @@ -152,7 +152,7 @@ test('admin uses a modal editor on compact layout', async ({ page }) => {
await createDialog.locator('#profile-fields-admin-label').fill(createdLabel)
await createDialog.getByTestId('profile-fields-admin-save').click()

await expect(page.getByTestId('profile-fields-admin-success')).toContainText('Field created successfully.')
await expect(page.getByTestId('profile-fields-admin-success')).toContainText('Field created.')
await expect(page.getByTestId(`profile-fields-admin-definition-${createdFieldKey}`)).toBeVisible()
await expect(createDialog).toBeHidden()
} finally {
Expand Down Expand Up @@ -301,7 +301,7 @@ test('admin gets an initial select option row and can remove empty rows by keybo
await expect(page.getByTestId('profile-fields-admin-option-handle-0')).toBeVisible()

await page.getByTestId('profile-fields-admin-save').click()
await expect(page.getByTestId('profile-fields-admin-success')).toContainText('Field created successfully.')
await expect(page.getByTestId('profile-fields-admin-success')).toContainText('Field created.')

await deleteDefinitionByFieldKey(page.request, fieldKey)
})
Expand Down Expand Up @@ -336,7 +336,7 @@ test('admin can bulk add select options from multiple lines', async ({ page }) =
}

await page.getByTestId('profile-fields-admin-save').click()
await expect(page.getByTestId('profile-fields-admin-success')).toContainText('Field created successfully.')
await expect(page.getByTestId('profile-fields-admin-success')).toContainText('Field created.')

await deleteDefinitionByFieldKey(page.request, fieldKey)
})
Expand Down Expand Up @@ -400,7 +400,7 @@ test('admin reuses the empty select option row on repeated Enter', async ({ page
await expect(page.getByTestId('profile-fields-admin-option-row-4')).toHaveCount(0)

await page.getByTestId('profile-fields-admin-save').click()
await expect(page.getByTestId('profile-fields-admin-success')).toContainText('Field updated successfully.')
await expect(page.getByTestId('profile-fields-admin-success')).toContainText('Field updated.')

await page.reload()
await openSelectDefinitionEditor(page, fieldKey, label)
Expand Down Expand Up @@ -451,7 +451,7 @@ test('admin can reorder select options from the handle menu and drag handle', as
await expect(optionInput(page, 3)).toHaveValue('Gamma')

await page.getByTestId('profile-fields-admin-save').click()
await expect(page.getByTestId('profile-fields-admin-success')).toContainText('Field updated successfully.')
await expect(page.getByTestId('profile-fields-admin-success')).toContainText('Field updated.')

await page.reload()
await openSelectDefinitionEditor(page, fieldKey, label)
Expand Down
6 changes: 4 additions & 2 deletions src/components/AdminUserFieldsDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,9 @@ SPDX-License-Identifier: AGPL-3.0-or-later
<NcButton @click="closeDialog">
{{ t('profile_fields', 'Cancel') }}
</NcButton>
<!-- TRANSLATORS "\u00A0" keeps the ellipsis attached to the previous word for correct typography and avoids awkward line breaks. -->
<NcButton variant="primary" :disabled="!hasPendingChanges || hasInvalidFields || isSavingAny || isLoading" @click="saveAllFields">
{{ isSavingAny ? t('profile_fields', 'Saving changes…') : t('profile_fields', 'Save changes') }}
{{ isSavingAny ? t('profile_fields', 'Saving changes\u00A0…') : t('profile_fields', 'Save changes') }}
</NcButton>
</template>
</NcDialog>
Expand Down Expand Up @@ -184,7 +185,8 @@ export default defineComponent({

const headerUserName = computed(() => props.userDisplayName.trim() !== '' ? props.userDisplayName : props.userUid)
const visibilityFieldLabel = t('profile_fields', 'Who can view this field value')
const loadingMessage = computed(() => t('profile_fields', 'Loading profile fields for {userUid}…', { userUid: props.userUid }))
// TRANSLATORS "{userUid}" is a technical account identifier (not the display name). "\u00A0" keeps the ellipsis attached to the previous word and avoids awkward line breaks.
const loadingMessage = computed(() => t('profile_fields', 'Loading profile fields for {userUid}\u00A0…', { userUid: props.userUid }))
const editableFields = computed<AdminEditableField[]>(() => buildAdminEditableFields(definitions.value, userValues.value))
const isSavingAny = computed(() => savingIds.value.length > 0)
const headerDescription = computed(() => {
Expand Down
9 changes: 7 additions & 2 deletions src/components/admin/AdminSelectOptionsSection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,10 @@ 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)
const optionsCountLabel = computed(() => n('profile_fields', 'option', 'options', normalizedOptionCount.value, { count: normalizedOptionCount.value }))
const bulkOptionsSummary = computed(() => n('profile_fields', '1 option ready.', '{count} options ready.', bulkOptionValues.value.length, { count: bulkOptionValues.value.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 "{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 }))

const duplicateOptionIndices = computed(() => {
const seen = new Map<string, number>()
Expand Down Expand Up @@ -184,8 +186,11 @@ const hasOptionValue = (index: number) => options.value[index]?.value.trim() !==
const canMoveOptionUp = (index: number) => index > 0
const canMoveOptionDown = (index: number) => index < options.value.length - 1
const isOptionDuplicate = (index: number) => duplicateOptionIndices.value.has(index)
// TRANSLATORS "{optionValue}" is the visible text of one selectable option.
const reorderOptionLabel = (optionValue: string) => t('profile_fields', 'Reorder option {optionValue}', { optionValue })
// TRANSLATORS "{position}" is a 1-based option index shown as placeholder text.
const optionPlaceholder = (position: number) => t('profile_fields', 'Option {position}', { position })
// TRANSLATORS "{optionValue}" is the visible text of one selectable option.
const removeOptionLabel = (optionValue: string) => t('profile_fields', 'Remove option {optionValue}', { optionValue })

const focusOptionInput = async(index: number) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ describe('AdminSelectOptionsSection', () => {
})

expect(wrapper.text()).toContain('tr:Options')
expect(wrapper.text()).toContain('tr:option')
expect(wrapper.text()).toContain('tr:Option')
expect(wrapper.text()).toContain('tr:Add single option')
})

Expand Down
11 changes: 7 additions & 4 deletions src/views/AdminSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ SPDX-License-Identifier: AGPL-3.0-or-later

<div class="profile-fields-admin__grid profile-fields-admin__grid--identity">
<div class="profile-fields-admin__field">
<!-- TRANSLATORS "Field key" means a stable technical identifier (API key), not a keyboard key. This context applies to both label occurrences below. -->
<!-- TRANSLATORS "APIs" and "integrations" refer to technical systems and external tools. -->
<label for="profile-fields-admin-field-key">{{ t('profile_fields', 'Field key') }}</label>
<NcInputField
id="profile-fields-admin-field-key"
Expand Down Expand Up @@ -397,7 +399,8 @@ const editorEmptyState = computed(() => sortedDefinitions.value.length === 0
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 }))
const saveActionLabel = computed(() => isSaving.value ? t('profile_fields', 'Saving changes…') : (isEditing.value ? t('profile_fields', 'Save changes') : t('profile_fields', 'Create field')))
// 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 })
const actionsForLabel = (label: string) => t('profile_fields', 'Actions for {label}', { label })
const toggleDefinitionActiveLabel = (definition: FieldDefinition) => definition.active
Expand Down Expand Up @@ -628,7 +631,7 @@ const persistDefinition = async() => {
selectedId.value = created.id
populateForm(created)
markJustSaved(created.id)
setSuccessMessage(t('profile_fields', 'Field created successfully.'))
setSuccessMessage(t('profile_fields', 'Field created.'))
} else {
const updated = await updateDefinition(selectedDefinition.value.id, {
label: payload.label,
Expand All @@ -642,7 +645,7 @@ const persistDefinition = async() => {
replaceDefinitionInState(updated)
populateForm(updated)
markJustSaved(updated.id)
setSuccessMessage(t('profile_fields', 'Field updated successfully.'))
setSuccessMessage(t('profile_fields', 'Field updated.'))
}
if (isCompactLayout.value) {
closeEditor()
Expand All @@ -666,7 +669,7 @@ const removeDefinition = async() => {
removeDefinitionFromState(selectedDefinition.value.id)
isCreatingNew.value = false
resetForm()
setSuccessMessage(t('profile_fields', 'Field deleted successfully.'))
setSuccessMessage(t('profile_fields', 'Field deleted.'))
} catch (error: any) {
errorMessage.value = error?.response?.data?.ocs?.data?.message ?? error?.message ?? t('profile_fields', 'Could not delete this field. Please try again.')
} finally {
Expand Down
3 changes: 2 additions & 1 deletion src/views/PersonalSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,8 @@ SPDX-License-Identifier: AGPL-3.0-or-later
/>

<NcButton variant="primary" :disabled="isSaving(field.definition.id) || !hasFieldChanges(field)" @click="saveField(field)">
{{ isSaving(field.definition.id) ? t('profile_fields', 'Saving changes…') : t('profile_fields', 'Save changes') }}
<!-- TRANSLATORS "\u00A0" keeps the ellipsis attached to the previous word for correct typography and avoids awkward line breaks. -->
{{ isSaving(field.definition.id) ? t('profile_fields', 'Saving changes\u00A0…') : t('profile_fields', 'Save changes') }}
</NcButton>
</div>

Expand Down
3 changes: 2 additions & 1 deletion src/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,8 +535,9 @@ class WorkflowProfileFieldElement extends HTMLElement {

const placeholder = document.createElement('option')
placeholder.value = ''
// TRANSLATORS "\u00A0" keeps the ellipsis attached to the previous word for correct typography and avoids awkward line breaks.
placeholder.textContent = definitions.length === 0
? t('profile_fields', 'Loading profile fields…')
? t('profile_fields', 'Loading profile fields\u00A0…')
: t('profile_fields', 'Choose a profile field')
fieldSelect.append(placeholder)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ public function testCreateSelectFieldForwardsOptions(): void {
public function testCreateReturnsBadRequestOnValidationFailure(): void {
$this->service->expects($this->once())
->method('create')
->willThrowException(new InvalidArgumentException('field_key already exists'));
->willThrowException(new InvalidArgumentException('"field_key" already exists'));

$response = $this->controller->create(
'cpf',
Expand All @@ -128,7 +128,7 @@ public function testCreateReturnsBadRequestOnValidationFailure(): void {
);

$this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus());
$this->assertSame(['message' => 'field_key already exists'], $response->getData());
$this->assertSame(['message' => '"field_key" already exists'], $response->getData());
}

public function testUpdateSelectFieldForwardsOptions(): void {
Expand Down
6 changes: 3 additions & 3 deletions tests/php/Unit/Service/FieldDefinitionServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public function testCreateRejectsDuplicatedFieldKey(): void {
->willReturn(new FieldDefinition());

$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('field_key already exists');
$this->expectExceptionMessage('"field_key" already exists');

$this->service->create([
'field_key' => 'cpf',
Expand Down Expand Up @@ -127,7 +127,7 @@ public function testUpdateRejectsFieldKeyRename(): void {
$existing->setExposurePolicy(FieldExposurePolicy::PRIVATE->value);

$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('field_key cannot be changed');
$this->expectExceptionMessage('"field_key" cannot be changed');

$this->service->update($existing, [
'field_key' => 'cpf_new',
Expand All @@ -150,7 +150,7 @@ public function testUpdateRejectsTypeChangeWhenValuesExist(): void {
->willReturn(true);

$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('type cannot be changed after values exist');
$this->expectExceptionMessage('Type cannot be changed after values exist');

$this->service->update($existing, [
'label' => 'CPF',
Expand Down
Loading