diff --git a/img/screenshots/admin-catalog-thumb.png b/img/screenshots/admin-catalog-thumb.png index a35520e..8dbf4db 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 876fa29..7d90d97 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 206458a..90f2589 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 6b60440..1b7de62 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 d67aed8..91e46b8 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 b54eb4a..bc7d1af 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 2581075..9bb15b7 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 d25a57e..53d1e05 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 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) { diff --git a/lib/Db/FieldDefinition.php b/lib/Db/FieldDefinition.php index bd5b33c..69f57d4 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 ef39368..99bad4f 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 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, ]; } 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, []); diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index cf22311..e40b6e0 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 eed1e9b..5a64b79 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 cd51a58..eccf9fb 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 9f2f2d6..c34b2d4 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 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), 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" + } } } } diff --git a/openapi-full.json b/openapi-full.json index a4130bc..728d488 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 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": { diff --git a/package-lock.json b/package-lock.json index 78b7f45..04a9e80 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 6a6e7c8..4834997 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 68278d2..4e77673 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 ff84a38..20a94ab 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 9237f2d..82562ed 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 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, } }, }) diff --git a/src/tests/utils/pedroPotiPersona.spec.ts b/src/tests/utils/pedroPotiPersona.spec.ts index a5cf882..0dfbf35 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 0000000..713ec59 --- /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 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[]; }; }; }; diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 29918ed..a11fa2e 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 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; }; diff --git a/src/utils/pedroPotiPersona.js b/src/utils/pedroPotiPersona.js index 71b27aa..321361f 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 0000000..7707dbc --- /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 92008aa..28ab723 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.` }} +

+
+ + +