Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
81c464d
feat(select): add SELECT case to FieldType enum
vitormattos Mar 18, 2026
76dbff9
test(select): cover select validation in FieldDefinitionValidatorTest
vitormattos Mar 18, 2026
9b79c41
feat(select): validate options for select type in FieldDefinitionVali…
vitormattos Mar 18, 2026
fc50596
test(select): cover normalizeValue for select type in FieldValueServi…
vitormattos Mar 18, 2026
21e642d
feat(select): add options property to FieldDefinition entity
vitormattos Mar 18, 2026
0ca9d9b
feat(select): normalize select value against stored options list
vitormattos Mar 18, 2026
65b90ab
test(select): cover select operator support in UserProfileFieldCheckTest
vitormattos Mar 18, 2026
44f3379
feat(select): add SELECT_OPERATORS and wire select into workflow check
vitormattos Mar 18, 2026
38a76ee
test(select): cover options persistence and serialization in FieldDef…
vitormattos Mar 18, 2026
528a59a
feat(select): expose options in FieldDefinition.jsonSerialize
vitormattos Mar 18, 2026
aa8181e
feat(select): persist options JSON when creating or updating a select…
vitormattos Mar 18, 2026
b6bdb36
feat(select): add migration to add options column to profile_fields_d…
vitormattos Mar 18, 2026
ec9618a
feat(select): add select to ProfileFieldsType and options to ProfileF…
vitormattos Mar 18, 2026
3072b0e
feat(select): add options column to initial migration
vitormattos Mar 18, 2026
94b2896
feat(select): remove separate options migration, unified into Version…
vitormattos Mar 18, 2026
a31f7ef
test(controller): add select options forwarding tests for create/update
vitormattos Mar 18, 2026
a607423
feat(controller): add options parameter to create/update for select f…
vitormattos Mar 18, 2026
56d3eed
chore(openapi): regenerate openapi.json with select type and options …
vitormattos Mar 18, 2026
b5c7339
chore(openapi): regenerate openapi-administration.json with select ty…
vitormattos Mar 18, 2026
917848c
chore(openapi): regenerate openapi-full.json with select type and opt…
vitormattos Mar 18, 2026
a667aab
chore(types): regenerate openapi.ts with select type and options field
vitormattos Mar 18, 2026
5e293b9
chore(types): regenerate openapi-administration.ts with select type a…
vitormattos Mar 18, 2026
3d3667d
chore(types): regenerate openapi-full.ts with select type and options…
vitormattos Mar 18, 2026
2592fa5
feat(admin): add select field type with options editor in AdminSettings
vitormattos Mar 18, 2026
a9eac15
feat(personal): render NcSelect for select fields in PersonalSettings
vitormattos Mar 18, 2026
d3cf80c
feat(dialog): render NcSelect for select fields in AdminUserFieldsDialog
vitormattos Mar 18, 2026
c1f1cef
feat(admin): redesign options editor with editable rows (Metavox patt…
vitormattos Mar 18, 2026
6ccb201
fix(psalm): add options to requireString docblock in FieldDefinitionV…
vitormattos Mar 18, 2026
49ceb7b
fix(psalm): add options to ProfileFieldsDefinitionInput psalm type
vitormattos Mar 18, 2026
ebac5da
fix(mapper): add options column to all SELECT queries in FieldDefinit…
vitormattos Mar 18, 2026
98f5bf9
fix(service): use JSON_THROW_ON_ERROR when encoding options in FieldD…
vitormattos Mar 18, 2026
0dd48a5
fix(validator): trim and deduplicate options in validateOptions
vitormattos Mar 18, 2026
0acde6a
fix(entity): add null coalescing fallback for json_decode in jsonSeri…
vitormattos Mar 18, 2026
e8af0e3
chore: update openapi documentation
vitormattos Mar 18, 2026
f6052db
test(value): reject int and float in normalizeSelectValue
vitormattos Mar 18, 2026
f622b56
fix(value): enforce is_string in normalizeSelectValue instead of cast…
vitormattos Mar 18, 2026
a43971a
feat(admin): prevent duplicate options from being saved
vitormattos Mar 18, 2026
9df8a1c
chore(deps): add vuedraggable v4 for Vue 3 drag-and-drop
vitormattos Mar 18, 2026
0e30504
feat(utils): add select option helpers
vitormattos Mar 18, 2026
667bb87
test(utils): cover select option helpers
vitormattos Mar 18, 2026
a96674a
feat(admin): improve select option editor ux
vitormattos Mar 18, 2026
18d9fd9
test(playwright): extend profile field fixture support
vitormattos Mar 18, 2026
23f7ef6
test(playwright): cover select option admin flows
vitormattos Mar 18, 2026
deda9f6
feat(showcase): add select field screenshot seed
vitormattos Mar 18, 2026
c585211
chore(playwright): simplify screenshot capture flow
vitormattos Mar 18, 2026
3fe9e97
docs(screenshots): refresh admin catalog image
vitormattos Mar 18, 2026
5cdf7f2
docs(screenshots): refresh admin catalog thumbnail
vitormattos Mar 18, 2026
f0bd1d0
docs(screenshots): refresh personal settings image
vitormattos Mar 18, 2026
37d304e
docs(screenshots): refresh personal settings thumbnail
vitormattos Mar 18, 2026
db84a54
docs(screenshots): refresh user dialog image
vitormattos Mar 18, 2026
cef0b83
docs(screenshots): refresh user dialog thumbnail
vitormattos Mar 18, 2026
1e5da2e
docs(screenshots): refresh workflow image
vitormattos Mar 18, 2026
2b5d495
docs(screenshots): refresh workflow thumbnail
vitormattos Mar 18, 2026
df418a9
test(utils): update pedro poti showcase expectations
vitormattos Mar 18, 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 modified img/screenshots/admin-catalog-thumb.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified img/screenshots/admin-catalog.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified img/screenshots/personal-settings-thumb.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified img/screenshots/personal-settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified img/screenshots/user-management-dialog-thumb.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified img/screenshots/user-management-dialog.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified img/screenshots/workflow-notify-admins-thumb.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified img/screenshots/workflow-notify-admins.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 16 additions & 4 deletions lib/Controller/FieldDefinitionApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ public function index(): DataResponse {
* @param string $initialVisibility Initial visibility applied to new values
* @param int $sortOrder Display order used in admin and profile forms
* @param bool $active Whether the definition is currently active
* @param list<string> $options Allowed values for select fields (ignored for other types)
* @return DataResponse<Http::STATUS_CREATED, ProfileFieldsDefinition, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{message: string}, array{}>
*
* 201: Field definition created successfully
Expand All @@ -77,9 +78,10 @@ public function create(
string $initialVisibility = 'private',
int $sortOrder = 0,
bool $active = true,
array $options = [],
): DataResponse {
try {
$definition = $this->fieldDefinitionService->create([
$payload = [
'field_key' => $fieldKey,
'label' => $label,
'type' => $type,
Expand All @@ -89,7 +91,11 @@ public function create(
'initial_visibility' => $initialVisibility,
'sort_order' => $sortOrder,
'active' => $active,
]);
];
if ($options !== []) {
$payload['options'] = $options;
}
$definition = $this->fieldDefinitionService->create($payload);

return new DataResponse($definition->jsonSerialize(), Http::STATUS_CREATED);
} catch (InvalidArgumentException $exception) {
Expand All @@ -111,6 +117,7 @@ public function create(
* @param string $initialVisibility Initial visibility applied to new values
* @param int $sortOrder Display order used in admin and profile forms
* @param bool $active Whether the definition is currently active
* @param list<string> $options Allowed values for select fields (ignored for other types)
* @return DataResponse<Http::STATUS_OK, ProfileFieldsDefinition, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
*
* 200: Field definition updated successfully
Expand All @@ -128,14 +135,15 @@ public function update(
string $initialVisibility = 'private',
int $sortOrder = 0,
bool $active = true,
array $options = [],
): DataResponse {
$existing = $this->fieldDefinitionService->findById($id);
if ($existing === null) {
return new DataResponse(['message' => 'Field definition not found'], Http::STATUS_NOT_FOUND);
}

try {
$definition = $this->fieldDefinitionService->update($existing, [
$payload = [
'label' => $label,
'type' => $type,
'admin_only' => $adminOnly,
Expand All @@ -144,7 +152,11 @@ public function update(
'initial_visibility' => $initialVisibility,
'sort_order' => $sortOrder,
'active' => $active,
]);
];
if ($options !== []) {
$payload['options'] = $options;
}
$definition = $this->fieldDefinitionService->update($existing, $payload);

return new DataResponse($definition->jsonSerialize(), Http::STATUS_OK);
} catch (InvalidArgumentException $exception) {
Expand Down
7 changes: 7 additions & 0 deletions lib/Db/FieldDefinition.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
* @method void setSortOrder(int $value)
* @method bool getActive()
* @method void setActive(bool $value)
* @method string|null getOptions()
* @method void setOptions(?string $value)
* @method \DateTimeInterface getCreatedAt()
* @method void setCreatedAt(\DateTimeInterface $value)
* @method \DateTimeInterface getUpdatedAt()
Expand All @@ -47,6 +49,7 @@ class FieldDefinition extends Entity {
protected $initialVisibility;
protected $sortOrder;
protected $active;
protected $options;
protected $createdAt;
protected $updatedAt;

Expand All @@ -73,11 +76,14 @@ public function __construct() {
* initial_visibility: string,
* sort_order: int,
* active: bool,
* options: list<string>|null,
* created_at: string,
* updated_at: string,
* }
*/
public function jsonSerialize(): array {
$rawOptions = $this->getOptions();

return [
'id' => $this->getId(),
'field_key' => $this->getFieldKey(),
Expand All @@ -89,6 +95,7 @@ public function jsonSerialize(): array {
'initial_visibility' => $this->getInitialVisibility(),
'sort_order' => $this->getSortOrder(),
'active' => $this->getActive(),
'options' => $rawOptions !== null ? (json_decode($rawOptions, true) ?? null) : null,
'created_at' => $this->getCreatedAt()->format(DATE_ATOM),
'updated_at' => $this->getUpdatedAt()->format(DATE_ATOM),
];
Expand Down
4 changes: 4 additions & 0 deletions lib/Db/FieldDefinitionMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public function findByFieldKey(string $fieldKey): ?FieldDefinition {
'initial_visibility',
'sort_order',
'active',
'options',
'created_at',
'updated_at',
)
Expand All @@ -59,6 +60,7 @@ public function findById(int $id): ?FieldDefinition {
'initial_visibility',
'sort_order',
'active',
'options',
'created_at',
'updated_at',
)
Expand Down Expand Up @@ -88,6 +90,7 @@ public function findAllOrdered(): array {
'initial_visibility',
'sort_order',
'active',
'options',
'created_at',
'updated_at',
)
Expand All @@ -114,6 +117,7 @@ public function findActiveOrdered(): array {
'initial_visibility',
'sort_order',
'active',
'options',
'created_at',
'updated_at',
)
Expand Down
2 changes: 2 additions & 0 deletions lib/Enum/FieldType.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
enum FieldType: string {
case TEXT = 'text';
case NUMBER = 'number';
case SELECT = 'select';

/**
* @return list<string>
Expand All @@ -20,6 +21,7 @@ public static function values(): array {
return [
self::TEXT->value,
self::NUMBER->value,
self::SELECT->value,
];
}

Expand Down
4 changes: 4 additions & 0 deletions lib/Migration/Version1000Date20260309120000.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt
$table->addColumn('active', Types::BOOLEAN, [
'default' => true,
]);
$table->addColumn('options', Types::TEXT, [
'notnull' => false,
'default' => null,
]);
$table->addColumn('created_at', Types::DATETIME, []);
$table->addColumn('updated_at', Types::DATETIME, []);

Expand Down
4 changes: 3 additions & 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'
* @psalm-type ProfileFieldsType = 'text'|'number'|'select'
* @psalm-type ProfileFieldsVisibility = 'private'|'users'|'public'
* @psalm-type ProfileFieldsDefinitionInput = array{
* field_key?: string,
Expand All @@ -22,6 +22,7 @@
* initial_visibility?: string,
* sort_order?: int,
* active?: bool,
* options?: list<string>,
* }
* @psalm-type ProfileFieldsDefinition = array{
* id: int,
Expand All @@ -34,6 +35,7 @@
* initial_visibility: ProfileFieldsVisibility,
* sort_order: int,
* active: bool,
* options: list<string>|null,
* created_at: string,
* updated_at: string,
* }
Expand Down
11 changes: 11 additions & 0 deletions lib/Service/FieldDefinitionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

use DateTime;
use InvalidArgumentException;
use JsonException;
use OCA\ProfileFields\Db\FieldDefinition;
use OCA\ProfileFields\Db\FieldDefinitionMapper;
use OCA\ProfileFields\Db\FieldValueMapper;
Expand Down Expand Up @@ -44,6 +45,11 @@ public function create(array $definition): FieldDefinition {
$entity->setInitialVisibility($validated['initial_visibility']);
$entity->setSortOrder($validated['sort_order']);
$entity->setActive($validated['active']);
try {
$entity->setOptions(isset($validated['options']) ? json_encode($validated['options'], JSON_THROW_ON_ERROR) : null);
} catch (JsonException $e) {
throw new InvalidArgumentException('options could not be encoded: ' . $e->getMessage(), 0, $e);
}
$entity->setCreatedAt($createdAt);
$entity->setUpdatedAt($updatedAt);

Expand Down Expand Up @@ -71,6 +77,11 @@ public function update(FieldDefinition $existing, array $definition): FieldDefin
$existing->setInitialVisibility($validated['initial_visibility']);
$existing->setSortOrder($validated['sort_order']);
$existing->setActive($validated['active']);
try {
$existing->setOptions(isset($validated['options']) ? json_encode($validated['options'], JSON_THROW_ON_ERROR) : null);
} catch (JsonException $e) {
throw new InvalidArgumentException('options could not be encoded: ' . $e->getMessage(), 0, $e);
}
$existing->setUpdatedAt($this->parseImportedDate($definition['updated_at'] ?? null) ?? new DateTime());

return $this->fieldDefinitionMapper->update($existing);
Expand Down
37 changes: 36 additions & 1 deletion lib/Service/FieldDefinitionValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,19 @@ class FieldDefinitionValidator {
* initial_visibility?: string,
* sort_order?: int,
* active?: bool,
* options?: list<string>,
* } $definition
* @return array{
* field_key: non-empty-string,
* label: non-empty-string,
* type: 'text'|'number',
* type: 'text'|'number'|'select',
* admin_only: bool,
* user_editable: bool,
* user_visible: bool,
* initial_visibility: 'private'|'users'|'public',
* sort_order: int,
* active: bool,
* options: list<string>|null,
* }
*/
public function validate(array $definition): array {
Expand Down Expand Up @@ -65,6 +67,8 @@ public function validate(array $definition): array {
throw new InvalidArgumentException('user_editable cannot be enabled when the field is hidden from users');
}

$options = $this->validateOptions($type, $definition['options'] ?? null);

return [
'field_key' => $fieldKey,
'label' => $label,
Expand All @@ -75,9 +79,39 @@ public function validate(array $definition): array {
'initial_visibility' => $visibility,
'sort_order' => (int)($definition['sort_order'] ?? 0),
'active' => (bool)($definition['active'] ?? true),
'options' => $options,
];
}

/**
* @param mixed $options
* @return list<string>|null
*/
private function validateOptions(string $type, mixed $options): ?array {
if ($type !== FieldType::SELECT->value) {
return null;
}

if (!is_array($options) || count($options) === 0) {
throw new InvalidArgumentException('select fields require at least one option');
}

$normalized = [];
foreach ($options as $option) {
if (!is_string($option) || trim($option) === '') {
throw new InvalidArgumentException('each option must be a non-empty string');
}
$normalized[] = trim($option);
}

$deduplicated = array_values(array_unique($normalized));
if (count($deduplicated) !== count($normalized)) {
throw new InvalidArgumentException('options must not contain duplicate values');
}

return $deduplicated;
}

/**
* @param array{
* field_key?: string,
Expand All @@ -89,6 +123,7 @@ public function validate(array $definition): array {
* initial_visibility?: string,
* sort_order?: int,
* active?: bool,
* options?: list<string>,
* } $definition
*/
private function requireString(array $definition, string $key): string {
Expand Down
19 changes: 19 additions & 0 deletions lib/Service/FieldValueService.php
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ public function normalizeValue(FieldDefinition $definition, array|string|int|flo
return match ($type) {
FieldType::TEXT => $this->normalizeTextValue($rawValue),
FieldType::NUMBER => $this->normalizeNumberValue($rawValue),
FieldType::SELECT => $this->normalizeSelectValue($rawValue, $definition),
};
}

Expand Down Expand Up @@ -231,6 +232,24 @@ private function normalizeTextValue(array|string|int|float|bool $rawValue): arra
return ['value' => trim((string)$rawValue)];
}

/**
* @param array<string, mixed>|scalar $rawValue
* @return array{value: string}
*/
private function normalizeSelectValue(array|string|int|float|bool $rawValue, FieldDefinition $definition): array {
if (!is_string($rawValue)) {
throw new InvalidArgumentException('select fields expect a string value');
}

$value = trim($rawValue);
$options = json_decode($definition->getOptions() ?? '[]', true);
if (!in_array($value, $options, true)) {
throw new InvalidArgumentException(sprintf('"%s" is not a valid option for this field', $value));
}

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

/**
* @param array<string, mixed>|scalar $rawValue
* @return array{value: int|float}
Expand Down
10 changes: 9 additions & 1 deletion lib/Workflow/UserProfileFieldCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ class UserProfileFieldCheck implements ICheck {
'greater',
'!less',
];
private const SELECT_OPERATORS = [
self::OPERATOR_IS_SET,
self::OPERATOR_IS_NOT_SET,
'is',
'!is',
];

public function __construct(
private IUserSession $userSession,
Expand Down Expand Up @@ -156,6 +162,7 @@ private function isOperatorSupported(FieldDefinition $definition, string $operat
$operators = match (FieldType::from($definition->getType())) {
FieldType::TEXT => self::TEXT_OPERATORS,
FieldType::NUMBER => self::NUMBER_OPERATORS,
FieldType::SELECT => self::SELECT_OPERATORS,
};

return in_array($operator, $operators, true);
Expand Down Expand Up @@ -192,7 +199,8 @@ private function evaluate(FieldDefinition $definition, string $operator, string|
$expectedValue = $normalizedExpected['value'] ?? null;

return match (FieldType::from($definition->getType())) {
FieldType::TEXT => $this->evaluateTextOperator($operator, (string)$expectedValue, (string)$actualValue),
FieldType::TEXT,
FieldType::SELECT => $this->evaluateTextOperator($operator, (string)$expectedValue, (string)$actualValue),
FieldType::NUMBER => $this->evaluateNumberOperator(
$operator,
$this->normalizeNumericComparisonOperand($expectedValue),
Expand Down
Loading
Loading