diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 0dbdfce034..640a9854ed 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -9,6 +9,7 @@ use OC\App\CompareVersion; use OCA\Contacts\AppInfo\Application; +use OCA\Contacts\Service\CustomPropertiesService; use OCA\Contacts\Service\GroupSharingService; use OCA\Contacts\Service\SocialApiService; use OCP\App\IAppManager; @@ -33,6 +34,7 @@ public function __construct( private IAppManager $appManager, private CompareVersion $compareVersion, private GroupSharingService $groupSharingService, + private CustomPropertiesService $customPropertiesService, ) { parent::__construct(Application::APP_ID, $request); } @@ -75,6 +77,10 @@ public function index(): TemplateResponse { $this->initialState->provideInitialState('isContactsInteractionEnabled', $isContactsInteractionEnabled); $this->initialState->provideInitialState('isCirclesEnabled', $isCirclesEnabled && $isCircleVersionCompatible); $this->initialState->provideInitialState('isTalkEnabled', $isTalkEnabled && $isTalkVersionCompatible); + $this->initialState->provideInitialState( + 'customProperties', + $this->customPropertiesService->getCustomProperties(), + ); Util::addStyle(Application::APP_ID, 'contacts-main'); Util::addScript(Application::APP_ID, 'contacts-main'); diff --git a/lib/Service/CustomPropertiesService.php b/lib/Service/CustomPropertiesService.php new file mode 100644 index 0000000000..945022ed1c --- /dev/null +++ b/lib/Service/CustomPropertiesService.php @@ -0,0 +1,119 @@ +config->getAppValue(Application::APP_ID, 'customProperties', '[]'); + $decoded = json_decode($json, true); + if (!is_array($decoded)) { + $this->logger->warning('Ignoring contacts customProperties app config: not a JSON array'); + return []; + } + + $properties = []; + foreach ($decoded as $entry) { + $property = $this->sanitizeEntry($entry); + if ($property === null) { + $this->logger->warning('Ignoring invalid contacts customProperties entry', ['entry' => $entry]); + continue; + } + $properties[] = $property; + } + return $properties; + } + + private function sanitizeEntry(mixed $entry): ?array { + if (!is_array($entry) || !is_string($entry['name'] ?? null) || !is_string($entry['label'] ?? null)) { + return null; + } + + $name = strtolower($entry['name']); + $label = trim($entry['label']); + if ($label === '' || preg_match('/^x-[a-z0-9-]+$/', $name) !== 1) { + return null; + } + + $force = $entry['force'] ?? 'text'; + if (!in_array($force, ['text', 'select'], true)) { + return null; + } + + $options = $this->sanitizeOptions($entry['options'] ?? []); + if ($force === 'select' && $options === []) { + return null; + } + + $property = [ + 'name' => $name, + 'label' => $label, + 'force' => $force, + 'multiple' => ($entry['multiple'] ?? false) === true, + 'primary' => ($entry['primary'] ?? false) === true, + ]; + if ($options !== []) { + $property['options'] = $options; + } + if (is_string($entry['icon'] ?? null) && preg_match('/^icon-[a-z0-9-]+$/', $entry['icon']) === 1) { + $property['icon'] = $entry['icon']; + } + return $property; + } + + private function sanitizeOptions(mixed $options): array { + if (!is_array($options)) { + return []; + } + + $sanitized = []; + foreach ($options as $option) { + if (!is_array($option) || !is_string($option['id'] ?? null) || !is_string($option['name'] ?? null) + || trim($option['id']) === '' || trim($option['name']) === '') { + continue; + } + $sanitized[] = ['id' => $option['id'], 'name' => $option['name']]; + } + return $sanitized; + } +} diff --git a/src/models/customProperties.ts b/src/models/customProperties.ts new file mode 100644 index 0000000000..8f5639eb65 --- /dev/null +++ b/src/models/customProperties.ts @@ -0,0 +1,70 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +interface PropertyTypeOption { + id: string + name: string +} + +export interface CustomPropertyConfig { + name: string + label: string + force?: 'text' | 'select' + options?: PropertyTypeOption[] + multiple?: boolean + primary?: boolean + icon?: string +} + +interface CustomPropertyModel { + readableName: string + icon: string + force: 'text' | 'select' + multiple: boolean + primary: boolean + options?: PropertyTypeOption[] + defaultValue?: { value: string, type: string[] } +} + +/** + * Merge admin-defined custom property types (app config "customProperties", + * validated server-side by CustomPropertiesService) into the property registry. + * + * @param properties the rfcProps.properties registry to extend + * @param fieldOrder the rfcProps.fieldOrder list to extend + * @param customProperties sanitized entries from the initial state + */ +export function applyCustomProperties(properties: Record, fieldOrder: string[], customProperties: CustomPropertyConfig[]) { + if (!Array.isArray(customProperties)) { + return + } + + customProperties.forEach((custom) => { + const name = typeof custom?.name === 'string' ? custom.name.toLowerCase() : '' + if (!name.startsWith('x-') || properties[name]) { + return + } + + const model: CustomPropertyModel = { + readableName: custom.label, + icon: custom.icon || 'icon-detailed-name', + force: custom.force === 'select' ? 'select' : 'text', + multiple: custom.multiple === true, + primary: custom.primary === true, + } + + if (Array.isArray(custom.options) && custom.options.length > 0) { + model.options = custom.options + if (model.force === 'text') { + // options on a text property are TYPE choices: preselect the + // first one like the builtin tel/email/adr defaults do + model.defaultValue = { value: '', type: [custom.options[0].id] } + } + } + + properties[name] = model + fieldOrder.push(name) + }) +} diff --git a/src/models/rfcProps.js b/src/models/rfcProps.js index 5acf08905f..475a3e3d0e 100644 --- a/src/models/rfcProps.js +++ b/src/models/rfcProps.js @@ -8,6 +8,7 @@ import ActionCopyNtoFN from '../components/Actions/ActionCopyNtoFN.vue' import NcActionToggleYear from '../components/Actions/NcActionToggleYear.vue' import logger from '../services/logger.js' import { otherContacts } from '../utils/chartUtils.js' +import { applyCustomProperties } from './customProperties.ts' import zones from './zones.js' // Load the default profile (for example, home or work) configured by the user @@ -433,4 +434,6 @@ const fieldOrder = [ 'role', ] +applyCustomProperties(properties, fieldOrder, loadState('contacts', 'customProperties', [])) + export default { properties, fieldOrder } diff --git a/tests/javascript/models/customProperties.test.ts b/tests/javascript/models/customProperties.test.ts new file mode 100644 index 0000000000..3d2009cd4a --- /dev/null +++ b/tests/javascript/models/customProperties.test.ts @@ -0,0 +1,99 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { CustomPropertyConfig } from '../../../src/models/customProperties' +import { applyCustomProperties } from '../../../src/models/customProperties' + +describe('customProperties', () => { + + let properties: Record + let fieldOrder: string[] + + beforeEach(() => { + properties = { + tel: { readableName: 'Phone' }, + } + fieldOrder = ['tel'] + }) + + test('registers a plain text property with defaults', () => { + applyCustomProperties(properties, fieldOrder, [ + { name: 'x-customernumber', label: 'Customer number' }, + ]) + + expect(properties['x-customernumber']).toEqual({ + readableName: 'Customer number', + icon: 'icon-detailed-name', + force: 'text', + multiple: false, + primary: false, + }) + expect(fieldOrder).toContain('x-customernumber') + }) + + test('options on a text property become TYPE choices with the first one preselected', () => { + applyCustomProperties(properties, fieldOrder, [ + { + name: 'x-office', + label: 'Office', + multiple: true, + options: [{ id: 'BERLIN', name: 'Berlin' }, { id: 'LINGEN', name: 'Lingen' }], + }, + ]) + + expect(properties['x-office']).toMatchObject({ + multiple: true, + defaultValue: { value: '', type: ['BERLIN'] }, + }) + }) + + test('options on a select property are value choices without a preselected default', () => { + applyCustomProperties(properties, fieldOrder, [ + { + name: 'x-region', + label: 'Region', + force: 'select', + options: [{ id: 'NORTH', name: 'North' }], + }, + ]) + + expect(properties['x-region']).toMatchObject({ force: 'select' }) + expect(properties['x-region']).not.toHaveProperty('defaultValue') + }) + + test('skips names colliding with builtin properties', () => { + applyCustomProperties(properties, fieldOrder, [ + { name: 'tel', label: 'Phone override' }, + ]) + + expect(properties.tel).toEqual({ readableName: 'Phone' }) + expect(fieldOrder).toEqual(['tel']) + }) + + test('skips entries without x- prefix or name', () => { + applyCustomProperties(properties, fieldOrder, [ + { name: 'customernumber', label: 'Customer number' }, + { label: 'No name' }, + null, + ] as CustomPropertyConfig[]) + + expect(Object.keys(properties)).toEqual(['tel']) + }) + + test('normalizes uppercase names', () => { + applyCustomProperties(properties, fieldOrder, [ + { name: 'X-Customernumber', label: 'Customer number' }, + ]) + + expect(properties['x-customernumber']).toBeDefined() + }) + + test('tolerates a non-array state', () => { + applyCustomProperties(properties, fieldOrder, undefined as unknown as CustomPropertyConfig[]) + applyCustomProperties(properties, fieldOrder, 'garbage' as unknown as CustomPropertyConfig[]) + + expect(Object.keys(properties)).toEqual(['tel']) + }) +}) diff --git a/tests/unit/Controller/PageControllerTest.php b/tests/unit/Controller/PageControllerTest.php index dfa30b4be3..f64daefb3f 100644 --- a/tests/unit/Controller/PageControllerTest.php +++ b/tests/unit/Controller/PageControllerTest.php @@ -9,6 +9,7 @@ use ChristophWurst\Nextcloud\Testing\TestCase; use OC\App\CompareVersion; +use OCA\Contacts\Service\CustomPropertiesService; use OCA\Contacts\Service\GroupSharingService; use OCA\Contacts\Service\SocialApiService; use OCP\App\IAppManager; @@ -50,6 +51,8 @@ class PageControllerTest extends TestCase { private GroupSharingService|MockObject $groupSharingService; + private CustomPropertiesService|MockObject $customPropertiesService; + protected function setUp(): void { parent::setUp(); @@ -62,6 +65,7 @@ protected function setUp(): void { $this->appManager = $this->createMock(IAppManager::class); $this->compareVersion = $this->createMock(CompareVersion::class); $this->groupSharingService = $this->createMock(GroupSharingService::class); + $this->customPropertiesService = $this->createMock(CustomPropertiesService::class); $this->controller = new PageController( $this->request, @@ -73,6 +77,7 @@ protected function setUp(): void { $this->appManager, $this->compareVersion, $this->groupSharingService, + $this->customPropertiesService, ); } diff --git a/tests/unit/Service/CustomPropertiesServiceTest.php b/tests/unit/Service/CustomPropertiesServiceTest.php new file mode 100644 index 0000000000..21447f71e1 --- /dev/null +++ b/tests/unit/Service/CustomPropertiesServiceTest.php @@ -0,0 +1,131 @@ +config = $this->createMock(IConfig::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->service = new CustomPropertiesService( + $this->config, + $this->logger, + ); + } + + private function mockConfigValue(string $json): void { + $this->config->method('getAppValue') + ->with('contacts', 'customProperties', '[]') + ->willReturn($json); + } + + public function testEmptyByDefault(): void { + $this->mockConfigValue('[]'); + + $this->assertSame([], $this->service->getCustomProperties()); + } + + public function testInvalidJsonIsIgnoredAndLogged(): void { + $this->mockConfigValue('{not json'); + $this->logger->expects($this->once())->method('warning'); + + $this->assertSame([], $this->service->getCustomProperties()); + } + + public function testValidEntryIsNormalized(): void { + $this->mockConfigValue(json_encode([ + [ + 'name' => 'X-Customernumber', + 'label' => 'Customer number', + 'icon' => 'icon-detailed-name', + 'unknownKey' => 'is stripped', + ], + ])); + + $this->assertSame([ + [ + 'name' => 'x-customernumber', + 'label' => 'Customer number', + 'force' => 'text', + 'multiple' => false, + 'primary' => false, + 'icon' => 'icon-detailed-name', + ], + ], $this->service->getCustomProperties()); + } + + public function testOptionsAreSanitized(): void { + $this->mockConfigValue(json_encode([ + [ + 'name' => 'x-region', + 'label' => 'Region', + 'force' => 'select', + 'multiple' => true, + 'options' => [ + ['id' => 'NORTH', 'name' => 'North'], + ['id' => '', 'name' => 'no id'], + ['name' => 'missing id'], + 'not an array', + ], + ], + ])); + + $properties = $this->service->getCustomProperties(); + $this->assertCount(1, $properties); + $this->assertSame([['id' => 'NORTH', 'name' => 'North']], $properties[0]['options']); + $this->assertTrue($properties[0]['multiple']); + } + + /** + * @dataProvider provideInvalidEntries + */ + public function testInvalidEntriesAreDroppedAndLogged(mixed $entry): void { + $this->mockConfigValue(json_encode([$entry])); + $this->logger->expects($this->once())->method('warning'); + + $this->assertSame([], $this->service->getCustomProperties()); + } + + public static function provideInvalidEntries(): array { + return [ + 'not an array' => ['just a string'], + 'missing name' => [['label' => 'No name']], + 'missing label' => [['name' => 'x-foo']], + 'empty label' => [['name' => 'x-foo', 'label' => ' ']], + 'name without x- prefix' => [['name' => 'customernumber', 'label' => 'Customer number']], + 'name with invalid characters' => [['name' => 'x-foo.bar', 'label' => 'Foo']], + 'unknown force' => [['name' => 'x-foo', 'label' => 'Foo', 'force' => 'date']], + 'select without options' => [['name' => 'x-foo', 'label' => 'Foo', 'force' => 'select']], + ]; + } + + public function testValidEntriesSurviveInvalidSiblings(): void { + $this->mockConfigValue(json_encode([ + ['name' => 'x-valid', 'label' => 'Valid'], + ['name' => 'invalid', 'label' => 'Invalid'], + ])); + + $properties = $this->service->getCustomProperties(); + $this->assertCount(1, $properties); + $this->assertSame('x-valid', $properties[0]['name']); + } +}