diff --git a/lib/Service/FieldDefinitionService.php b/lib/Service/FieldDefinitionService.php index 8638c76..70aa7bd 100644 --- a/lib/Service/FieldDefinitionService.php +++ b/lib/Service/FieldDefinitionService.php @@ -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(); @@ -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); @@ -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']); @@ -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()); diff --git a/lib/Service/FieldValueService.php b/lib/Service/FieldValueService.php index fa70c2d..d00ab59 100644 --- a/lib/Service/FieldValueService.php +++ b/lib/Service/FieldValueService.php @@ -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) { diff --git a/playwright/e2e/profile-fields.spec.ts b/playwright/e2e/profile-fields.spec.ts index 2b532e6..a6018f0 100644 --- a/playwright/e2e/profile-fields.spec.ts +++ b/playwright/e2e/profile-fields.spec.ts @@ -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) }) @@ -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 { @@ -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) }) @@ -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) }) @@ -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) @@ -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) diff --git a/src/components/AdminUserFieldsDialog.vue b/src/components/AdminUserFieldsDialog.vue index 3a2314d..f5f1b9d 100644 --- a/src/components/AdminUserFieldsDialog.vue +++ b/src/components/AdminUserFieldsDialog.vue @@ -123,8 +123,9 @@ SPDX-License-Identifier: AGPL-3.0-or-later {{ t('profile_fields', 'Cancel') }} + - {{ isSavingAny ? t('profile_fields', 'Saving changes…') : t('profile_fields', 'Save changes') }} + {{ isSavingAny ? t('profile_fields', 'Saving changes\u00A0…') : t('profile_fields', 'Save changes') }} @@ -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(() => buildAdminEditableFields(definitions.value, userValues.value)) const isSavingAny = computed(() => savingIds.value.length > 0) const headerDescription = computed(() => { diff --git a/src/components/admin/AdminSelectOptionsSection.vue b/src/components/admin/AdminSelectOptionsSection.vue index 35e680f..5c1ecb3 100644 --- a/src/components/admin/AdminSelectOptionsSection.vue +++ b/src/components/admin/AdminSelectOptionsSection.vue @@ -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() @@ -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) => { diff --git a/src/tests/components/admin/AdminSelectOptionsSection.spec.ts b/src/tests/components/admin/AdminSelectOptionsSection.spec.ts index 15f4fd1..713beaf 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:Option') expect(wrapper.text()).toContain('tr:Add single option') }) diff --git a/src/views/AdminSettings.vue b/src/views/AdminSettings.vue index 5d0530e..90eb421 100644 --- a/src/views/AdminSettings.vue +++ b/src/views/AdminSettings.vue @@ -149,6 +149,8 @@ SPDX-License-Identifier: AGPL-3.0-or-later
+ + 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 @@ -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, @@ -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() @@ -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 { diff --git a/src/views/PersonalSettings.vue b/src/views/PersonalSettings.vue index 7a9e26f..64e8891 100644 --- a/src/views/PersonalSettings.vue +++ b/src/views/PersonalSettings.vue @@ -151,7 +151,8 @@ SPDX-License-Identifier: AGPL-3.0-or-later /> - {{ isSaving(field.definition.id) ? t('profile_fields', 'Saving changes…') : t('profile_fields', 'Save changes') }} + + {{ isSaving(field.definition.id) ? t('profile_fields', 'Saving changes\u00A0…') : t('profile_fields', 'Save changes') }}
diff --git a/src/workflow.ts b/src/workflow.ts index 34c925d..4c79c48 100644 --- a/src/workflow.ts +++ b/src/workflow.ts @@ -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) diff --git a/tests/php/Unit/Controller/FieldDefinitionApiControllerTest.php b/tests/php/Unit/Controller/FieldDefinitionApiControllerTest.php index 2369d76..ae8fca0 100644 --- a/tests/php/Unit/Controller/FieldDefinitionApiControllerTest.php +++ b/tests/php/Unit/Controller/FieldDefinitionApiControllerTest.php @@ -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', @@ -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 { diff --git a/tests/php/Unit/Service/FieldDefinitionServiceTest.php b/tests/php/Unit/Service/FieldDefinitionServiceTest.php index c0e4793..6c8657a 100644 --- a/tests/php/Unit/Service/FieldDefinitionServiceTest.php +++ b/tests/php/Unit/Service/FieldDefinitionServiceTest.php @@ -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', @@ -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', @@ -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',