diff --git a/img/screenshots/admin-catalog-thumb.png b/img/screenshots/admin-catalog-thumb.png index a35520ed..8dbf4db8 100644 Binary files a/img/screenshots/admin-catalog-thumb.png and b/img/screenshots/admin-catalog-thumb.png differ diff --git a/img/screenshots/admin-catalog.png b/img/screenshots/admin-catalog.png index 876fa292..7d90d975 100644 Binary files a/img/screenshots/admin-catalog.png and b/img/screenshots/admin-catalog.png differ diff --git a/img/screenshots/personal-settings-thumb.png b/img/screenshots/personal-settings-thumb.png index 206458a5..90f25895 100644 Binary files a/img/screenshots/personal-settings-thumb.png and b/img/screenshots/personal-settings-thumb.png differ diff --git a/img/screenshots/personal-settings.png b/img/screenshots/personal-settings.png index 6b604401..1b7de62d 100644 Binary files a/img/screenshots/personal-settings.png and b/img/screenshots/personal-settings.png differ diff --git a/img/screenshots/user-management-dialog-thumb.png b/img/screenshots/user-management-dialog-thumb.png index d67aed88..91e46b8a 100644 Binary files a/img/screenshots/user-management-dialog-thumb.png and b/img/screenshots/user-management-dialog-thumb.png differ diff --git a/img/screenshots/user-management-dialog.png b/img/screenshots/user-management-dialog.png index b54eb4a5..bc7d1afc 100644 Binary files a/img/screenshots/user-management-dialog.png and b/img/screenshots/user-management-dialog.png differ diff --git a/img/screenshots/workflow-notify-admins-thumb.png b/img/screenshots/workflow-notify-admins-thumb.png index 25810758..9bb15b73 100644 Binary files a/img/screenshots/workflow-notify-admins-thumb.png and b/img/screenshots/workflow-notify-admins-thumb.png differ diff --git a/img/screenshots/workflow-notify-admins.png b/img/screenshots/workflow-notify-admins.png index d25a57e6..53d1e051 100644 Binary files a/img/screenshots/workflow-notify-admins.png and b/img/screenshots/workflow-notify-admins.png differ diff --git a/lib/Controller/FieldDefinitionApiController.php b/lib/Controller/FieldDefinitionApiController.php index c0c86e47..9810cee0 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) { diff --git a/lib/Db/FieldDefinition.php b/lib/Db/FieldDefinition.php index bd5b33c9..69f57d49 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; @@ -73,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(), @@ -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), ]; diff --git a/lib/Db/FieldDefinitionMapper.php b/lib/Db/FieldDefinitionMapper.php index ef39368b..99bad4fa 100644 --- a/lib/Db/FieldDefinitionMapper.php +++ b/lib/Db/FieldDefinitionMapper.php @@ -33,6 +33,7 @@ public function findByFieldKey(string $fieldKey): ?FieldDefinition { 'initial_visibility', 'sort_order', 'active', + 'options', 'created_at', 'updated_at', ) @@ -59,6 +60,7 @@ public function findById(int $id): ?FieldDefinition { 'initial_visibility', 'sort_order', 'active', + 'options', 'created_at', 'updated_at', ) @@ -88,6 +90,7 @@ public function findAllOrdered(): array { 'initial_visibility', 'sort_order', 'active', + 'options', 'created_at', 'updated_at', ) @@ -114,6 +117,7 @@ public function findActiveOrdered(): array { 'initial_visibility', 'sort_order', 'active', + 'options', 'created_at', 'updated_at', ) diff --git a/lib/Enum/FieldType.php b/lib/Enum/FieldType.php index ebbf0595..fd7ee75e 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, ]; } diff --git a/lib/Migration/Version1000Date20260309120000.php b/lib/Migration/Version1000Date20260309120000.php index c68193c4..04c7882c 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, []); diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index cf223114..e40b6e0f 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, @@ -22,6 +22,7 @@ * initial_visibility?: string, * sort_order?: int, * active?: bool, + * options?: list, * } * @psalm-type ProfileFieldsDefinition = array{ * id: int, @@ -34,6 +35,7 @@ * initial_visibility: ProfileFieldsVisibility, * sort_order: int, * active: bool, + * options: list|null, * created_at: string, * updated_at: string, * } diff --git a/lib/Service/FieldDefinitionService.php b/lib/Service/FieldDefinitionService.php index eed1e9b5..5a64b795 100644 --- a/lib/Service/FieldDefinitionService.php +++ b/lib/Service/FieldDefinitionService.php @@ -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; @@ -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); @@ -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); diff --git a/lib/Service/FieldDefinitionValidator.php b/lib/Service/FieldDefinitionValidator.php index cd51a58c..eccf9fb7 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,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|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, @@ -89,6 +123,7 @@ public function validate(array $definition): array { * initial_visibility?: string, * sort_order?: int, * active?: bool, + * options?: list, * } $definition */ private function requireString(array $definition, string $key): string { diff --git a/lib/Service/FieldValueService.php b/lib/Service/FieldValueService.php index 9f2f2d67..c34b2d49 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_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|scalar $rawValue * @return array{value: int|float} diff --git a/lib/Workflow/UserProfileFieldCheck.php b/lib/Workflow/UserProfileFieldCheck.php index eae1c225..e863563d 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), diff --git a/openapi-administration.json b/openapi-administration.json index ecf87ec7..ac4e54ee 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" + } } } } diff --git a/openapi-full.json b/openapi-full.json index a4130bc0..728d4884 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" }, @@ -109,6 +117,12 @@ }, "active": { "type": "boolean" + }, + "options": { + "type": "array", + "items": { + "type": "string" + } } } }, @@ -256,7 +270,8 @@ "type": "string", "enum": [ "text", - "number" + "number", + "select" ] }, "ValuePayload": { @@ -451,6 +466,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 +629,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" + } } } } diff --git a/openapi.json b/openapi.json index 70d825fa..c66314fc 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": { diff --git a/package-lock.json b/package-lock.json index 78b7f45a..04a9e801 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,8 @@ "@nextcloud/l10n": "^3.4.1", "@nextcloud/router": "^3.1.0", "@nextcloud/vue": "^9.6.0", - "vue": "^3.5.29" + "vue": "^3.5.29", + "vuedraggable": "^4.1.0" }, "devDependencies": { "@nextcloud/browserslist-config": "^3.1.2", @@ -7263,6 +7264,12 @@ "dev": true, "license": "ISC" }, + "node_modules/sortablejs": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz", + "integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==", + "license": "MIT" + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -8848,6 +8855,18 @@ "vue": "3.x" } }, + "node_modules/vuedraggable": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz", + "integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==", + "license": "MIT", + "dependencies": { + "sortablejs": "1.14.0" + }, + "peerDependencies": { + "vue": "^3.0.1" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", diff --git a/package.json b/package.json index 6a6e7c85..4834997b 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "@nextcloud/l10n": "^3.4.1", "@nextcloud/router": "^3.1.0", "@nextcloud/vue": "^9.6.0", - "vue": "^3.5.29" + "vue": "^3.5.29", + "vuedraggable": "^4.1.0" }, "devDependencies": { "@nextcloud/browserslist-config": "^3.1.2", diff --git a/playwright/e2e/profile-fields.spec.ts b/playwright/e2e/profile-fields.spec.ts index 68278d2b..4e776738 100644 --- a/playwright/e2e/profile-fields.spec.ts +++ b/playwright/e2e/profile-fields.spec.ts @@ -8,6 +8,22 @@ import { createDefinition, deleteDefinitionByFieldKey } from '../support/profile const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' +const optionInput = (page, index: number) => page.getByTestId(`profile-fields-admin-option-row-${index}`).locator('input') + +const chooseFieldType = async(page, label: 'Text' | 'Number' | 'Select') => { + await page.getByTestId('profile-fields-admin-type-select').click() + await page.getByRole('option', { name: label, exact: true }).click() +} + +const openSelectDefinitionEditor = async(page, fieldKey: string, label: string) => { + await page.goto('./settings/admin/profile_fields') + await expect(page.getByTestId('profile-fields-admin')).toBeVisible() + + await page.getByTestId(`profile-fields-admin-definition-${fieldKey}`).click() + await expect(page.locator('#profile-fields-admin-label')).toHaveValue(label) + await expect(page.getByTestId('profile-fields-admin-option-row-0')).toBeVisible() +} + const collectEmbeddedLayoutMetrics = async(page, fieldKey: string) => { const aboutInput = page.getByRole('textbox', { name: 'About' }) const customField = page.getByTestId(`profile-fields-personal-field-${fieldKey}`) @@ -95,6 +111,207 @@ test('admin can create, update, and delete a field definition', async ({ page }) await deleteDefinitionByFieldKey(page.request, fieldKey) }) +test('admin gets an initial select option row and can remove empty rows by keyboard', async ({ page }) => { + const suffix = Date.now() + const fieldKey = `playwright_select_create_${suffix}` + const label = `Playwright select create ${suffix}` + + await deleteDefinitionByFieldKey(page.request, fieldKey) + + await page.goto('./settings/admin/profile_fields') + await expect(page.getByTestId('profile-fields-admin')).toBeVisible() + + await page.getByTestId('profile-fields-admin-new-field').click() + await page.locator('#profile-fields-admin-field-key').fill(fieldKey) + await page.locator('#profile-fields-admin-label').fill(label) + await chooseFieldType(page, 'Select') + + await expect(page.getByTestId('profile-fields-admin-option-row-0')).toBeVisible() + await expect(optionInput(page, 0)).toBeFocused() + await expect(optionInput(page, 0)).toHaveValue('') + await expect(page.locator('[data-testid^="profile-fields-admin-option-handle-"]')).toHaveCount(0) + + await optionInput(page, 0).fill('Alpha') + await optionInput(page, 0).press('Enter') + await expect(page.getByTestId('profile-fields-admin-option-row-1')).toBeVisible() + await expect(optionInput(page, 1)).toBeFocused() + + await page.locator('#profile-fields-admin-label').click() + await expect(page.getByTestId('profile-fields-admin-option-row-1')).toHaveCount(0) + + await optionInput(page, 0).press('Enter') + await expect(page.getByTestId('profile-fields-admin-option-row-1')).toBeVisible() + await expect(optionInput(page, 1)).toBeFocused() + + await optionInput(page, 1).press('Backspace') + await expect(page.getByTestId('profile-fields-admin-option-row-1')).toHaveCount(0) + await expect(optionInput(page, 0)).toBeFocused() + await expect(page.getByTestId('profile-fields-admin-option-handle-0')).toBeVisible() + + await page.getByTestId('profile-fields-admin-save').click() + await expect(page.getByTestId('profile-fields-admin-success')).toContainText('Field definition created.') + + await deleteDefinitionByFieldKey(page.request, fieldKey) +}) + +test('admin can bulk add select options from multiple lines', async ({ page }) => { + const suffix = Date.now() + const fieldKey = `playwright_select_bulk_${suffix}` + const label = `Playwright select bulk ${suffix}` + + await deleteDefinitionByFieldKey(page.request, fieldKey) + + await page.goto('./settings/admin/profile_fields') + await expect(page.getByTestId('profile-fields-admin')).toBeVisible() + + await page.getByTestId('profile-fields-admin-new-field').click() + await page.locator('#profile-fields-admin-field-key').fill(fieldKey) + await page.locator('#profile-fields-admin-label').fill(label) + await chooseFieldType(page, 'Select') + + await page.getByTestId('profile-fields-admin-add-multiple-options').click() + await page.getByTestId('profile-fields-admin-bulk-options-input').fill('Alpha\n\n Beta \nGamma') + await page.getByTestId('profile-fields-admin-bulk-options-submit').click() + + await expect(optionInput(page, 0)).toHaveValue('Alpha') + await expect(optionInput(page, 1)).toHaveValue('Beta') + await expect(optionInput(page, 2)).toHaveValue('Gamma') + await expect(page.locator('[data-testid^="profile-fields-admin-option-handle-"]')).toHaveCount(3) + + const continuationRow = page.getByTestId('profile-fields-admin-option-row-3') + if (await continuationRow.count()) { + await expect(continuationRow.locator('input')).toHaveValue('') + } + + await page.getByTestId('profile-fields-admin-save').click() + await expect(page.getByTestId('profile-fields-admin-success')).toContainText('Field definition created.') + + await deleteDefinitionByFieldKey(page.request, fieldKey) +}) + +test('admin reuses the empty select option row on repeated Enter', async ({ page }) => { + const suffix = Date.now() + const fieldKey = `playwright_select_${suffix}` + const label = `Playwright select ${suffix}` + + await deleteDefinitionByFieldKey(page.request, fieldKey) + await createDefinition(page.request, { + fieldKey, + label, + type: 'select', + options: ['Alpha', 'Beta', 'Gamma'], + }) + + try { + await openSelectDefinitionEditor(page, fieldKey, label) + await expect(optionInput(page, 0)).toHaveValue('Alpha') + await expect(optionInput(page, 1)).toHaveValue('Beta') + await expect(optionInput(page, 2)).toHaveValue('Gamma') + + await optionInput(page, 2).press('Enter') + await expect(page.getByTestId('profile-fields-admin-option-row-3')).toBeVisible() + await expect(optionInput(page, 3)).toHaveValue('') + await expect(optionInput(page, 3)).toBeFocused() + await expect(page.locator('[data-testid^="profile-fields-admin-option-handle-"]')).toHaveCount(3) + + await optionInput(page, 3).fill('Delta') + await expect(page.getByTestId('profile-fields-admin-option-handle-3')).toBeVisible() + await expect(page.locator('[data-testid^="profile-fields-admin-option-handle-"]')).toHaveCount(4) + + await optionInput(page, 3).press('Enter') + await expect(page.getByTestId('profile-fields-admin-option-row-4')).toBeVisible() + await expect(optionInput(page, 4)).toHaveValue('') + await expect(optionInput(page, 4)).toBeFocused() + await expect(page.getByTestId('profile-fields-admin-option-handle-3')).toBeVisible() + await expect(page.locator('[data-testid^="profile-fields-admin-option-handle-"]')).toHaveCount(4) + + await optionInput(page, 4).press('Enter') + await expect(page.getByTestId('profile-fields-admin-option-row-4')).toBeVisible() + await expect(page.getByTestId('profile-fields-admin-option-row-5')).toHaveCount(0) + await expect(optionInput(page, 4)).toBeFocused() + await expect(page.locator('[data-testid^="profile-fields-admin-option-handle-"]')).toHaveCount(4) + + await page.getByTestId('profile-fields-admin-option-handle-3').click() + await page.getByRole('menuitem', { name: 'Move up' }).click() + + await expect(optionInput(page, 0)).toHaveValue('Alpha') + await expect(optionInput(page, 1)).toHaveValue('Beta') + await expect(optionInput(page, 2)).toHaveValue('Delta') + await expect(optionInput(page, 3)).toHaveValue('Gamma') + + await page.getByTestId('profile-fields-admin-option-handle-2').dragTo(page.getByTestId('profile-fields-admin-option-handle-1')) + + await expect(optionInput(page, 0)).toHaveValue('Alpha') + await expect(optionInput(page, 1)).toHaveValue('Delta') + await expect(optionInput(page, 2)).toHaveValue('Beta') + await expect(optionInput(page, 3)).toHaveValue('Gamma') + await expect(page.getByTestId('profile-fields-admin-option-row-4')).toHaveCount(0) + + await page.getByTestId('profile-fields-admin-save').click() + await expect(page.getByTestId('profile-fields-admin-success')).toContainText('Field definition updated.') + + await page.reload() + await openSelectDefinitionEditor(page, fieldKey, label) + await expect(optionInput(page, 0)).toHaveValue('Alpha') + await expect(optionInput(page, 1)).toHaveValue('Delta') + await expect(optionInput(page, 2)).toHaveValue('Beta') + await expect(optionInput(page, 3)).toHaveValue('Gamma') + await expect(page.getByTestId('profile-fields-admin-option-row-4')).toHaveCount(0) + } finally { + await deleteDefinitionByFieldKey(page.request, fieldKey) + } +}) + +test('admin can reorder select options from the handle menu and drag handle', async ({ page }) => { + const suffix = Date.now() + const fieldKey = `playwright_reorder_${suffix}` + const label = `Playwright reorder ${suffix}` + + await deleteDefinitionByFieldKey(page.request, fieldKey) + await createDefinition(page.request, { + fieldKey, + label, + type: 'select', + options: ['Alpha', 'Beta', 'Gamma', 'Delta'], + }) + + try { + await openSelectDefinitionEditor(page, fieldKey, label) + await expect(optionInput(page, 0)).toHaveValue('Alpha') + await expect(optionInput(page, 1)).toHaveValue('Beta') + await expect(optionInput(page, 2)).toHaveValue('Gamma') + await expect(optionInput(page, 3)).toHaveValue('Delta') + await expect(page.locator('[data-testid^="profile-fields-admin-option-handle-"]')).toHaveCount(4) + + await page.getByTestId('profile-fields-admin-option-handle-3').click() + await page.getByRole('menuitem', { name: 'Move up' }).click() + + await expect(optionInput(page, 0)).toHaveValue('Alpha') + await expect(optionInput(page, 1)).toHaveValue('Beta') + await expect(optionInput(page, 2)).toHaveValue('Delta') + await expect(optionInput(page, 3)).toHaveValue('Gamma') + + await page.getByTestId('profile-fields-admin-option-handle-2').dragTo(page.getByTestId('profile-fields-admin-option-handle-1')) + + await expect(optionInput(page, 0)).toHaveValue('Alpha') + await expect(optionInput(page, 1)).toHaveValue('Delta') + await expect(optionInput(page, 2)).toHaveValue('Beta') + await expect(optionInput(page, 3)).toHaveValue('Gamma') + + await page.getByTestId('profile-fields-admin-save').click() + await expect(page.getByTestId('profile-fields-admin-success')).toContainText('Field definition updated.') + + await page.reload() + await openSelectDefinitionEditor(page, fieldKey, label) + await expect(optionInput(page, 0)).toHaveValue('Alpha') + await expect(optionInput(page, 1)).toHaveValue('Delta') + await expect(optionInput(page, 2)).toHaveValue('Beta') + await expect(optionInput(page, 3)).toHaveValue('Gamma') + } finally { + await deleteDefinitionByFieldKey(page.request, fieldKey) + } +}) + test('embedded personal settings autosave a user-visible field', async ({ page }) => { const suffix = Date.now() const fieldKey = `playwright_personal_${suffix}` diff --git a/playwright/generate-screenshots.mjs b/playwright/generate-screenshots.mjs index ff84a38a..20a94abb 100644 --- a/playwright/generate-screenshots.mjs +++ b/playwright/generate-screenshots.mjs @@ -245,8 +245,11 @@ const prepareWorkflowScreenshot = async(page) => { const showMoreButton = page.getByRole('button', { name: 'Show more', exact: true }) if (await showMoreButton.count() > 0) { await showMoreButton.click() + await page.getByRole('button', { name: 'Show less', exact: true }).waitFor({ state: 'visible', timeout: 60_000 }) } + await page.getByRole('heading', { name: 'Create Talk conversation', exact: true }).first().waitFor({ state: 'visible', timeout: 60_000 }) + await page.waitForTimeout(800) await page.evaluate(() => { document.querySelector('header')?.setAttribute('style', 'display:none') @@ -307,6 +310,7 @@ const run = async() => { fieldKey: field.fieldKey, label: field.label, type: field.type, + ...(field.type === 'select' ? { options: field.options ?? [] } : {}), adminOnly: field.adminOnly, userEditable: field.userEditable, userVisible: field.userVisible, @@ -342,7 +346,7 @@ const run = async() => { await adminPage.goto('./settings/admin/profile_fields') await adminPage.getByTestId('profile-fields-admin-definition-showcase_support_region').waitFor({ state: 'visible', timeout: 60_000 }) await hideNonShowcaseAdminDefinitions(adminPage) - await adminPage.getByTestId('profile-fields-admin-definition-showcase_support_region').click() + await adminPage.getByTestId('profile-fields-admin-definition-showcase_council_channel').click() await adminPage.locator('[data-testid="profile-fields-admin"]').screenshot({ path: join(screenshotDir, 'admin-catalog.png'), type: 'png' }) const personalPage = await demoContext.newPage() diff --git a/playwright/support/profile-fields.ts b/playwright/support/profile-fields.ts index 9237f2d8..82562eda 100644 --- a/playwright/support/profile-fields.ts +++ b/playwright/support/profile-fields.ts @@ -3,7 +3,7 @@ import type { APIRequestContext } from '@playwright/test' -type FieldType = 'text' | 'number' +type FieldType = 'text' | 'number' | 'select' type FieldVisibility = 'private' | 'users' | 'public' type FieldDefinition = { @@ -11,6 +11,7 @@ type FieldDefinition = { field_key: string label: string type: FieldType + options: string[] | null admin_only: boolean user_editable: boolean user_visible: boolean @@ -36,6 +37,7 @@ type DefinitionPayload = { fieldKey: string label: string type?: FieldType + options?: string[] adminOnly?: boolean userEditable?: boolean userVisible?: boolean @@ -102,6 +104,7 @@ export async function createDefinition( fieldKey: payload.fieldKey, label: payload.label, type: payload.type ?? 'text', + ...(payload.type === 'select' ? { options: payload.options ?? [] } : {}), adminOnly: payload.adminOnly ?? false, userEditable: payload.userEditable ?? true, userVisible: payload.userVisible ?? true, diff --git a/src/components/AdminUserFieldsDialog.vue b/src/components/AdminUserFieldsDialog.vue index 9b85c42b..671d179a 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, } }, }) diff --git a/src/tests/utils/pedroPotiPersona.spec.ts b/src/tests/utils/pedroPotiPersona.spec.ts index a5cf8826..0dfbf352 100644 --- a/src/tests/utils/pedroPotiPersona.spec.ts +++ b/src/tests/utils/pedroPotiPersona.spec.ts @@ -26,6 +26,7 @@ describe('pedroPotiPersona', () => { 'Community', 'Letter alias', 'Leadership role', + 'Council channel', 'Council rank', ]) @@ -34,5 +35,6 @@ describe('pedroPotiPersona', () => { expect(showcaseValueByKey.get('showcase_product_specialty')).toBe('Tupi correspondence') expect(showcaseValueByKey.get('showcase_customer_segment')).toBe('Potiguara communities') expect(showcaseValueByKey.get('showcase_incident_role')).toBe('Regedor of Paraiba') + expect(showcaseValueByKey.get('showcase_council_channel')).toBe('Village assembly') }) }) diff --git a/src/tests/utils/selectFieldOptions.spec.ts b/src/tests/utils/selectFieldOptions.spec.ts new file mode 100644 index 00000000..713ec595 --- /dev/null +++ b/src/tests/utils/selectFieldOptions.spec.ts @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { describe, expect, it } from 'vitest' + +import { + createEditableSelectOptions, + extractEditableSelectOptionValues, + normalizeEditableSelectOptionValue, + parseEditableSelectOptionValues, + moveEditableSelectOption, +} from '../../utils/selectFieldOptions.ts' + +describe('selectFieldOptions', () => { + it('creates draggable options with stable ids and preserves values', () => { + let nextId = 0 + const createId = () => `option-${nextId++}` + + expect(createEditableSelectOptions(['Alpha', 'Beta'], createId)).toEqual([ + { id: 'option-0', value: 'Alpha' }, + { id: 'option-1', value: 'Beta' }, + ]) + }) + + it('extracts option values for persistence', () => { + expect(extractEditableSelectOptionValues([ + { id: 'option-0', value: 'Alpha' }, + { id: 'option-1', value: 'Beta' }, + ])).toEqual(['Alpha', 'Beta']) + }) + + it('normalizes option values for duplicate detection', () => { + expect(normalizeEditableSelectOptionValue(' Alpha ')).toBe('alpha') + expect(normalizeEditableSelectOptionValue('BeTa')).toBe('beta') + }) + + it('parses multiple option lines and ignores blanks', () => { + expect(parseEditableSelectOptionValues('Alpha\n\n Beta \n \nGamma')).toEqual(['Alpha', 'Beta', 'Gamma']) + }) + + it('moves an option one position up or down', () => { + const options = [ + { id: 'option-0', value: 'Alpha' }, + { id: 'option-1', value: 'Beta' }, + { id: 'option-2', value: 'Gamma' }, + ] + + expect(moveEditableSelectOption(options, 1, -1).map(({ id }) => id)).toEqual(['option-1', 'option-0', 'option-2']) + expect(moveEditableSelectOption(options, 1, 1).map(({ id }) => id)).toEqual(['option-0', 'option-2', 'option-1']) + }) + + it('returns the original array when the move is out of bounds', () => { + const options = [ + { id: 'option-0', value: 'Alpha' }, + { id: 'option-1', value: 'Beta' }, + ] + + expect(moveEditableSelectOption(options, 0, -1)).toBe(options) + expect(moveEditableSelectOption(options, 1, 1)).toBe(options) + }) +}) diff --git a/src/types/openapi/openapi-administration.ts b/src/types/openapi/openapi-administration.ts index 20bcbf2f..53aeb9a0 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[]; }; }; }; diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 29918edb..a11fa2eb 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; }; @@ -231,6 +232,7 @@ export interface components { /** Format: int64 */ sort_order?: number; active?: boolean; + options?: string[]; }; EditableField: { definition: components["schemas"]["Definition"]; @@ -274,7 +276,7 @@ export interface components { }; }; /** @enum {string} */ - Type: "text" | "number"; + Type: "text" | "number" | "select"; ValuePayload: { value: Record; }; @@ -378,6 +380,11 @@ export interface operations { * @default true */ active?: boolean; + /** + * @description Allowed values for select fields (ignored for other types) + * @default [] + */ + options?: string[]; }; }; }; @@ -465,6 +472,11 @@ export interface operations { * @default true */ active?: boolean; + /** + * @description Allowed values for select fields (ignored for other types) + * @default [] + */ + options?: string[]; }; }; }; diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 3bb4c5c6..1ab2e3d5 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; }; diff --git a/src/utils/pedroPotiPersona.js b/src/utils/pedroPotiPersona.js index 71b27aa4..321361fc 100644 --- a/src/utils/pedroPotiPersona.js +++ b/src/utils/pedroPotiPersona.js @@ -77,6 +77,19 @@ export const pedroPotiPersona = { adminValue: { value: 'Administrative overseer', currentVisibility: 'users' }, demoValue: { value: 'Regedor of Paraiba', currentVisibility: 'users' }, }, + { + fieldKey: 'showcase_council_channel', + label: 'Council channel', + type: 'select', + options: ['Council messenger', 'Harbor liaison', 'Village assembly'], + adminOnly: false, + userEditable: true, + userVisible: true, + initialVisibility: 'users', + sortOrder: 55, + adminValue: { value: 'Council messenger', currentVisibility: 'users' }, + demoValue: { value: 'Village assembly', currentVisibility: 'users' }, + }, { fieldKey: 'showcase_on_call_tier', label: 'Council rank', diff --git a/src/utils/selectFieldOptions.ts b/src/utils/selectFieldOptions.ts new file mode 100644 index 00000000..7707dbc8 --- /dev/null +++ b/src/utils/selectFieldOptions.ts @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +export interface EditableSelectOption { + id: string + value: string +} + +export const createEditableSelectOptions = ( + values: string[], + createId: () => string, +): EditableSelectOption[] => values.map((value) => ({ + id: createId(), + value, +})) + +export const normalizeEditableSelectOptionValue = (value: string): string => value.trim().toLowerCase() + +export const parseEditableSelectOptionValues = (value: string): string[] => value + .split(/\r?\n/g) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + +export const extractEditableSelectOptionValues = (options: EditableSelectOption[]): string[] => options.map(({ value }) => value) + +export const moveEditableSelectOption = ( + options: EditableSelectOption[], + index: number, + direction: -1 | 1, +): EditableSelectOption[] => { + const targetIndex = index + direction + if (index < 0 || index >= options.length || targetIndex < 0 || targetIndex >= options.length) { + return options + } + + const reordered = [...options] + const [selected] = reordered.splice(index, 1) + reordered.splice(targetIndex, 0, selected) + return reordered +} diff --git a/src/views/AdminSettings.vue b/src/views/AdminSettings.vue index 92008aaa..28ab7235 100644 --- a/src/views/AdminSettings.vue +++ b/src/views/AdminSettings.vue @@ -124,6 +124,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later
+
+
+

Options

+

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

+

Press Enter to create the next option. Empty rows are reused, removed on blur, and Backspace or Delete removes an empty row. Use Add multiple options to paste one option per line.

+
+ + + + + +
+ + Add option + + + Add multiple options + +
+
+

Permissions

@@ -188,7 +271,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later
- + {{ isSaving ? 'Saving...' : (isEditing ? 'Save changes' : 'Create field') }} @@ -206,20 +289,61 @@ SPDX-License-Identifier: AGPL-3.0-or-later
+ + +
+ +

+ {{ bulkOptionValues.length === 1 ? '1 option ready to add.' : `${bulkOptionValues.length} options ready to add.` }} +

+
+ + +