diff --git a/img/screenshots/url-field-admin-proof.png b/img/screenshots/url-field-admin-proof.png new file mode 100644 index 0000000..47e2c3d Binary files /dev/null and b/img/screenshots/url-field-admin-proof.png differ diff --git a/lib/Enum/FieldType.php b/lib/Enum/FieldType.php index 2713a0e..9dd2a8b 100644 --- a/lib/Enum/FieldType.php +++ b/lib/Enum/FieldType.php @@ -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'; @@ -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, ]; diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 832a05a..89699bc 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -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' diff --git a/lib/Service/DataImportService.php b/lib/Service/DataImportService.php index b915d7b..e7cd8a2 100644 --- a/lib/Service/DataImportService.php +++ b/lib/Service/DataImportService.php @@ -69,7 +69,7 @@ public function import(array $payload, bool $dryRun = false): array { * @param list $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), }; @@ -349,6 +350,23 @@ private function normalizeDateValue(array|string|int|float|bool $rawValue): arra return ['value' => $value]; } + /** + * @param array|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 $value */ diff --git a/lib/Service/ImportPayloadValidator.php b/lib/Service/ImportPayloadValidator.php index 3f2837b..0186433 100644 --- a/lib/Service/ImportPayloadValidator.php +++ b/lib/Service/ImportPayloadValidator.php @@ -32,7 +32,7 @@ public function __construct( * definitions: listoperatorRequiresValue((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']); } @@ -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, }; @@ -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, diff --git a/openapi-administration.json b/openapi-administration.json index f0c947d..39c6287 100644 --- a/openapi-administration.json +++ b/openapi-administration.json @@ -200,6 +200,7 @@ "number", "boolean", "date", + "url", "select", "multiselect" ] diff --git a/openapi-full.json b/openapi-full.json index 312f374..d7246b7 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -275,6 +275,7 @@ "number", "boolean", "date", + "url", "select", "multiselect" ] diff --git a/openapi.json b/openapi.json index 415c600..6686e13 100644 --- a/openapi.json +++ b/openapi.json @@ -146,6 +146,7 @@ "number", "boolean", "date", + "url", "select", "multiselect" ] diff --git a/src/components/AdminUserFieldsDialog.vue b/src/components/AdminUserFieldsDialog.vue index 4e7ec25..e4c9be6 100644 --- a/src/components/AdminUserFieldsDialog.vue +++ b/src/components/AdminUserFieldsDialog.vue @@ -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)[type] @@ -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)[type] @@ -232,6 +234,7 @@ export default defineComponent({ number: 'decimal', boolean: 'text', date: 'numeric', + url: 'url', select: 'text', multiselect: 'text', } as Record)[type] @@ -241,6 +244,7 @@ export default defineComponent({ number: 'text', boolean: 'text', date: 'date', + url: 'url', select: 'text', multiselect: 'text', } as Record)[type] @@ -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)) { @@ -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) : '' } @@ -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 }) diff --git a/src/tests/components/AdminSettings.spec.ts b/src/tests/components/AdminSettings.spec.ts index 4a2c873..5a5c1b9 100644 --- a/src/tests/components/AdminSettings.spec.ts +++ b/src/tests/components/AdminSettings.spec.ts @@ -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: '
' }), + }, + }, + }) + + await flushPromises() + await wrapper.get('button').trigger('click') + + expect(wrapper.text()).toContain('tr:URL') + }) }) \ No newline at end of file diff --git a/src/tests/components/AdminUserFieldsDialog.spec.ts b/src/tests/components/AdminUserFieldsDialog.spec.ts index 95682ae..384ef0a 100644 --- a/src/tests/components/AdminUserFieldsDialog.spec.ts +++ b/src/tests/components/AdminUserFieldsDialog.spec.ts @@ -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([ { @@ -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() + }) }) \ No newline at end of file diff --git a/src/tests/components/PersonalSettings.spec.ts b/src/tests/components/PersonalSettings.spec.ts index 3bebbba..364c6d3 100644 --- a/src/tests/components/PersonalSettings.spec.ts +++ b/src/tests/components/PersonalSettings.spec.ts @@ -64,6 +64,29 @@ vi.mock('../../api', () => ({ }, can_edit: true, }, + { + definition: { + id: 5, + field_key: 'website', + label: 'Website', + type: 'url', + edit_policy: 'users', + exposure_policy: 'private', + sort_order: 2, + active: true, + options: null, + }, + value: { + id: 12, + field_definition_id: 5, + user_uid: 'alice', + value: { value: 'https://example.com' }, + current_visibility: 'private', + updated_by_uid: 'alice', + updated_at: '2026-03-20T12:00:00+00:00', + }, + can_edit: true, + }, ]), upsertOwnValue: vi.fn(), })) @@ -134,4 +157,20 @@ describe('PersonalSettings', () => { expect(wrapper.text()).toContain('tr:True') expect(wrapper.text()).toContain('tr:False') }) + + it('renders url fields with type=url input', async() => { + const wrapper = mount(PersonalSettings, { + global: { + stubs: { + Teleport: true, + }, + }, + }) + + await flushPromises() + + const input = wrapper.find('[data-testid="profile-fields-personal-input-website"]') + expect(input.exists()).toBe(true) + expect(input.attributes('type')).toBe('url') + }) }) \ No newline at end of file diff --git a/src/tests/utils/workflowProfileFieldCheck.spec.ts b/src/tests/utils/workflowProfileFieldCheck.spec.ts index 22fd719..691b460 100644 --- a/src/tests/utils/workflowProfileFieldCheck.spec.ts +++ b/src/tests/utils/workflowProfileFieldCheck.spec.ts @@ -15,6 +15,7 @@ const definitions = [ { field_key: 'score', label: 'Score', type: 'number', active: true }, { field_key: 'start_date', label: 'Start date', type: 'date', active: true }, { field_key: 'is_manager', label: 'Is manager', type: 'boolean', active: true }, + { field_key: 'website', label: 'Website', type: 'url', active: true }, ] as const describe('workflowProfileFieldCheck', () => { @@ -74,4 +75,19 @@ describe('workflowProfileFieldCheck', () => { expect(isWorkflowOperatorSupported('contains', serializeWorkflowCheckValue({ field_key: 'score', value: '9' }), definitions)).toBe(false) expect(isWorkflowOperatorSupported('greater', serializeWorkflowCheckValue({ field_key: 'score', value: '9' }), definitions)).toBe(true) }) + + it('returns text-style operators for url definitions', () => { + expect(getWorkflowOperatorKeys(serializeWorkflowCheckValue({ field_key: 'website', value: 'https://example.com' }), definitions)).toEqual([ + 'is-set', + '!is-set', + 'is', + '!is', + 'contains', + '!contains', + ]) + }) + + it('accepts contains operator for url field', () => { + expect(isWorkflowOperatorSupported('contains', serializeWorkflowCheckValue({ field_key: 'website', value: 'example.com' }), definitions)).toBe(true) + }) }) diff --git a/src/types/index.ts b/src/types/index.ts index b1c460c..a7ce545 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -19,7 +19,7 @@ type ApiOperationRequestBody = TOperation extends { type ApiRequestJsonBody = ApiJsonBody> -export type FieldType = ApiComponents['schemas']['Type'] | 'multiselect' | 'date' | 'boolean' +export type FieldType = ApiComponents['schemas']['Type'] | 'multiselect' | 'date' | 'boolean' | 'url' export type FieldVisibility = ApiComponents['schemas']['Visibility'] export type FieldEditPolicy = 'admins' | 'users' export type FieldExposurePolicy = 'hidden' | FieldVisibility diff --git a/src/types/openapi/openapi-administration.ts b/src/types/openapi/openapi-administration.ts index ad1fcaa..3093aa6 100644 --- a/src/types/openapi/openapi-administration.ts +++ b/src/types/openapi/openapi-administration.ts @@ -193,7 +193,7 @@ export type components = { }; }; /** @enum {string} */ - Type: "text" | "number" | "boolean" | "date" | "select" | "multiselect"; + Type: "text" | "number" | "boolean" | "date" | "url" | "select" | "multiselect"; ValuePayload: { value: Record; }; diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 74a1eb3..41baa38 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -276,7 +276,7 @@ export type components = { }; }; /** @enum {string} */ - Type: "text" | "number" | "boolean" | "date" | "select" | "multiselect"; + Type: "text" | "number" | "boolean" | "date" | "url" | "select" | "multiselect"; ValuePayload: { value: Record; }; diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index c87e0bb..10e19d1 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -100,7 +100,7 @@ export type components = { itemsperpage?: string; }; /** @enum {string} */ - Type: "text" | "number" | "boolean" | "date" | "select" | "multiselect"; + Type: "text" | "number" | "boolean" | "date" | "url" | "select" | "multiselect"; ValuePayload: { value: Record; }; diff --git a/src/utils/workflowProfileFieldCheck.ts b/src/utils/workflowProfileFieldCheck.ts index 6937721..9e6c0ed 100644 --- a/src/utils/workflowProfileFieldCheck.ts +++ b/src/utils/workflowProfileFieldCheck.ts @@ -68,6 +68,8 @@ export const getWorkflowOperatorKeys = (rawValue: string | null | undefined, def ? [...numberOperatorKeys] : definition.type === 'boolean' ? [...booleanOperatorKeys] + : definition.type === 'url' + ? [...textOperatorKeys] : [...textOperatorKeys] } diff --git a/src/views/AdminSettings.vue b/src/views/AdminSettings.vue index e714132..78193a2 100644 --- a/src/views/AdminSettings.vue +++ b/src/views/AdminSettings.vue @@ -289,6 +289,7 @@ const fieldTypeOptions: Array<{ value: FieldType, label: string }> = [ { value: 'number', label: t('profile_fields', 'Number') }, { value: 'boolean', label: t('profile_fields', 'Boolean') }, { value: 'date', label: t('profile_fields', 'Date') }, + { value: 'url', label: t('profile_fields', 'URL') }, { value: 'select', label: t('profile_fields', 'Select') }, { value: 'multiselect', label: t('profile_fields', 'Multiselect') }, ] diff --git a/src/views/PersonalSettings.vue b/src/views/PersonalSettings.vue index 9e45480..9194c12 100644 --- a/src/views/PersonalSettings.vue +++ b/src/views/PersonalSettings.vue @@ -268,16 +268,17 @@ const managedByAdminAriaLabel = (fieldLabel: string) => t('profile_fields', '{fi const fieldInputId = (fieldId: number) => `profile-fields-personal-value-${fieldId}` -const componentInputTypesByType: Record = { +const componentInputTypesByType: Record = { text: 'text', number: 'number', boolean: 'text', date: 'date', + url: 'url', select: 'text', multiselect: 'text', } -const componentInputTypeForType = (type: FieldType): 'text' | 'number' | 'date' => { +const componentInputTypeForType = (type: FieldType): 'text' | 'number' | 'date' | 'url' => { return componentInputTypesByType[type] } @@ -453,7 +454,7 @@ const canAutosaveField = (field: EditableField) => { return field.definition.type === 'multiselect' } - if (field.definition.type === 'text' || field.definition.type === 'select' || field.definition.type === 'date' || field.definition.type === 'boolean') { + if (field.definition.type === 'text' || field.definition.type === 'select' || field.definition.type === 'date' || field.definition.type === 'boolean' || field.definition.type === 'url') { return true } diff --git a/tests/php/Unit/Service/FieldDefinitionValidatorTest.php b/tests/php/Unit/Service/FieldDefinitionValidatorTest.php index 95dfe66..867e4d6 100644 --- a/tests/php/Unit/Service/FieldDefinitionValidatorTest.php +++ b/tests/php/Unit/Service/FieldDefinitionValidatorTest.php @@ -130,6 +130,17 @@ public function testValidateBooleanFieldDefinition(): void { $this->assertNull($validated['options']); } + public function testValidateUrlFieldDefinition(): void { + $validated = $this->validator->validate([ + 'field_key' => 'website', + 'label' => 'Website', + 'type' => FieldType::URL->value, + ]); + + $this->assertSame(FieldType::URL->value, $validated['type']); + $this->assertNull($validated['options']); + } + public function testRejectMultiSelectWithNoOptions(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('multiselect fields require at least one option'); diff --git a/tests/php/Unit/Service/FieldValueServiceTest.php b/tests/php/Unit/Service/FieldValueServiceTest.php index 98348ab..481c53b 100644 --- a/tests/php/Unit/Service/FieldValueServiceTest.php +++ b/tests/php/Unit/Service/FieldValueServiceTest.php @@ -81,6 +81,40 @@ public function testNormalizeBooleanValueRejectsInvalidValue(): void { $this->service->normalizeValue($definition, 'yes'); } + public function testNormalizeUrlValueAcceptsValidUrl(): void { + $definition = $this->buildDefinition(FieldType::URL->value); + + $normalized = $this->service->normalizeValue($definition, 'https://example.com'); + + $this->assertSame(['value' => 'https://example.com'], $normalized); + } + + public function testNormalizeUrlValueTrimsWhitespace(): void { + $definition = $this->buildDefinition(FieldType::URL->value); + + $normalized = $this->service->normalizeValue($definition, ' https://example.com '); + + $this->assertSame(['value' => 'https://example.com'], $normalized); + } + + public function testNormalizeUrlValueRejectsNonUrl(): void { + $definition = $this->buildDefinition(FieldType::URL->value); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('URL fields require a valid URL.'); + + $this->service->normalizeValue($definition, 'not-a-url'); + } + + public function testNormalizeUrlValueRejectsArray(): void { + $definition = $this->buildDefinition(FieldType::URL->value); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('URL fields require a valid URL.'); + + $this->service->normalizeValue($definition, ['https://example.com']); + } + public function testNormalizeMissingValueAsNull(): void { $definition = $this->buildDefinition(FieldType::TEXT->value); diff --git a/tests/php/Unit/Workflow/UserProfileFieldCheckTest.php b/tests/php/Unit/Workflow/UserProfileFieldCheckTest.php index 37aaf2d..e1867f1 100644 --- a/tests/php/Unit/Workflow/UserProfileFieldCheckTest.php +++ b/tests/php/Unit/Workflow/UserProfileFieldCheckTest.php @@ -193,6 +193,35 @@ public function testValidateCheckRejectsContainsForBooleanField(): void { $this->check->validateCheck('contains', $this->encodeConfig('is_manager', true)); } + public function testExecuteCheckMatchesUrlContains(): void { + $definition = $this->buildDefinition(8, 'website', FieldType::URL->value); + $value = $this->buildStoredValue(8, 'alice', '{"value":"https://example.com/page"}'); + + $this->fieldDefinitionService->expects($this->once()) + ->method('findByFieldKey') + ->with('website') + ->willReturn($definition); + $this->fieldValueMapper->expects($this->once()) + ->method('findByFieldDefinitionIdAndUserUid') + ->with(8, 'alice') + ->willReturn($value); + + $this->userSession->method('getUser')->willReturn($this->buildUser('alice')); + + $this->assertTrue($this->check->executeCheck('contains', $this->encodeConfig('website', 'example.com'))); + } + + public function testValidateCheckAcceptsContainsForUrlField(): void { + $this->fieldDefinitionService->expects($this->once()) + ->method('findByFieldKey') + ->with('website') + ->willReturn($this->buildDefinition(8, 'website', FieldType::URL->value)); + + // Should not throw + $this->check->validateCheck('contains', $this->encodeConfig('website', 'example.com')); + $this->addToAssertionCount(1); + } + public function testExecuteCheckTreatsMissingValueAsNotSet(): void { $definition = $this->buildDefinition(7, 'region', FieldType::TEXT->value);