Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions lib/Controller/PageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}
Expand Down Expand Up @@ -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');
Expand Down
119 changes: 119 additions & 0 deletions lib/Service/CustomPropertiesService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Contacts\Service;

use OCA\Contacts\AppInfo\Application;
use OCP\IConfig;
use Psr\Log\LoggerInterface;

/**
* Admin-defined custom property types for the contact editor.
*
* Configured as a JSON array in the app config:
*
* occ config:app:set contacts customProperties --value='[
* {"name": "x-customernumber", "label": "Customer number"},
* {"name": "x-region", "label": "Region", "force": "select",
* "options": [{"id": "NORTH", "name": "North"}, {"id": "SOUTH", "name": "South"}]}
* ]'
*
* Supported keys per entry:
* - name (required): vCard property name, lowercase, must match /^x-[a-z0-9-]+$/
* - label (required): display name shown in the UI (not translated)
* - force: editor type, "text" (default) or "select"
* - options: list of {id, name} — TYPE parameter choices for "text",
* value choices for "select" (required there)
* - multiple: whether the property may be added more than once per contact
* - primary: show on the first level of the "Add more info" menu
* - icon: CSS icon class, must match /^icon-[a-z0-9-]+$/
*
* Invalid entries are dropped and logged, they never break the app.
*/
class CustomPropertiesService {

public function __construct(
private IConfig $config,
private LoggerInterface $logger,
) {
}

public function getCustomProperties(): array {
$json = $this->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;
}
}
70 changes: 70 additions & 0 deletions src/models/customProperties.ts
Original file line number Diff line number Diff line change
@@ -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<string, object>, 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)
})
}
3 changes: 3 additions & 0 deletions src/models/rfcProps.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -433,4 +434,6 @@ const fieldOrder = [
'role',
]

applyCustomProperties(properties, fieldOrder, loadState('contacts', 'customProperties', []))

export default { properties, fieldOrder }
99 changes: 99 additions & 0 deletions tests/javascript/models/customProperties.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, object>
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'])
})
})
5 changes: 5 additions & 0 deletions tests/unit/Controller/PageControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -50,6 +51,8 @@ class PageControllerTest extends TestCase {

private GroupSharingService|MockObject $groupSharingService;

private CustomPropertiesService|MockObject $customPropertiesService;

protected function setUp(): void {
parent::setUp();

Expand All @@ -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,
Expand All @@ -73,6 +77,7 @@ protected function setUp(): void {
$this->appManager,
$this->compareVersion,
$this->groupSharingService,
$this->customPropertiesService,
);
}

Expand Down
Loading