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
b97dc17
test(url): add FieldDefinitionValidator test for URL type
vitormattos Mar 20, 2026
5a3b0e2
test(url): add FieldValueService tests for URL normalization
vitormattos Mar 20, 2026
1c2e086
test(url): add UserProfileFieldCheck tests for URL operators
vitormattos Mar 20, 2026
0b0bff5
feat(url): add URL enum case to FieldType
vitormattos Mar 20, 2026
427de2d
feat(url): add url to ProfileFieldsType psalm-type
vitormattos Mar 20, 2026
616e611
feat(url): support url in FieldDefinitionValidator type annotation
vitormattos Mar 20, 2026
baa52a3
feat(url): normalize and validate URL field values
vitormattos Mar 20, 2026
62e81fb
feat(url): add url to type union in ImportPayloadValidator
vitormattos Mar 20, 2026
604e651
feat(url): add url to type annotations in DataImportService
vitormattos Mar 20, 2026
0b5891f
feat(url): add URL operators and contains bypass in workflow check
vitormattos Mar 20, 2026
524231e
feat(url): add url to FieldType union type
vitormattos Mar 20, 2026
031cff3
feat(url): return text operators for URL type in workflow check
vitormattos Mar 20, 2026
575f76a
feat(url): add URL option to field type selector in admin settings
vitormattos Mar 20, 2026
26494d1
feat(url): support url input type in personal settings
vitormattos Mar 20, 2026
1d9d516
feat(url): add URL field type support in AdminUserFieldsDialog
vitormattos Mar 20, 2026
fb33b38
test(url): add workflow check tests for URL type operators
vitormattos Mar 20, 2026
31dab83
test(url): add URL type option test in AdminSettings spec
vitormattos Mar 20, 2026
e5e7a35
test(url): add URL field type render and helper-text tests
vitormattos Mar 20, 2026
d89c866
test(url): add URL input type test in PersonalSettings spec
vitormattos Mar 20, 2026
98584ce
chore(url): regenerate openapi.json with url type
vitormattos Mar 20, 2026
70f246a
chore(url): regenerate openapi-full.json with url type
vitormattos Mar 20, 2026
24b6798
chore(url): regenerate openapi-administration.json with url type
vitormattos Mar 20, 2026
9224642
chore(url): regenerate openapi.ts types with url type
vitormattos Mar 20, 2026
323ae09
chore(url): regenerate openapi-full.ts types with url type
vitormattos Mar 20, 2026
1c1d7a6
chore(url): regenerate openapi-administration.ts types with url type
vitormattos Mar 20, 2026
6d14253
docs(url): add admin proof screenshot for URL field type
vitormattos Mar 20, 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/url-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 @@ -14,6 +14,7 @@ enum FieldType: string {
case NUMBER = 'number';
case BOOLEAN = 'boolean';
case DATE = 'date';
case URL = 'url';
case SELECT = 'select';
case MULTISELECT = 'multiselect';

Expand All @@ -26,6 +27,7 @@ public static function values(): array {
self::NUMBER->value,
self::BOOLEAN->value,
self::DATE->value,
self::URL->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'|'select'|'multiselect'
* @psalm-type ProfileFieldsType = 'text'|'number'|'boolean'|'date'|'url'|'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'|'select'|'multiselect',
* type: 'text'|'number'|'boolean'|'date'|'url'|'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'|'select'|'multiselect',
* type: 'text'|'number'|'boolean'|'date'|'url'|'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'|'select'|'multiselect',
* type: 'text'|'number'|'boolean'|'date'|'url'|'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'|'select'|'multiselect',
* type: 'text'|'number'|'boolean'|'date'|'url'|'select'|'multiselect',
* edit_policy: 'admins'|'users',
* exposure_policy: 'hidden'|'private'|'users'|'public',
* sort_order: int,
Expand Down
18 changes: 18 additions & 0 deletions lib/Service/FieldValueService.php
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ public function normalizeValue(FieldDefinition $definition, array|string|int|flo
FieldType::NUMBER => $this->normalizeNumberValue($rawValue),
FieldType::BOOLEAN => $this->normalizeBooleanValue($rawValue),
FieldType::DATE => $this->normalizeDateValue($rawValue),
FieldType::URL => $this->normalizeUrlValue($rawValue),
FieldType::SELECT => $this->normalizeSelectValue($rawValue, $definition),
FieldType::MULTISELECT => $this->normalizeMultiSelectValue($rawValue, $definition),
};
Expand Down Expand Up @@ -349,6 +350,23 @@ private function normalizeDateValue(array|string|int|float|bool $rawValue): arra
return ['value' => $value];
}

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

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

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

/**
* @param array<string, mixed> $value
*/
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'|'select'|'multiselect',
* type: 'text'|'number'|'boolean'|'date'|'url'|'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'|'select'|'multiselect',
* type: 'text'|'number'|'boolean'|'date'|'url'|'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'|'select'|'multiselect',
* type: 'text'|'number'|'boolean'|'date'|'url'|'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'|'select'|'multiselect',
* type: 'text'|'number'|'boolean'|'date'|'url'|'select'|'multiselect',
* edit_policy: 'admins'|'users',
* exposure_policy: 'hidden'|'private'|'users'|'public',
* sort_order: int,
Expand Down
19 changes: 18 additions & 1 deletion lib/Workflow/UserProfileFieldCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ class UserProfileFieldCheck implements ICheck {
'is',
'!is',
];
private const URL_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 @@ -122,8 +130,11 @@ public function validateCheck($operator, $value) {

if ($this->operatorRequiresValue((string)$operator)) {
try {
if (FieldType::from($definition->getType()) === FieldType::MULTISELECT) {
$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.
} else {
$this->fieldValueService->normalizeValue($definition, $config['value']);
}
Expand Down Expand Up @@ -184,6 +195,7 @@ private function isOperatorSupported(FieldDefinition $definition, string $operat
FieldType::NUMBER => self::NUMBER_OPERATORS,
FieldType::BOOLEAN => self::BOOLEAN_OPERATORS,
FieldType::DATE => self::DATE_OPERATORS,
FieldType::URL => self::URL_OPERATORS,
FieldType::SELECT => self::SELECT_OPERATORS,
FieldType::MULTISELECT => self::SELECT_OPERATORS,
};
Expand Down Expand Up @@ -249,11 +261,16 @@ private function evaluate(FieldDefinition $definition, string $operator, string|
return $this->evaluateMultiSelectOperator($operator, $expectedValue, $actualValue);
}

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

$normalizedExpected = $this->fieldValueService->normalizeValue($definition, $expectedRawValue);
$expectedValue = $normalizedExpected['value'] ?? null;

return match ($fieldType) {
FieldType::TEXT,
FieldType::URL,
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 @@ -200,6 +200,7 @@
"number",
"boolean",
"date",
"url",
"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 @@ -275,6 +275,7 @@
"number",
"boolean",
"date",
"url",
"select",
"multiselect"
]
Expand Down
1 change: 1 addition & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@
"number",
"boolean",
"date",
"url",
"select",
"multiselect"
]
Expand Down
16 changes: 15 additions & 1 deletion src/components/AdminUserFieldsDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ export default defineComponent({
number: t('profile_fields', 'Only numeric values are accepted.'),
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).'),
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 @@ -220,6 +221,7 @@ export default defineComponent({
number: t('profile_fields', 'Enter a number'),
boolean: t('profile_fields', 'Select true or false'),
date: t('profile_fields', 'Select a date'),
url: t('profile_fields', 'Enter a URL'),
select: t('profile_fields', 'Select an option'),
multiselect: t('profile_fields', 'Select one or more options'),
} as Record<FieldType, string>)[type]
Expand All @@ -232,6 +234,7 @@ export default defineComponent({
number: 'decimal',
boolean: 'text',
date: 'numeric',
url: 'url',
select: 'text',
multiselect: 'text',
} as Record<FieldType, string>)[type]
Expand All @@ -241,6 +244,7 @@ export default defineComponent({
number: 'text',
boolean: 'text',
date: 'date',
url: 'url',
select: 'text',
multiselect: 'text',
} as Record<FieldType, string>)[type]
Expand Down Expand Up @@ -324,6 +328,15 @@ export default defineComponent({
}
}

if (field.definition.type === 'url') {
try {
// eslint-disable-next-line no-new
new URL(rawValue)
} catch {
return t('profile_fields', '{fieldLabel} must be a valid URL.', { fieldLabel: field.definition.label })
}
}

if (field.definition.type === 'select') {
const options = field.definition.options ?? []
if (!options.includes(rawValue)) {
Expand Down Expand Up @@ -355,7 +368,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'
return field.definition.type === 'number' || field.definition.type === 'date' || field.definition.type === 'boolean' || field.definition.type === 'url'
? descriptionForType(field.definition.type)
: ''
}
Expand Down Expand Up @@ -430,6 +443,7 @@ export default defineComponent({
'number fields expect a numeric value': t('profile_fields', '{fieldLabel} must be a numeric value.', { fieldLabel: field.definition.label }),
'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 }),
'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 @@ -105,4 +105,19 @@ describe('AdminSettings', () => {

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

it('offers the URL 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:URL')
})
})
47 changes: 47 additions & 0 deletions src/tests/components/AdminUserFieldsDialog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,17 @@ vi.mock('../../api', () => ({
active: true,
options: null,
},
{
id: 5,
field_key: 'website',
label: 'Website',
type: 'url',
edit_policy: 'users',
exposure_policy: 'private',
sort_order: 2,
active: true,
options: null,
},
]),
listAdminUserValues: vi.fn().mockResolvedValue([
{
Expand Down Expand Up @@ -131,4 +142,40 @@ describe('AdminUserFieldsDialog', () => {
expect(wrapper.text()).toContain('tr:True')
expect(wrapper.text()).toContain('tr:False')
})

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

await flushPromises()

const urlInput = wrapper.find('#profile-fields-user-dialog-value-5')
expect(urlInput.exists()).toBe(true)
expect(urlInput.attributes('type')).toBe('url')
})

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

await flushPromises()

// The helper text is passed via the :helper-text prop on NcInputField for the url field.
// Verify the URL field renders its description via a data-testid selector approach:
// the NcInputField mock renders with all bound attrs so helper-text appears as a DOM attribute.
const urlInput = wrapper.find('#profile-fields-user-dialog-value-5')
expect(urlInput.exists()).toBe(true)
// helper-text is bound as an attribute through v-bind="$attrs"
expect(urlInput.attributes('helper-text')).toBeTruthy()
})
})
Loading
Loading