Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
93e3ce1
test(email): add FieldDefinitionValidator test for email type
vitormattos Mar 21, 2026
09a7b32
test(email): add FieldValueService tests for email normalization
vitormattos Mar 21, 2026
9571736
test(email): add UserProfileFieldCheck tests for email operators
vitormattos Mar 21, 2026
647ab46
feat(email): add EMAIL enum case to FieldType
vitormattos Mar 21, 2026
d708819
feat(email): add email to ProfileFieldsType psalm-type
vitormattos Mar 21, 2026
4ea18d3
feat(email): support email in FieldDefinitionValidator type annotation
vitormattos Mar 21, 2026
b518320
feat(email): normalize and validate email field values
vitormattos Mar 21, 2026
0c28368
feat(email): add email to type union in ImportPayloadValidator
vitormattos Mar 21, 2026
46bd7da
feat(email): add email to type annotations in DataImportService
vitormattos Mar 21, 2026
517c5bf
feat(email): add email operators and contains bypass in workflow check
vitormattos Mar 21, 2026
8afce03
feat(email): add email to FieldType union type
vitormattos Mar 21, 2026
f9d4aa3
feat(email): return text operators for email type in workflow check
vitormattos Mar 21, 2026
1661445
feat(email): add Email option to field type selector in admin settings
vitormattos Mar 21, 2026
09977c7
feat(email): support email input type in personal settings
vitormattos Mar 21, 2026
40cb269
feat(email): add email field type support in AdminUserFieldsDialog
vitormattos Mar 21, 2026
fda9a36
test(email): add workflow check tests for email type operators
vitormattos Mar 21, 2026
a79a339
test(email): add Email type option test in AdminSettings spec
vitormattos Mar 21, 2026
adb56c5
test(email): add email field type render and helper-text tests
vitormattos Mar 21, 2026
26d482c
test(email): add email input type test in PersonalSettings spec
vitormattos Mar 21, 2026
840ccd4
chore(email): regenerate openapi.json with email type
vitormattos Mar 21, 2026
e2165cc
chore(email): regenerate openapi-full.json with email type
vitormattos Mar 21, 2026
f73d628
chore(email): regenerate openapi-administration.json with email type
vitormattos Mar 21, 2026
09bb229
chore(email): regenerate openapi.ts types with email type
vitormattos Mar 21, 2026
cd28d1e
chore(email): regenerate openapi-full.ts types with email type
vitormattos Mar 21, 2026
7707123
chore(email): regenerate openapi-administration.ts types with email type
vitormattos Mar 21, 2026
1dfcd79
docs(email): add admin proof screenshot for email field type
vitormattos Mar 21, 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
Binary file added img/screenshots/email-field-admin-proof.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions lib/Enum/FieldType.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ enum FieldType: string {
case BOOLEAN = 'boolean';
case DATE = 'date';
case URL = 'url';
case EMAIL = 'email';
case SELECT = 'select';
case MULTISELECT = 'multiselect';

Expand All @@ -28,6 +29,7 @@ public static function values(): array {
self::BOOLEAN->value,
self::DATE->value,
self::URL->value,
self::EMAIL->value,
self::SELECT->value,
self::MULTISELECT->value,
];
Expand Down
2 changes: 1 addition & 1 deletion lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
namespace OCA\ProfileFields;

/**
* @psalm-type ProfileFieldsType = 'text'|'number'|'boolean'|'date'|'url'|'select'|'multiselect'
* @psalm-type ProfileFieldsType = 'text'|'number'|'boolean'|'date'|'url'|'email'|'select'|'multiselect'
* @psalm-type ProfileFieldsVisibility = 'private'|'users'|'public'
* @psalm-type ProfileFieldsEditPolicy = 'admins'|'users'
* @psalm-type ProfileFieldsExposurePolicy = 'hidden'|'private'|'users'|'public'
Expand Down
6 changes: 3 additions & 3 deletions lib/Service/DataImportService.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ public function import(array $payload, bool $dryRun = false): array {
* @param list<array{
* field_key: non-empty-string,
* label: non-empty-string,
* type: 'text'|'number'|'boolean'|'date'|'url'|'select'|'multiselect',
* type: 'text'|'number'|'boolean'|'date'|'url'|'email'|'select'|'multiselect',
* edit_policy: 'admins'|'users',
* exposure_policy: 'hidden'|'private'|'users'|'public',
* sort_order: int,
Expand Down Expand Up @@ -148,7 +148,7 @@ private function collectValueSummary(array $values, array &$summary): void {
* @param list<array{
* field_key: non-empty-string,
* label: non-empty-string,
* type: 'text'|'number'|'boolean'|'date'|'url'|'select'|'multiselect',
* type: 'text'|'number'|'boolean'|'date'|'url'|'email'|'select'|'multiselect',
* edit_policy: 'admins'|'users',
* exposure_policy: 'hidden'|'private'|'users'|'public',
* sort_order: int,
Expand Down Expand Up @@ -248,7 +248,7 @@ private function persistValues(array $values, array $definitionsByFieldKey, arra
* @param array{
* field_key: non-empty-string,
* label: non-empty-string,
* type: 'text'|'number'|'boolean'|'date'|'url'|'select'|'multiselect',
* type: 'text'|'number'|'boolean'|'date'|'url'|'email'|'select'|'multiselect',
* edit_policy: 'admins'|'users',
* exposure_policy: 'hidden'|'private'|'users'|'public',
* sort_order: int,
Expand Down
2 changes: 1 addition & 1 deletion lib/Service/FieldDefinitionValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class FieldDefinitionValidator {
* @return array{
* field_key: non-empty-string,
* label: non-empty-string,
* type: 'text'|'number'|'boolean'|'date'|'url'|'select'|'multiselect',
* type: 'text'|'number'|'boolean'|'date'|'url'|'email'|'select'|'multiselect',
* edit_policy: 'admins'|'users',
* exposure_policy: 'hidden'|'private'|'users'|'public',
* sort_order: int,
Expand Down
24 changes: 21 additions & 3 deletions lib/Service/FieldValueService.php
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ public function normalizeValue(FieldDefinition $definition, array|string|int|flo
FieldType::BOOLEAN => $this->normalizeBooleanValue($rawValue),
FieldType::DATE => $this->normalizeDateValue($rawValue),
FieldType::URL => $this->normalizeUrlValue($rawValue),
FieldType::EMAIL => $this->normalizeEmailValue($rawValue),
FieldType::SELECT => $this->normalizeSelectValue($rawValue, $definition),
FieldType::MULTISELECT => $this->normalizeMultiSelectValue($rawValue, $definition),
};
Expand Down Expand Up @@ -367,6 +368,23 @@ private function normalizeUrlValue(array|string|int|float|bool $rawValue): array
return ['value' => $value];
}

/**
* @param array<string, mixed>|scalar $rawValue
* @return array{value: string}
*/
private function normalizeEmailValue(array|string|int|float|bool $rawValue): array {
if (!is_string($rawValue)) {
throw new InvalidArgumentException($this->l10n->t('Email fields require a valid email address.'));
}

$value = trim($rawValue);
if (filter_var($value, FILTER_VALIDATE_EMAIL) === false) {
throw new InvalidArgumentException($this->l10n->t('Email fields require a valid email address.'));
}

return ['value' => $value];
}

/**
* @param array<string, mixed> $value
*/
Expand Down Expand Up @@ -429,8 +447,8 @@ private function normalizeSearchValue(FieldDefinition $definition, string $opera
return $this->normalizeValue($definition, $rawValue);
}

if (FieldType::from($definition->getType()) !== FieldType::TEXT) {
throw new InvalidArgumentException($this->l10n->t('The "contains" operator is only available for text fields.'));
if (!in_array(FieldType::from($definition->getType()), [FieldType::TEXT, FieldType::EMAIL], true)) {
throw new InvalidArgumentException($this->l10n->t('The "contains" operator is only available for text and email fields.'));
}

$normalized = $this->normalizeValue($definition, $rawValue);
Expand All @@ -451,7 +469,7 @@ private function matchesSearchOperator(FieldType $fieldType, array $candidateVal
return ($candidateValue['value'] ?? null) === ($searchValue['value'] ?? null);
}

if ($fieldType !== FieldType::TEXT) {
if (!in_array($fieldType, [FieldType::TEXT, FieldType::EMAIL], true)) {
return false;
}

Expand Down
8 changes: 4 additions & 4 deletions lib/Service/ImportPayloadValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public function __construct(
* definitions: list<array{
* field_key: non-empty-string,
* label: non-empty-string,
* type: 'text'|'number'|'boolean'|'date'|'url'|'select'|'multiselect',
* type: 'text'|'number'|'boolean'|'date'|'url'|'email'|'select'|'multiselect',
* edit_policy: 'admins'|'users',
* exposure_policy: 'hidden'|'private'|'users'|'public',
* sort_order: int,
Expand Down Expand Up @@ -71,7 +71,7 @@ public function validate(array $payload): array {
* @return array<non-empty-string, array{
* field_key: non-empty-string,
* label: non-empty-string,
* type: 'text'|'number'|'boolean'|'date'|'url'|'select'|'multiselect',
* type: 'text'|'number'|'boolean'|'date'|'url'|'email'|'select'|'multiselect',
* edit_policy: 'admins'|'users',
* exposure_policy: 'hidden'|'private'|'users'|'public',
* sort_order: int,
Expand Down Expand Up @@ -119,7 +119,7 @@ private function validateDefinitions(array $definitions): array {
* @param array<non-empty-string, array{
* field_key: non-empty-string,
* label: non-empty-string,
* type: 'text'|'number'|'boolean'|'date'|'url'|'select'|'multiselect',
* type: 'text'|'number'|'boolean'|'date'|'url'|'email'|'select'|'multiselect',
* edit_policy: 'admins'|'users',
* exposure_policy: 'hidden'|'private'|'users'|'public',
* sort_order: int,
Expand Down Expand Up @@ -255,7 +255,7 @@ private function normalizeOptionalDate(array $payload, string $key, string $mess
* @param array{
* field_key: non-empty-string,
* label: non-empty-string,
* type: 'text'|'number'|'boolean'|'date'|'url'|'select'|'multiselect',
* type: 'text'|'number'|'boolean'|'date'|'url'|'email'|'select'|'multiselect',
* edit_policy: 'admins'|'users',
* exposure_policy: 'hidden'|'private'|'users'|'public',
* sort_order: int,
Expand Down
19 changes: 16 additions & 3 deletions lib/Workflow/UserProfileFieldCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ class UserProfileFieldCheck implements ICheck {
'contains',
'!contains',
];
private const EMAIL_OPERATORS = [
self::OPERATOR_IS_SET,
self::OPERATOR_IS_NOT_SET,
'is',
'!is',
'contains',
'!contains',
];
private const SELECT_OPERATORS = [
self::OPERATOR_IS_SET,
self::OPERATOR_IS_NOT_SET,
Expand Down Expand Up @@ -133,8 +141,11 @@ public function validateCheck($operator, $value) {
$fieldType = FieldType::from($definition->getType());
if ($fieldType === FieldType::MULTISELECT) {
$this->normalizeExpectedMultiSelectOperand($definition, $config['value']);
} elseif ($fieldType === FieldType::URL && ((string)$operator === 'contains' || (string)$operator === '!contains')) {
// URL contains search terms are plain substrings — no URL validation needed.
} elseif (
($fieldType === FieldType::URL || $fieldType === FieldType::EMAIL)
&& ((string)$operator === 'contains' || (string)$operator === '!contains')
) {
// URL/email contains search terms are plain substrings — no strict value validation needed.
} else {
$this->fieldValueService->normalizeValue($definition, $config['value']);
}
Expand Down Expand Up @@ -196,6 +207,7 @@ private function isOperatorSupported(FieldDefinition $definition, string $operat
FieldType::BOOLEAN => self::BOOLEAN_OPERATORS,
FieldType::DATE => self::DATE_OPERATORS,
FieldType::URL => self::URL_OPERATORS,
FieldType::EMAIL => self::EMAIL_OPERATORS,
FieldType::SELECT => self::SELECT_OPERATORS,
FieldType::MULTISELECT => self::SELECT_OPERATORS,
};
Expand Down Expand Up @@ -261,7 +273,7 @@ private function evaluate(FieldDefinition $definition, string $operator, string|
return $this->evaluateMultiSelectOperator($operator, $expectedValue, $actualValue);
}

if ($fieldType === FieldType::URL && ($operator === 'contains' || $operator === '!contains')) {
if (($fieldType === FieldType::URL || $fieldType === FieldType::EMAIL) && ($operator === 'contains' || $operator === '!contains')) {
return $this->evaluateTextOperator($operator, trim((string)$expectedRawValue), (string)$actualValue);
}

Expand All @@ -271,6 +283,7 @@ private function evaluate(FieldDefinition $definition, string $operator, string|
return match ($fieldType) {
FieldType::TEXT,
FieldType::URL,
FieldType::EMAIL,
FieldType::SELECT => $this->evaluateTextOperator($operator, (string)$expectedValue, (string)$actualValue),
FieldType::BOOLEAN => $this->evaluateBooleanOperator(
$operator,
Expand Down
1 change: 1 addition & 0 deletions openapi-administration.json
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@
"boolean",
"date",
"url",
"email",
"select",
"multiselect"
]
Expand Down
1 change: 1 addition & 0 deletions openapi-full.json
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@
"boolean",
"date",
"url",
"email",
"select",
"multiselect"
]
Expand Down
1 change: 1 addition & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@
"boolean",
"date",
"url",
"email",
"select",
"multiselect"
]
Expand Down
14 changes: 13 additions & 1 deletion src/components/AdminUserFieldsDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ export default defineComponent({
boolean: t('profile_fields', 'Choose either true or false.'),
date: t('profile_fields', 'Use a valid date in YYYY-MM-DD format.'),
url: t('profile_fields', 'Enter a valid URL (e.g. https://example.com).'),
email: t('profile_fields', 'Enter a valid email address (e.g. alice@example.com).'),
select: t('profile_fields', 'Choose one of the predefined options.'),
multiselect: t('profile_fields', 'Choose one or more predefined options.'),
} as Record<FieldType, string>)[type]
Expand All @@ -222,6 +223,7 @@ export default defineComponent({
boolean: t('profile_fields', 'Select true or false'),
date: t('profile_fields', 'Select a date'),
url: t('profile_fields', 'Enter a URL'),
email: t('profile_fields', 'Enter an email address'),
select: t('profile_fields', 'Select an option'),
multiselect: t('profile_fields', 'Select one or more options'),
} as Record<FieldType, string>)[type]
Expand All @@ -235,6 +237,7 @@ export default defineComponent({
boolean: 'text',
date: 'numeric',
url: 'url',
email: 'email',
select: 'text',
multiselect: 'text',
} as Record<FieldType, string>)[type]
Expand All @@ -245,6 +248,7 @@ export default defineComponent({
boolean: 'text',
date: 'date',
url: 'url',
email: 'email',
select: 'text',
multiselect: 'text',
} as Record<FieldType, string>)[type]
Expand Down Expand Up @@ -337,6 +341,13 @@ export default defineComponent({
}
}

if (field.definition.type === 'email') {
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailPattern.test(rawValue)) {
return t('profile_fields', '{fieldLabel} must be a valid email address.', { fieldLabel: field.definition.label })
}
}

if (field.definition.type === 'select') {
const options = field.definition.options ?? []
if (!options.includes(rawValue)) {
Expand Down Expand Up @@ -368,7 +379,7 @@ export default defineComponent({
const hasInvalidFields = computed(() => invalidFields.value.length > 0)

const helperTextForField = (field: AdminEditableField) => {
return field.definition.type === 'number' || field.definition.type === 'date' || field.definition.type === 'boolean' || field.definition.type === 'url'
return field.definition.type === 'number' || field.definition.type === 'date' || field.definition.type === 'boolean' || field.definition.type === 'url' || field.definition.type === 'email'
? descriptionForType(field.definition.type)
: ''
}
Expand Down Expand Up @@ -444,6 +455,7 @@ export default defineComponent({
'Boolean fields require true or false values.': t('profile_fields', '{fieldLabel} must be either true or false.', { fieldLabel: field.definition.label }),
'Date fields require a valid ISO-8601 date in YYYY-MM-DD format.': t('profile_fields', '{fieldLabel} must be a valid date in YYYY-MM-DD format.', { fieldLabel: field.definition.label }),
'URL fields require a valid URL.': t('profile_fields', '{fieldLabel} must be a valid URL.', { fieldLabel: field.definition.label }),
'Email fields require a valid email address.': t('profile_fields', '{fieldLabel} must be a valid email address.', { fieldLabel: field.definition.label }),
'current_visibility is not supported': t('profile_fields', 'The selected visibility is not supported.'),
}[message] ?? (message.includes('is not a valid option')
? t('profile_fields', '{fieldLabel}: invalid option selected.', { fieldLabel: field.definition.label })
Expand Down
15 changes: 15 additions & 0 deletions src/tests/components/AdminSettings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,4 +120,19 @@ describe('AdminSettings', () => {

expect(wrapper.text()).toContain('tr:URL')
})

it('offers the Email field type in the editor', async() => {
const wrapper = mount(AdminSettings, {
global: {
stubs: {
Draggable: defineComponent({ template: '<div><slot /></div>' }),
},
},
})

await flushPromises()
await wrapper.get('button').trigger('click')

expect(wrapper.text()).toContain('tr:Email')
})
})
43 changes: 43 additions & 0 deletions src/tests/components/AdminUserFieldsDialog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,17 @@ vi.mock('../../api', () => ({
active: true,
options: null,
},
{
id: 6,
field_key: 'work_email',
label: 'Work email',
type: 'email',
edit_policy: 'users',
exposure_policy: 'private',
sort_order: 3,
active: true,
options: null,
},
]),
listAdminUserValues: vi.fn().mockResolvedValue([
{
Expand Down Expand Up @@ -178,4 +189,36 @@ describe('AdminUserFieldsDialog', () => {
// helper-text is bound as an attribute through v-bind="$attrs"
expect(urlInput.attributes('helper-text')).toBeTruthy()
})

it('renders email fields with type=email input', async() => {
const wrapper = mount(AdminUserFieldsDialog, {
props: {
open: true,
userUid: 'alice',
userDisplayName: 'Alice',
},
})

await flushPromises()

const emailInput = wrapper.find('#profile-fields-user-dialog-value-6')
expect(emailInput.exists()).toBe(true)
expect(emailInput.attributes('type')).toBe('email')
})

it('shows email helper text for email fields', async() => {
const wrapper = mount(AdminUserFieldsDialog, {
props: {
open: true,
userUid: 'alice',
userDisplayName: 'Alice',
},
})

await flushPromises()

const emailInput = wrapper.find('#profile-fields-user-dialog-value-6')
expect(emailInput.exists()).toBe(true)
expect(emailInput.attributes('helper-text')).toBeTruthy()
})
})
Loading
Loading