From 81c464d6a33e0909a385d45c8a54b069b4bf6398 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:29:54 -0300 Subject: [PATCH 01/54] feat(select): add SELECT case to FieldType enum Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Enum/FieldType.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/Enum/FieldType.php b/lib/Enum/FieldType.php index ebbf059..fd7ee75 100644 --- a/lib/Enum/FieldType.php +++ b/lib/Enum/FieldType.php @@ -12,6 +12,7 @@ enum FieldType: string { case TEXT = 'text'; case NUMBER = 'number'; + case SELECT = 'select'; /** * @return list @@ -20,6 +21,7 @@ public static function values(): array { return [ self::TEXT->value, self::NUMBER->value, + self::SELECT->value, ]; } From 76dbff95b5b7158bfb58ed5e4b071b2870ccd624 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:30:16 -0300 Subject: [PATCH 02/54] test(select): cover select validation in FieldDefinitionValidatorTest Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Service/FieldDefinitionValidatorTest.php | 56 +++++++++++++++++-- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/tests/php/Unit/Service/FieldDefinitionValidatorTest.php b/tests/php/Unit/Service/FieldDefinitionValidatorTest.php index bf65e1d..fdb5aa8 100644 --- a/tests/php/Unit/Service/FieldDefinitionValidatorTest.php +++ b/tests/php/Unit/Service/FieldDefinitionValidatorTest.php @@ -48,17 +48,63 @@ public function testRejectInvalidType(): void { ]); } - public function testRejectSelectTypeBecauseEnumerablesAreOutOfMvp(): void { + public function testValidateSelectFieldDefinition(): void { + $validated = $this->validator->validate([ + 'field_key' => 'contract_type', + 'label' => 'Contract Type', + 'type' => FieldType::SELECT->value, + 'options' => ['CLT', 'PJ', 'Cooperado'], + ]); + + $this->assertSame(FieldType::SELECT->value, $validated['type']); + $this->assertSame(['CLT', 'PJ', 'Cooperado'], $validated['options']); + } + + public function testRejectSelectWithNoOptions(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('type is not supported'); + $this->expectExceptionMessage('select fields require at least one option'); $this->validator->validate([ - 'field_key' => 'department', - 'label' => 'Department', - 'type' => 'select', + 'field_key' => 'contract_type', + 'label' => 'Contract Type', + 'type' => FieldType::SELECT->value, + 'options' => [], + ]); + } + + public function testRejectSelectWithMissingOptions(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('select fields require at least one option'); + + $this->validator->validate([ + 'field_key' => 'contract_type', + 'label' => 'Contract Type', + 'type' => FieldType::SELECT->value, ]); } + public function testRejectSelectWithBlankOption(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('each option must be a non-empty string'); + + $this->validator->validate([ + 'field_key' => 'contract_type', + 'label' => 'Contract Type', + 'type' => FieldType::SELECT->value, + 'options' => ['CLT', ' ', 'PJ'], + ]); + } + + public function testNonSelectTypesDoNotRequireOptions(): void { + $validated = $this->validator->validate([ + 'field_key' => 'cpf', + 'label' => 'CPF', + 'type' => FieldType::TEXT->value, + ]); + + $this->assertNull($validated['options']); + } + public function testRejectAdminOnlyAndUserEditableCombination(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('admin_only and user_editable cannot both be enabled'); From 9b79c4178c750cd6222b3701488536f94131d362 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:30:16 -0300 Subject: [PATCH 03/54] feat(select): validate options for select type in FieldDefinitionValidator Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/FieldDefinitionValidator.php | 29 +++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/lib/Service/FieldDefinitionValidator.php b/lib/Service/FieldDefinitionValidator.php index cd51a58..08a1969 100644 --- a/lib/Service/FieldDefinitionValidator.php +++ b/lib/Service/FieldDefinitionValidator.php @@ -25,17 +25,19 @@ class FieldDefinitionValidator { * initial_visibility?: string, * sort_order?: int, * active?: bool, + * options?: list, * } $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|null, * } */ public function validate(array $definition): array { @@ -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, @@ -75,9 +79,32 @@ 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|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'); + } + + foreach ($options as $option) { + if (!is_string($option) || trim($option) === '') { + throw new InvalidArgumentException('each option must be a non-empty string'); + } + } + + return array_values($options); + } + /** * @param array{ * field_key?: string, From fc50596a6fb8ca51a0aac73730cc9280f62bd1c3 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:31:10 -0300 Subject: [PATCH 04/54] test(select): cover normalizeValue for select type in FieldValueServiceTest Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Unit/Service/FieldValueServiceTest.php | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/php/Unit/Service/FieldValueServiceTest.php b/tests/php/Unit/Service/FieldValueServiceTest.php index 7f3b989..f8d582e 100644 --- a/tests/php/Unit/Service/FieldValueServiceTest.php +++ b/tests/php/Unit/Service/FieldValueServiceTest.php @@ -337,10 +337,53 @@ public function testSearchByDefinitionRejectsUnsupportedOperator(): void { $this->service->searchByDefinition($definition, 'starts_with', 'lat', 50, 0); } + public function testNormalizeSelectValueAcceptsValidOption(): void { + $definition = $this->buildSelectDefinition(['CLT', 'PJ', 'Cooperado']); + + $normalized = $this->service->normalizeValue($definition, 'PJ'); + + $this->assertSame(['value' => 'PJ'], $normalized); + } + + public function testNormalizeSelectValueRejectsValueOutsideOptions(): void { + $definition = $this->buildSelectDefinition(['CLT', 'PJ', 'Cooperado']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('"Freelancer" is not a valid option for this field'); + + $this->service->normalizeValue($definition, 'Freelancer'); + } + + public function testNormalizeSelectValueAcceptsNull(): void { + $definition = $this->buildSelectDefinition(['CLT', 'PJ']); + + $normalized = $this->service->normalizeValue($definition, null); + + $this->assertSame(['value' => null], $normalized); + } + + public function testNormalizeSelectValueRejectsArray(): void { + $definition = $this->buildSelectDefinition(['CLT', 'PJ']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('select fields expect a string value'); + + $this->service->normalizeValue($definition, ['CLT']); + } + private function buildDefinition(string $type): FieldDefinition { $definition = new FieldDefinition(); $definition->setType($type); $definition->setInitialVisibility('private'); return $definition; } + + /** + * @param list $options + */ + private function buildSelectDefinition(array $options): FieldDefinition { + $definition = $this->buildDefinition(FieldType::SELECT->value); + $definition->setOptions(json_encode($options)); + return $definition; + } } From 21e642d16c7db62b31348ec8004b1947825e2ec4 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:31:15 -0300 Subject: [PATCH 05/54] feat(select): add options property to FieldDefinition entity Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Db/FieldDefinition.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/Db/FieldDefinition.php b/lib/Db/FieldDefinition.php index bd5b33c..6bdd27d 100644 --- a/lib/Db/FieldDefinition.php +++ b/lib/Db/FieldDefinition.php @@ -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() @@ -47,6 +49,7 @@ class FieldDefinition extends Entity { protected $initialVisibility; protected $sortOrder; protected $active; + protected $options; protected $createdAt; protected $updatedAt; From 0ca9d9beece310d7eb3f4835a7e73e796c966d72 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:31:15 -0300 Subject: [PATCH 06/54] feat(select): normalize select value against stored options list Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/FieldValueService.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/lib/Service/FieldValueService.php b/lib/Service/FieldValueService.php index 9f2f2d6..7f365ae 100644 --- a/lib/Service/FieldValueService.php +++ b/lib/Service/FieldValueService.php @@ -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), }; } @@ -231,6 +232,24 @@ private function normalizeTextValue(array|string|int|float|bool $rawValue): arra return ['value' => trim((string)$rawValue)]; } + /** + * @param array|scalar $rawValue + * @return array{value: string} + */ + private function normalizeSelectValue(array|string|int|float|bool $rawValue, FieldDefinition $definition): array { + if (is_array($rawValue) || is_bool($rawValue)) { + throw new InvalidArgumentException('select fields expect a string value'); + } + + $value = trim((string)$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|scalar $rawValue * @return array{value: int|float} From 65b90abf2811e9194652738629c092cb9ef9000a Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:32:30 -0300 Subject: [PATCH 07/54] test(select): cover select operator support in UserProfileFieldCheckTest Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Workflow/UserProfileFieldCheckTest.php | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/php/Unit/Workflow/UserProfileFieldCheckTest.php b/tests/php/Unit/Workflow/UserProfileFieldCheckTest.php index 625c842..8d98370 100644 --- a/tests/php/Unit/Workflow/UserProfileFieldCheckTest.php +++ b/tests/php/Unit/Workflow/UserProfileFieldCheckTest.php @@ -188,6 +188,59 @@ public function testExecuteCheckUsesEntitySubjectUserWhenAvailable(): void { $this->assertTrue($this->check->executeCheck('is', $this->encodeConfig('region', 'LATAM'))); } + public function testExecuteCheckMatchesSelectExactValue(): void { + $definition = $this->buildDefinition(7, 'contract_type', FieldType::SELECT->value); + $definition->setOptions(json_encode(['CLT', 'PJ', 'Cooperado'])); + $value = $this->buildStoredValue(7, 'alice', '{"value":"PJ"}'); + + $this->fieldDefinitionService->expects($this->once()) + ->method('findByFieldKey') + ->with('contract_type') + ->willReturn($definition); + $this->fieldValueMapper->expects($this->once()) + ->method('findByFieldDefinitionIdAndUserUid') + ->with(7, 'alice') + ->willReturn($value); + + $this->userSession->method('getUser')->willReturn($this->buildUser('alice')); + + $this->assertTrue($this->check->executeCheck('is', $this->encodeConfig('contract_type', 'PJ'))); + } + + public function testExecuteCheckDoesNotMatchSelectDifferentValue(): void { + $definition = $this->buildDefinition(7, 'contract_type', FieldType::SELECT->value); + $definition->setOptions(json_encode(['CLT', 'PJ', 'Cooperado'])); + $value = $this->buildStoredValue(7, 'alice', '{"value":"PJ"}'); + + $this->fieldDefinitionService->expects($this->once()) + ->method('findByFieldKey') + ->with('contract_type') + ->willReturn($definition); + $this->fieldValueMapper->expects($this->once()) + ->method('findByFieldDefinitionIdAndUserUid') + ->with(7, 'alice') + ->willReturn($value); + + $this->userSession->method('getUser')->willReturn($this->buildUser('alice')); + + $this->assertFalse($this->check->executeCheck('is', $this->encodeConfig('contract_type', 'CLT'))); + } + + public function testValidateCheckRejectsContainsForSelectField(): void { + $definition = $this->buildDefinition(7, 'contract_type', FieldType::SELECT->value); + $definition->setOptions(json_encode(['CLT', 'PJ'])); + + $this->fieldDefinitionService->expects($this->once()) + ->method('findByFieldKey') + ->with('contract_type') + ->willReturn($definition); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('The selected operator is not supported for this profile field'); + + $this->check->validateCheck('contains', $this->encodeConfig('contract_type', 'CL')); + } + private function buildDefinition(int $id, string $fieldKey, string $type): FieldDefinition { $definition = new FieldDefinition(); $definition->setId($id); From 44f3379ef82c49b859125b6ffe24d3fdcbd9c120 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:32:30 -0300 Subject: [PATCH 08/54] feat(select): add SELECT_OPERATORS and wire select into workflow check Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Workflow/UserProfileFieldCheck.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/Workflow/UserProfileFieldCheck.php b/lib/Workflow/UserProfileFieldCheck.php index eae1c22..e863563 100644 --- a/lib/Workflow/UserProfileFieldCheck.php +++ b/lib/Workflow/UserProfileFieldCheck.php @@ -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, @@ -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); @@ -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), From 38a76ee6d0e9b53f6f8378e0fe4356d33594bf00 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:33:21 -0300 Subject: [PATCH 09/54] test(select): cover options persistence and serialization in FieldDefinitionServiceTest Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Service/FieldDefinitionServiceTest.php | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests/php/Unit/Service/FieldDefinitionServiceTest.php b/tests/php/Unit/Service/FieldDefinitionServiceTest.php index f464bf2..bda1058 100644 --- a/tests/php/Unit/Service/FieldDefinitionServiceTest.php +++ b/tests/php/Unit/Service/FieldDefinitionServiceTest.php @@ -149,6 +149,70 @@ public function testUpdateRejectsTypeChangeWhenValuesExist(): void { ]); } + public function testCreateSelectFieldPersistsOptions(): void { + $this->fieldDefinitionMapper + ->method('findByFieldKey') + ->willReturn(null); + + $this->fieldDefinitionMapper + ->expects($this->once()) + ->method('insert') + ->with($this->callback(function (FieldDefinition $definition): bool { + $this->assertSame(FieldType::SELECT->value, $definition->getType()); + $this->assertSame('["CLT","PJ","Cooperado"]', $definition->getOptions()); + return true; + })) + ->willReturnCallback(static fn (FieldDefinition $definition): FieldDefinition => $definition); + + $this->service->create([ + 'field_key' => 'contract_type', + 'label' => 'Contract Type', + 'type' => FieldType::SELECT->value, + 'options' => ['CLT', 'PJ', 'Cooperado'], + ]); + } + + public function testJsonSerializeSelectIncludesOptions(): void { + $definition = new FieldDefinition(); + $definition->setId(1); + $definition->setFieldKey('contract_type'); + $definition->setLabel('Contract Type'); + $definition->setType(FieldType::SELECT->value); + $definition->setAdminOnly(false); + $definition->setUserEditable(true); + $definition->setUserVisible(true); + $definition->setInitialVisibility('private'); + $definition->setSortOrder(0); + $definition->setActive(true); + $definition->setOptions('["CLT","PJ"]'); + $definition->setCreatedAt(new \DateTime('2026-01-01T00:00:00+00:00')); + $definition->setUpdatedAt(new \DateTime('2026-01-01T00:00:00+00:00')); + + $serialized = $definition->jsonSerialize(); + + $this->assertSame(['CLT', 'PJ'], $serialized['options']); + } + + public function testJsonSerializeTextHasNullOptions(): void { + $definition = new FieldDefinition(); + $definition->setId(1); + $definition->setFieldKey('cpf'); + $definition->setLabel('CPF'); + $definition->setType(FieldType::TEXT->value); + $definition->setAdminOnly(false); + $definition->setUserEditable(false); + $definition->setUserVisible(false); + $definition->setInitialVisibility('private'); + $definition->setSortOrder(0); + $definition->setActive(true); + $definition->setCreatedAt(new \DateTime('2026-01-01T00:00:00+00:00')); + $definition->setUpdatedAt(new \DateTime('2026-01-01T00:00:00+00:00')); + + $serialized = $definition->jsonSerialize(); + + $this->assertNull($serialized['options']); + } + public function testUpdatePreservesImportedUpdatedAt(): void { $existing = new FieldDefinition(); $existing->setId(7); From 528a59a01a5c88926607da33e46b1eebb1fb802c Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:33:21 -0300 Subject: [PATCH 10/54] feat(select): expose options in FieldDefinition.jsonSerialize Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Db/FieldDefinition.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/Db/FieldDefinition.php b/lib/Db/FieldDefinition.php index 6bdd27d..0da0e69 100644 --- a/lib/Db/FieldDefinition.php +++ b/lib/Db/FieldDefinition.php @@ -76,11 +76,14 @@ public function __construct() { * initial_visibility: string, * sort_order: int, * active: bool, + * options: list|null, * created_at: string, * updated_at: string, * } */ public function jsonSerialize(): array { + $rawOptions = $this->getOptions(); + return [ 'id' => $this->getId(), 'field_key' => $this->getFieldKey(), @@ -92,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, 'created_at' => $this->getCreatedAt()->format(DATE_ATOM), 'updated_at' => $this->getUpdatedAt()->format(DATE_ATOM), ]; From aa8181e254b3949c0e42cc64417a90a3f453eb5a Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:33:21 -0300 Subject: [PATCH 11/54] feat(select): persist options JSON when creating or updating a select field Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/FieldDefinitionService.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/Service/FieldDefinitionService.php b/lib/Service/FieldDefinitionService.php index eed1e9b..57dbd4b 100644 --- a/lib/Service/FieldDefinitionService.php +++ b/lib/Service/FieldDefinitionService.php @@ -44,6 +44,7 @@ public function create(array $definition): FieldDefinition { $entity->setInitialVisibility($validated['initial_visibility']); $entity->setSortOrder($validated['sort_order']); $entity->setActive($validated['active']); + $entity->setOptions(isset($validated['options']) ? json_encode($validated['options']) : null); $entity->setCreatedAt($createdAt); $entity->setUpdatedAt($updatedAt); @@ -71,6 +72,7 @@ public function update(FieldDefinition $existing, array $definition): FieldDefin $existing->setInitialVisibility($validated['initial_visibility']); $existing->setSortOrder($validated['sort_order']); $existing->setActive($validated['active']); + $existing->setOptions(isset($validated['options']) ? json_encode($validated['options']) : null); $existing->setUpdatedAt($this->parseImportedDate($definition['updated_at'] ?? null) ?? new DateTime()); return $this->fieldDefinitionMapper->update($existing); From b6bdb364d1b2f73a3fe2bcc3b78b75c01be20c8a Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:33:57 -0300 Subject: [PATCH 12/54] feat(select): add migration to add options column to profile_fields_definitions Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Version1001Date20260317120000.php | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 lib/Migration/Version1001Date20260317120000.php diff --git a/lib/Migration/Version1001Date20260317120000.php b/lib/Migration/Version1001Date20260317120000.php new file mode 100644 index 0000000..78f2c71 --- /dev/null +++ b/lib/Migration/Version1001Date20260317120000.php @@ -0,0 +1,40 @@ +getTable('profile_fields_definitions'); + + if (!$table->hasColumn('options')) { + $table->addColumn('options', Types::TEXT, [ + 'notnull' => false, + 'default' => null, + ]); + } + + return $schema; + } +} From ec9618a894c5580643a2f09a4fde413548e6f256 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:33:57 -0300 Subject: [PATCH 13/54] feat(select): add select to ProfileFieldsType and options to ProfileFieldsDefinition psalm types Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/ResponseDefinitions.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index cf22311..465885b 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -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, @@ -34,6 +34,7 @@ * initial_visibility: ProfileFieldsVisibility, * sort_order: int, * active: bool, + * options: list|null, * created_at: string, * updated_at: string, * } From 3072b0eee62ef66672fb83bf355e0fe1ef0d90d7 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:44:40 -0300 Subject: [PATCH 14/54] feat(select): add options column to initial migration Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Migration/Version1000Date20260309120000.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/Migration/Version1000Date20260309120000.php b/lib/Migration/Version1000Date20260309120000.php index c68193c..04c7882 100644 --- a/lib/Migration/Version1000Date20260309120000.php +++ b/lib/Migration/Version1000Date20260309120000.php @@ -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, []); From 94b2896caa34bebbd2e0ecbe572981511dbf1b58 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:44:40 -0300 Subject: [PATCH 15/54] feat(select): remove separate options migration, unified into Version1000 Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Version1001Date20260317120000.php | 40 ------------------- 1 file changed, 40 deletions(-) delete mode 100644 lib/Migration/Version1001Date20260317120000.php diff --git a/lib/Migration/Version1001Date20260317120000.php b/lib/Migration/Version1001Date20260317120000.php deleted file mode 100644 index 78f2c71..0000000 --- a/lib/Migration/Version1001Date20260317120000.php +++ /dev/null @@ -1,40 +0,0 @@ -getTable('profile_fields_definitions'); - - if (!$table->hasColumn('options')) { - $table->addColumn('options', Types::TEXT, [ - 'notnull' => false, - 'default' => null, - ]); - } - - return $schema; - } -} From a31f7ef74bd27e7b65b4fd62094256453dcfd8ec Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:51:30 -0300 Subject: [PATCH 16/54] test(controller): add select options forwarding tests for create/update Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../FieldDefinitionApiControllerTest.php | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/php/Unit/Controller/FieldDefinitionApiControllerTest.php b/tests/php/Unit/Controller/FieldDefinitionApiControllerTest.php index 37fc38b..d16b4f1 100644 --- a/tests/php/Unit/Controller/FieldDefinitionApiControllerTest.php +++ b/tests/php/Unit/Controller/FieldDefinitionApiControllerTest.php @@ -79,6 +79,40 @@ public function testCreateReturnsCreatedDefinition(): void { $this->assertSame($definition->jsonSerialize(), $response->getData()); } + public function testCreateSelectFieldForwardsOptions(): void { + $definition = $this->buildDefinition(6, 'contract_type'); + $this->service->expects($this->once()) + ->method('create') + ->with([ + 'field_key' => 'contract_type', + 'label' => 'Contract Type', + 'type' => FieldType::SELECT->value, + 'admin_only' => false, + 'user_editable' => true, + 'user_visible' => true, + 'initial_visibility' => FieldVisibility::PRIVATE->value, + 'sort_order' => 0, + 'active' => true, + 'options' => ['CLT', 'PJ', 'Cooperado'], + ]) + ->willReturn($definition); + + $response = $this->controller->create( + 'contract_type', + 'Contract Type', + FieldType::SELECT->value, + false, + true, + true, + FieldVisibility::PRIVATE->value, + 0, + true, + ['CLT', 'PJ', 'Cooperado'], + ); + + $this->assertSame(Http::STATUS_CREATED, $response->getStatus()); + } + public function testCreateReturnsBadRequestOnValidationFailure(): void { $this->service->expects($this->once()) ->method('create') @@ -100,6 +134,46 @@ public function testCreateReturnsBadRequestOnValidationFailure(): void { $this->assertSame(['message' => 'field_key already exists'], $response->getData()); } + public function testUpdateSelectFieldForwardsOptions(): void { + $existing = $this->buildDefinition(6, 'contract_type'); + $updated = $this->buildDefinition(6, 'contract_type'); + + $this->service->expects($this->once()) + ->method('findById') + ->with(6) + ->willReturn($existing); + + $this->service->expects($this->once()) + ->method('update') + ->with($existing, [ + 'label' => 'Contract Type', + 'type' => FieldType::SELECT->value, + 'admin_only' => false, + 'user_editable' => true, + 'user_visible' => true, + 'initial_visibility' => FieldVisibility::PRIVATE->value, + 'sort_order' => 0, + 'active' => true, + 'options' => ['CLT', 'PJ'], + ]) + ->willReturn($updated); + + $response = $this->controller->update( + 6, + 'Contract Type', + FieldType::SELECT->value, + false, + true, + true, + FieldVisibility::PRIVATE->value, + 0, + true, + ['CLT', 'PJ'], + ); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + } + public function testUpdateReturnsNotFoundWhenDefinitionDoesNotExist(): void { $this->service->expects($this->once()) ->method('findById') From a607423347a3b23bc1aff8808a20d10232e1004d Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:51:35 -0300 Subject: [PATCH 17/54] feat(controller): add options parameter to create/update for select fields Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../FieldDefinitionApiController.php | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/lib/Controller/FieldDefinitionApiController.php b/lib/Controller/FieldDefinitionApiController.php index c0c86e4..9810cee 100644 --- a/lib/Controller/FieldDefinitionApiController.php +++ b/lib/Controller/FieldDefinitionApiController.php @@ -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 $options Allowed values for select fields (ignored for other types) * @return DataResponse|DataResponse * * 201: Field definition created successfully @@ -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, @@ -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) { @@ -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 $options Allowed values for select fields (ignored for other types) * @return DataResponse|DataResponse * * 200: Field definition updated successfully @@ -128,6 +135,7 @@ public function update( string $initialVisibility = 'private', int $sortOrder = 0, bool $active = true, + array $options = [], ): DataResponse { $existing = $this->fieldDefinitionService->findById($id); if ($existing === null) { @@ -135,7 +143,7 @@ public function update( } try { - $definition = $this->fieldDefinitionService->update($existing, [ + $payload = [ 'label' => $label, 'type' => $type, 'admin_only' => $adminOnly, @@ -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) { From 56d3eed0b1dc3ce7aff97ec13862d010ac02c5c7 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:51:53 -0300 Subject: [PATCH 18/54] chore(openapi): regenerate openapi.json with select type and options field Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- openapi.json | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/openapi.json b/openapi.json index 70d825f..c66314f 100644 --- a/openapi.json +++ b/openapi.json @@ -33,6 +33,7 @@ "initial_visibility", "sort_order", "active", + "options", "created_at", "updated_at" ], @@ -71,6 +72,13 @@ "active": { "type": "boolean" }, + "options": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + } + }, "created_at": { "type": "string" }, @@ -127,7 +135,8 @@ "type": "string", "enum": [ "text", - "number" + "number", + "select" ] }, "ValuePayload": { From b5c73392c94cb3e160bbae4d37731ebe2568b510 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:51:53 -0300 Subject: [PATCH 19/54] chore(openapi): regenerate openapi-administration.json with select type and options field Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- openapi-administration.json | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/openapi-administration.json b/openapi-administration.json index ecf87ec..ac4e54e 100644 --- a/openapi-administration.json +++ b/openapi-administration.json @@ -33,6 +33,7 @@ "initial_visibility", "sort_order", "active", + "options", "created_at", "updated_at" ], @@ -71,6 +72,13 @@ "active": { "type": "boolean" }, + "options": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + } + }, "created_at": { "type": "string" }, @@ -181,7 +189,8 @@ "type": "string", "enum": [ "text", - "number" + "number", + "select" ] }, "ValuePayload": { @@ -376,6 +385,14 @@ "type": "boolean", "default": true, "description": "Whether the definition is currently active" + }, + "options": { + "type": "array", + "default": [], + "description": "Allowed values for select fields (ignored for other types)", + "items": { + "type": "string" + } } } } @@ -531,6 +548,14 @@ "type": "boolean", "default": true, "description": "Whether the definition is currently active" + }, + "options": { + "type": "array", + "default": [], + "description": "Allowed values for select fields (ignored for other types)", + "items": { + "type": "string" + } } } } From 917848c73ecadaa83e75cff3294445fea581885b Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:51:53 -0300 Subject: [PATCH 20/54] chore(openapi): regenerate openapi-full.json with select type and options field Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- openapi-full.json | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/openapi-full.json b/openapi-full.json index a4130bc..f19d0c5 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -33,6 +33,7 @@ "initial_visibility", "sort_order", "active", + "options", "created_at", "updated_at" ], @@ -71,6 +72,13 @@ "active": { "type": "boolean" }, + "options": { + "type": "array", + "nullable": true, + "items": { + "type": "string" + } + }, "created_at": { "type": "string" }, @@ -256,7 +264,8 @@ "type": "string", "enum": [ "text", - "number" + "number", + "select" ] }, "ValuePayload": { @@ -451,6 +460,14 @@ "type": "boolean", "default": true, "description": "Whether the definition is currently active" + }, + "options": { + "type": "array", + "default": [], + "description": "Allowed values for select fields (ignored for other types)", + "items": { + "type": "string" + } } } } @@ -606,6 +623,14 @@ "type": "boolean", "default": true, "description": "Whether the definition is currently active" + }, + "options": { + "type": "array", + "default": [], + "description": "Allowed values for select fields (ignored for other types)", + "items": { + "type": "string" + } } } } From a667aabbe4dd404c84d7bfe88509f7d3c2b89d04 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:52:00 -0300 Subject: [PATCH 21/54] chore(types): regenerate openapi.ts with select type and options field Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/types/openapi/openapi.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 3bb4c5c..1ab2e3d 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -81,6 +81,7 @@ export interface components { /** Format: int64 */ sort_order: number; active: boolean; + options: string[] | null; created_at: string; updated_at: string; }; @@ -97,7 +98,7 @@ export interface components { itemsperpage?: string; }; /** @enum {string} */ - Type: "text" | "number"; + Type: "text" | "number" | "select"; ValuePayload: { value: Record; }; From 5e293b91b8ec5a9c368fcdcb6a35edbc84f8e106 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:52:00 -0300 Subject: [PATCH 22/54] chore(types): regenerate openapi-administration.ts with select type and options field Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/types/openapi/openapi-administration.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/types/openapi/openapi-administration.ts b/src/types/openapi/openapi-administration.ts index 20bcbf2..53aeb9a 100644 --- a/src/types/openapi/openapi-administration.ts +++ b/src/types/openapi/openapi-administration.ts @@ -157,6 +157,7 @@ export interface components { /** Format: int64 */ sort_order: number; active: boolean; + options: string[] | null; created_at: string; updated_at: string; }; @@ -190,7 +191,7 @@ export interface components { }; }; /** @enum {string} */ - Type: "text" | "number"; + Type: "text" | "number" | "select"; ValuePayload: { value: Record; }; @@ -294,6 +295,11 @@ export interface operations { * @default true */ active?: boolean; + /** + * @description Allowed values for select fields (ignored for other types) + * @default [] + */ + options?: string[]; }; }; }; @@ -381,6 +387,11 @@ export interface operations { * @default true */ active?: boolean; + /** + * @description Allowed values for select fields (ignored for other types) + * @default [] + */ + options?: string[]; }; }; }; From 3d3667d86fd6df5f871adcb7517c32be382574a6 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:52:00 -0300 Subject: [PATCH 23/54] chore(types): regenerate openapi-full.ts with select type and options field Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/types/openapi/openapi-full.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 29918ed..18f6051 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -217,6 +217,7 @@ export interface components { /** Format: int64 */ sort_order: number; active: boolean; + options: string[] | null; created_at: string; updated_at: string; }; @@ -274,7 +275,7 @@ export interface components { }; }; /** @enum {string} */ - Type: "text" | "number"; + Type: "text" | "number" | "select"; ValuePayload: { value: Record; }; @@ -378,6 +379,11 @@ export interface operations { * @default true */ active?: boolean; + /** + * @description Allowed values for select fields (ignored for other types) + * @default [] + */ + options?: string[]; }; }; }; @@ -465,6 +471,11 @@ export interface operations { * @default true */ active?: boolean; + /** + * @description Allowed values for select fields (ignored for other types) + * @default [] + */ + options?: string[]; }; }; }; From 2592fa5282a13fb1c9160bd68304c8f17ac2cf14 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 00:14:24 -0300 Subject: [PATCH 24/54] feat(admin): add select field type with options editor in AdminSettings Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/views/AdminSettings.vue | 63 +++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/views/AdminSettings.vue b/src/views/AdminSettings.vue index 92008aa..4a3c491 100644 --- a/src/views/AdminSettings.vue +++ b/src/views/AdminSettings.vue @@ -153,6 +153,35 @@ SPDX-License-Identifier: AGPL-3.0-or-later +
+
+

Options

+

Define the values users and admins can pick from this field.

+
+ +
    +
  • + {{ option }} + + × + +
  • +
+ +
+ + + Add option + +
+
+

Permissions

@@ -220,6 +249,7 @@ import { visibilityOptions } from '../utils/visibilityOptions.js' const fieldTypeOptions: Array<{ value: FieldType, label: string }> = [ { value: 'text', label: 'Text' }, { value: 'number', label: 'Number' }, + { value: 'select', label: 'Select' }, ] const definitions = ref([]) @@ -240,8 +270,11 @@ const form = reactive({ initialVisibility: 'private' as FieldVisibility, sortOrder: 0, active: true, + options: [] as string[], }) +const newOptionInput = ref('') + const userVisibleDescription = computed(() => form.userVisible ? 'Show the field in admin and personal user-facing settings.' : 'Hide system-managed fields from user-facing settings.') @@ -272,6 +305,7 @@ const buildFormState = () => ({ initialVisibility: form.initialVisibility, sortOrder: Number(form.sortOrder), active: form.active, + options: form.type === 'select' ? [...form.options] : [], }) const buildDefinitionState = (definition: FieldDefinition | null) => { @@ -286,6 +320,7 @@ const buildDefinitionState = (definition: FieldDefinition | null) => { initialVisibility: 'private' as FieldVisibility, sortOrder: definitions.value.length, active: true, + options: [], } } @@ -299,6 +334,7 @@ const buildDefinitionState = (definition: FieldDefinition | null) => { initialVisibility: definition.initial_visibility, sortOrder: definition.sort_order, active: definition.active, + options: definition.type === 'select' ? (definition.options ?? []) : [], } } @@ -332,6 +368,8 @@ const resetForm = () => { form.initialVisibility = 'private' form.sortOrder = definitions.value.length form.active = true + form.options = [] + newOptionInput.value = '' } const startCreatingField = () => { @@ -351,6 +389,8 @@ const populateForm = (definition: FieldDefinition) => { form.initialVisibility = definition.initial_visibility form.sortOrder = definition.sort_order form.active = definition.active + form.options = definition.type === 'select' ? [...(definition.options ?? [])] : [] + newOptionInput.value = '' } const loadDefinitions = async() => { @@ -389,6 +429,7 @@ const persistDefinition = async() => { initialVisibility: form.initialVisibility, sortOrder: Number(form.sortOrder), active: form.active, + ...(form.type === 'select' ? { options: [...form.options] } : {}), } try { @@ -406,6 +447,7 @@ const persistDefinition = async() => { initialVisibility: payload.initialVisibility, sortOrder: payload.sortOrder, active: payload.active, + ...(payload.type === 'select' ? { options: payload.options } : {}), }) successMessage.value = 'Field definition updated.' } @@ -469,6 +511,7 @@ const moveDefinition = async(direction: -1 | 1) => { initialVisibility: definition.initial_visibility, sortOrder, active: definition.active, + ...(definition.type === 'select' ? { options: definition.options ?? [] } : {}), }) })) @@ -485,6 +528,26 @@ const moveDefinition = async(direction: -1 | 1) => { } } +const addOption = () => { + const trimmed = newOptionInput.value.trim() + if (trimmed === '' || form.options.includes(trimmed)) { + return + } + form.options.push(trimmed) + newOptionInput.value = '' +} + +const removeOption = (index: number) => { + form.options.splice(index, 1) +} + +watch(() => form.type, (newType: FieldType) => { + if (newType !== 'select') { + form.options = [] + newOptionInput.value = '' + } +}) + watch(() => form.userVisible, (userVisible: boolean) => { if (!userVisible) { form.userEditable = false From a9eac1594c97b19d68258f2c1c66ea8af18a9960 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 00:14:24 -0300 Subject: [PATCH 25/54] feat(personal): render NcSelect for select fields in PersonalSettings Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/views/PersonalSettings.vue | 46 +++++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/src/views/PersonalSettings.vue b/src/views/PersonalSettings.vue index 8d563af..b63568c 100644 --- a/src/views/PersonalSettings.vue +++ b/src/views/PersonalSettings.vue @@ -69,7 +69,23 @@ SPDX-License-Identifier: AGPL-3.0-or-later
+ >({}) const draftVisibilities = reactive>({}) -const inputModesByType = { +const inputModesByType: Record = { text: 'text', number: 'decimal', -} as const + select: 'text', +} -const inputModeForType = (type: FieldType): 'text' | 'decimal' | 'numeric' => { +const inputModeForType = (type: FieldType): 'text' | 'decimal' => { return inputModesByType[type] } const fieldInputId = (fieldId: number) => `profile-fields-personal-value-${fieldId}` -const componentInputTypesByType = { +const componentInputTypesByType: Record = { text: 'text', number: 'number', -} as const + select: 'text', +} const componentInputTypeForType = (type: FieldType): 'text' | 'number' => { return componentInputTypesByType[type] @@ -230,6 +248,10 @@ const placeholderForField = (field: EditableField) => { return 'Enter a number' } + if (field.definition.type === 'select') { + return 'Choose a value' + } + return 'Enter a value' } @@ -326,7 +348,7 @@ const canAutosaveField = (field: EditableField) => { return true } - if (field.definition.type === 'text') { + if (field.definition.type === 'text' || field.definition.type === 'select') { return true } @@ -420,6 +442,18 @@ const buildPayload = (field: EditableField) => { } } +const selectOptionsFor = (definition: { options: string[] | null }) => + (definition.options ?? []).map((opt: string) => ({ value: opt, label: opt })) + +const selectOptionFor = (field: EditableField) => { + const value = draftValues[field.definition.id] + return value ? { value, label: value } : null +} + +const updateSelectValue = (fieldId: number, option: { value: string, label: string } | null) => { + updateDraftValue(fieldId, option?.value ?? '') +} + const saveField = async(field: EditableField) => { const fieldId = field.definition.id if (!hasFieldChanges(field)) { From d3cf80cf9d54e265e06c6162923a4ac7579ab48b Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 00:14:24 -0300 Subject: [PATCH 26/54] feat(dialog): render NcSelect for select fields in AdminUserFieldsDialog Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/components/AdminUserFieldsDialog.vue | 55 +++++++++++++++++++++--- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/src/components/AdminUserFieldsDialog.vue b/src/components/AdminUserFieldsDialog.vue index 9b85c42..671d179 100644 --- a/src/components/AdminUserFieldsDialog.vue +++ b/src/components/AdminUserFieldsDialog.vue @@ -46,7 +46,21 @@ SPDX-License-Identifier: AGPL-3.0-or-later
+ ({ + const descriptionForType = (type: FieldType): string => ({ text: 'Free text stored as a scalar value.', number: 'Only numeric values are accepted.', - }[type]) + select: 'Choose one of the predefined options.', + } as Record)[type] - const placeholderForField = (type: FieldType) => ({ + const placeholderForField = (type: FieldType): string => ({ text: 'Free text stored as a scalar value.', number: 'Enter a number', - }[type]) + select: 'Choose a value', + } as Record)[type] const plainNumberPattern = /^-?\d+(\.\d+)?$/ - const inputModeForField = (type: FieldType) => ({ + const inputModeForField = (type: FieldType): string => ({ text: 'text', number: 'decimal', - }[type]) + select: 'text', + } as Record)[type] + + const selectOptionsFor = (definition: FieldDefinition) => + (definition.options ?? []).map((opt: string) => ({ value: opt, label: opt })) + + const selectOptionFor = (fieldId: number) => { + const value = userDraftValues[fieldId]?.trim() + return value ? { value, label: value } : null + } + + const updateSelectValue = (fieldId: number, option: { value: string, label: string } | null) => { + userDraftValues[fieldId] = option?.value ?? '' + clearFieldError(fieldId) + } const rawDraftValueFor = (fieldId: number) => userDraftValues[fieldId]?.trim() ?? '' @@ -189,6 +219,13 @@ export default defineComponent({ return `${field.definition.label} must be a plain numeric value.` } + if (field.definition.type === 'select') { + const options = field.definition.options ?? [] + if (!options.includes(rawValue)) { + return `${field.definition.label} must be one of the allowed options.` + } + } + return null } @@ -268,7 +305,7 @@ export default defineComponent({ 'text fields expect a scalar value': `${field.definition.label} must be plain text.`, 'number fields expect a numeric value': `${field.definition.label} must be a numeric value.`, 'current_visibility is not supported': 'The selected visibility is not supported.', - }[message] ?? `${field.definition.label}: ${message}`) + }[message] ?? (message.includes('is not a valid option') ? `${field.definition.label}: invalid option selected.` : `${field.definition.label}: ${message}`)) } const extractApiMessage = (error: unknown) => { @@ -323,6 +360,7 @@ export default defineComponent({ } } + const currentPayload = (field: AdminEditableField) => { return { value: field.value?.value?.value ?? null, @@ -451,6 +489,9 @@ export default defineComponent({ userValueErrors, visibilityOptionFor, visibilityOptions, + selectOptionsFor, + selectOptionFor, + updateSelectValue, } }, }) From c1f1cef83a970f5efaa6dc8ac02d684406ef6bc7 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 00:23:10 -0300 Subject: [PATCH 27/54] feat(admin): redesign options editor with editable rows (Metavox pattern) Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/views/AdminSettings.vue | 78 +++++++++++++++++++++++-------------- 1 file changed, 48 insertions(+), 30 deletions(-) diff --git a/src/views/AdminSettings.vue b/src/views/AdminSettings.vue index 4a3c491..7fba958 100644 --- a/src/views/AdminSettings.vue +++ b/src/views/AdminSettings.vue @@ -159,24 +159,30 @@ SPDX-License-Identifier: AGPL-3.0-or-later

Define the values users and admins can pick from this field.

-
    -
  • - {{ option }} - - × +
    +
    + + + -
  • -
- -
- - +
+ + Add option @@ -239,8 +245,9 @@ SPDX-License-Identifier: AGPL-3.0-or-later