diff --git a/img/screenshots/admin-catalog-thumb.png b/img/screenshots/admin-catalog-thumb.png index 9e6fa2d..a4dba7d 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 a2925f6..e1e7561 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 6a5e8f0..470e041 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 8788537..872e6fb 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 f3ec5de..bba7c7d 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 8c713c6..1243038 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 8886f90..207af30 100644 Binary files a/img/screenshots/workflow-notify-admins-thumb.png and b/img/screenshots/workflow-notify-admins-thumb.png differ diff --git a/lib/Enum/FieldType.php b/lib/Enum/FieldType.php index fd7ee75..e1f4d9c 100644 --- a/lib/Enum/FieldType.php +++ b/lib/Enum/FieldType.php @@ -13,6 +13,7 @@ enum FieldType: string { case TEXT = 'text'; case NUMBER = 'number'; case SELECT = 'select'; + case MULTISELECT = 'multiselect'; /** * @return list @@ -22,6 +23,7 @@ public static function values(): array { self::TEXT->value, self::NUMBER->value, self::SELECT->value, + self::MULTISELECT->value, ]; } diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 28fec42..60f5a7d 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -10,7 +10,7 @@ namespace OCA\ProfileFields; /** - * @psalm-type ProfileFieldsType = 'text'|'number'|'select' + * @psalm-type ProfileFieldsType = 'text'|'number'|'select'|'multiselect' * @psalm-type ProfileFieldsVisibility = 'private'|'users'|'public' * @psalm-type ProfileFieldsEditPolicy = 'admins'|'users' * @psalm-type ProfileFieldsExposurePolicy = 'hidden'|'private'|'users'|'public' diff --git a/lib/Service/FieldDefinitionValidator.php b/lib/Service/FieldDefinitionValidator.php index c0e81cf..853af15 100644 --- a/lib/Service/FieldDefinitionValidator.php +++ b/lib/Service/FieldDefinitionValidator.php @@ -29,7 +29,7 @@ class FieldDefinitionValidator { * @return array{ * field_key: non-empty-string, * label: non-empty-string, - * type: 'text'|'number'|'select', + * type: 'text'|'number'|'select'|'multiselect', * edit_policy: 'admins'|'users', * exposure_policy: 'hidden'|'private'|'users'|'public', * sort_order: int, @@ -78,12 +78,13 @@ public function validate(array $definition): array { * @return list|null */ private function validateOptions(string $type, mixed $options): ?array { - if ($type !== FieldType::SELECT->value) { + $isSelectLike = in_array($type, [FieldType::SELECT->value, FieldType::MULTISELECT->value], true); + if (!$isSelectLike) { return null; } if (!is_array($options) || count($options) === 0) { - throw new InvalidArgumentException('select fields require at least one option'); + throw new InvalidArgumentException($type . ' fields require at least one option'); } $normalized = []; diff --git a/lib/Service/FieldValueService.php b/lib/Service/FieldValueService.php index a597e18..5b25160 100644 --- a/lib/Service/FieldValueService.php +++ b/lib/Service/FieldValueService.php @@ -107,6 +107,7 @@ public function normalizeValue(FieldDefinition $definition, array|string|int|flo FieldType::TEXT => $this->normalizeTextValue($rawValue), FieldType::NUMBER => $this->normalizeNumberValue($rawValue), FieldType::SELECT => $this->normalizeSelectValue($rawValue, $definition), + FieldType::MULTISELECT => $this->normalizeMultiSelectValue($rawValue, $definition), }; } @@ -255,6 +256,36 @@ private function normalizeSelectValue(array|string|int|float|bool $rawValue, Fie return ['value' => $value]; } + /** + * @param array|scalar $rawValue + * @return array{value: list} + */ + private function normalizeMultiSelectValue(array|string|int|float|bool $rawValue, FieldDefinition $definition): array { + if (!is_array($rawValue) || !array_is_list($rawValue)) { + throw new InvalidArgumentException($this->l10n->t('Multiselect fields require one or more configured option values.')); + } + + $options = json_decode($definition->getOptions() ?? '[]', true); + $normalized = []; + foreach ($rawValue as $candidate) { + if (!is_string($candidate) || trim($candidate) === '') { + throw new InvalidArgumentException($this->l10n->t('Multiselect fields require one or more configured option values.')); + } + + $value = trim($candidate); + if (!in_array($value, $options, true)) { + // TRANSLATORS %s is an invalid option value provided by the user. + throw new InvalidArgumentException($this->l10n->t('"%s" is not a valid option for this field', [$value])); + } + + if (!in_array($value, $normalized, true)) { + $normalized[] = $value; + } + } + + return ['value' => $normalized]; + } + /** * @param array|scalar $rawValue * @return array{value: int|float} diff --git a/lib/Workflow/UserProfileFieldCheck.php b/lib/Workflow/UserProfileFieldCheck.php index e863563..af4cfd7 100644 --- a/lib/Workflow/UserProfileFieldCheck.php +++ b/lib/Workflow/UserProfileFieldCheck.php @@ -106,7 +106,11 @@ public function validateCheck($operator, $value) { if ($this->operatorRequiresValue((string)$operator)) { try { - $this->fieldValueService->normalizeValue($definition, $config['value']); + if (FieldType::from($definition->getType()) === FieldType::MULTISELECT) { + $this->normalizeExpectedMultiSelectOperand($definition, $config['value']); + } else { + $this->fieldValueService->normalizeValue($definition, $config['value']); + } } catch (InvalidArgumentException $exception) { throw new \UnexpectedValueException($this->l10n->t('The configured comparison value is invalid'), 4, $exception); } @@ -163,6 +167,7 @@ private function isOperatorSupported(FieldDefinition $definition, string $operat FieldType::TEXT => self::TEXT_OPERATORS, FieldType::NUMBER => self::NUMBER_OPERATORS, FieldType::SELECT => self::SELECT_OPERATORS, + FieldType::MULTISELECT => self::SELECT_OPERATORS, }; return in_array($operator, $operators, true); @@ -172,7 +177,10 @@ private function operatorRequiresValue(string $operator): bool { return !in_array($operator, [self::OPERATOR_IS_SET, self::OPERATOR_IS_NOT_SET], true); } - private function extractActualValue(?FieldValue $value): string|int|float|bool|null { + /** + * @return string|int|float|bool|list|null + */ + private function extractActualValue(?FieldValue $value): string|int|float|bool|array|null { if ($value === null) { return null; } @@ -180,11 +188,27 @@ private function extractActualValue(?FieldValue $value): string|int|float|bool|n $serialized = $this->fieldValueService->serializeForResponse($value); $payload = $serialized['value']['value'] ?? null; - return is_array($payload) || is_object($payload) ? null : $payload; + if (is_array($payload) && array_is_list($payload)) { + $normalized = []; + foreach ($payload as $candidate) { + if (is_string($candidate)) { + $normalized[] = $candidate; + } + } + + return $normalized; + } + + return is_object($payload) ? null : $payload; } - private function evaluate(FieldDefinition $definition, string $operator, string|int|float|bool|null $expectedRawValue, string|int|float|bool|null $actualValue): bool { - $isSet = $actualValue !== null && $actualValue !== ''; + /** + * @param string|int|float|bool|list|null $actualValue + */ + private function evaluate(FieldDefinition $definition, string $operator, string|int|float|bool|null $expectedRawValue, string|int|float|bool|array|null $actualValue): bool { + $isSet = $actualValue !== null + && $actualValue !== '' + && (!is_array($actualValue) || count($actualValue) > 0); if ($operator === self::OPERATOR_IS_SET) { return $isSet; } @@ -195,10 +219,22 @@ private function evaluate(FieldDefinition $definition, string $operator, string| return false; } + $fieldType = FieldType::from($definition->getType()); + + if ($fieldType === FieldType::MULTISELECT) { + if (!is_array($actualValue)) { + return false; + } + + $expectedValue = $this->normalizeExpectedMultiSelectOperand($definition, $expectedRawValue); + + return $this->evaluateMultiSelectOperator($operator, $expectedValue, $actualValue); + } + $normalizedExpected = $this->fieldValueService->normalizeValue($definition, $expectedRawValue); $expectedValue = $normalizedExpected['value'] ?? null; - return match (FieldType::from($definition->getType())) { + return match ($fieldType) { FieldType::TEXT, FieldType::SELECT => $this->evaluateTextOperator($operator, (string)$expectedValue, (string)$actualValue), FieldType::NUMBER => $this->evaluateNumberOperator( @@ -206,9 +242,37 @@ private function evaluate(FieldDefinition $definition, string $operator, string| $this->normalizeNumericComparisonOperand($expectedValue), $this->normalizeNumericComparisonOperand($actualValue), ), + FieldType::MULTISELECT => false, }; } + /** + * @param list $actualValues + */ + private function evaluateMultiSelectOperator(string $operator, string $expectedValue, array $actualValues): bool { + $contains = in_array($expectedValue, $actualValues, true); + + return match ($operator) { + 'is' => $contains, + '!is' => !$contains, + default => false, + }; + } + + private function normalizeExpectedMultiSelectOperand(FieldDefinition $definition, string|int|float|bool|null $expectedRawValue): string { + if (!is_string($expectedRawValue)) { + throw new InvalidArgumentException('multiselect comparison value must be one configured option'); + } + + $value = trim($expectedRawValue); + $options = json_decode($definition->getOptions() ?? '[]', true); + if ($value === '' || !is_array($options) || !in_array($value, $options, true)) { + throw new InvalidArgumentException('multiselect comparison value must be one configured option'); + } + + return $value; + } + private function normalizeNumericComparisonOperand(string|int|float|bool|null $value): int|float { if (is_int($value) || is_float($value)) { return $value; diff --git a/openapi-administration.json b/openapi-administration.json index 256cd6c..5e4d492 100644 --- a/openapi-administration.json +++ b/openapi-administration.json @@ -198,7 +198,8 @@ "enum": [ "text", "number", - "select" + "select", + "multiselect" ] }, "ValuePayload": { diff --git a/openapi-full.json b/openapi-full.json index 9160a38..450afe6 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -273,7 +273,8 @@ "enum": [ "text", "number", - "select" + "select", + "multiselect" ] }, "ValuePayload": { diff --git a/openapi.json b/openapi.json index fa66e1d..ee04377 100644 --- a/openapi.json +++ b/openapi.json @@ -144,7 +144,8 @@ "enum": [ "text", "number", - "select" + "select", + "multiselect" ] }, "ValuePayload": { diff --git a/src/components/AdminUserFieldsDialog.vue b/src/components/AdminUserFieldsDialog.vue index 9cae92d..61ad08c 100644 --- a/src/components/AdminUserFieldsDialog.vue +++ b/src/components/AdminUserFieldsDialog.vue @@ -59,6 +59,20 @@ SPDX-License-Identifier: AGPL-3.0-or-later :placeholder="placeholderForField(field.definition.type)" @update:model-value="updateSelectValue(field.definition.id, $event)" /> + ([]) const userValueErrors = reactive>({}) - const userDraftValues = reactive>({}) + const userDraftValues = reactive>({}) const userDraftVisibilities = reactive>({}) const headerUserName = computed(() => props.userDisplayName.trim() !== '' ? props.userDisplayName : props.userUid) @@ -183,12 +197,14 @@ export default defineComponent({ text: t('profile_fields', 'Free text stored as a scalar value.'), number: t('profile_fields', 'Only numeric values are accepted.'), select: t('profile_fields', 'Choose one of the predefined options.'), + multiselect: t('profile_fields', 'Choose one or more predefined options.'), } as Record)[type] const placeholderForField = (type: FieldType): string => ({ text: t('profile_fields', 'Enter a value'), number: t('profile_fields', 'Enter a number'), select: t('profile_fields', 'Select an option'), + multiselect: t('profile_fields', 'Select one or more options'), } as Record)[type] const plainNumberPattern = /^-?\d+(\.\d+)?$/ @@ -197,13 +213,15 @@ export default defineComponent({ text: 'text', number: 'decimal', select: 'text', + multiselect: '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() + const current = userDraftValues[fieldId] + const value = typeof current === 'string' ? current.trim() : '' return value ? { value, label: value } : null } @@ -212,7 +230,24 @@ export default defineComponent({ clearFieldError(fieldId) } - const rawDraftValueFor = (fieldId: number) => userDraftValues[fieldId]?.trim() ?? '' + const multiselectOptionValuesFor = (fieldId: number) => { + const current = userDraftValues[fieldId] + if (!Array.isArray(current)) { + return [] + } + + return current.map((value: string) => ({ value, label: value })) + } + + const updateMultiSelectValue = (fieldId: number, options: Array<{ value: string, label: string }> | null) => { + userDraftValues[fieldId] = options?.map((option) => option.value) ?? [] + clearFieldError(fieldId) + } + + const rawDraftValueFor = (fieldId: number) => { + const current = userDraftValues[fieldId] + return typeof current === 'string' ? current.trim() : '' + } const validateField = (field: AdminEditableField): string | null => { const rawValue = rawDraftValueFor(field.definition.id) @@ -234,6 +269,18 @@ export default defineComponent({ } } + if (field.definition.type === 'multiselect') { + const current = userDraftValues[field.definition.id] + if (!Array.isArray(current)) { + return t('profile_fields', '{fieldLabel} must be one of the allowed options.', { fieldLabel: field.definition.label }) + } + + const options = field.definition.options ?? [] + if (current.some((value: string) => !options.includes(value))) { + return t('profile_fields', '{fieldLabel} must be one of the allowed options.', { fieldLabel: field.definition.label }) + } + } + return null } @@ -254,7 +301,11 @@ export default defineComponent({ const normaliseDraft = (field: AdminEditableField) => { const currentValue = field.value?.value - userDraftValues[field.definition.id] = currentValue?.value?.toString() ?? '' + if (Array.isArray(currentValue?.value)) { + userDraftValues[field.definition.id] = [...currentValue.value] + } else { + userDraftValues[field.definition.id] = currentValue?.value?.toString() ?? '' + } userDraftVisibilities[field.definition.id] = field.value?.current_visibility ?? definitionDefaultVisibility(field.definition) delete userValueErrors[field.definition.id] @@ -343,13 +394,14 @@ export default defineComponent({ } const payloadsMatch = ( - left: { value?: string | number | null, visibility: FieldVisibility }, - right: { value?: string | number | null, visibility: FieldVisibility }, + left: { value?: string | number | string[] | null, visibility: FieldVisibility }, + right: { value?: string | number | string[] | null, visibility: FieldVisibility }, ) => left.visibility === right.visibility - && (left.value ?? null) === (right.value ?? null) + && JSON.stringify(left.value ?? null) === JSON.stringify(right.value ?? null) const buildPayload = (field: AdminEditableField) => { - const rawValue = userDraftValues[field.definition.id]?.trim() ?? '' + const current = userDraftValues[field.definition.id] + const rawValue = typeof current === 'string' ? current.trim() : '' if (field.definition.type === 'number') { if (rawValue === '') { @@ -365,6 +417,18 @@ export default defineComponent({ return { value: numericValue, visibility: userDraftVisibilities[field.definition.id] } } + if (field.definition.type === 'multiselect') { + const selectedValues = userDraftValues[field.definition.id] + if (!Array.isArray(selectedValues)) { + throw new Error(t('profile_fields', 'Multiselect fields must use predefined options.')) + } + + return { + value: selectedValues, + visibility: userDraftVisibilities[field.definition.id], + } + } + return { value: rawValue === '' ? null : rawValue, visibility: userDraftVisibilities[field.definition.id], @@ -505,7 +569,9 @@ export default defineComponent({ visibilityOptions, selectOptionsFor, selectOptionFor, + multiselectOptionValuesFor, updateSelectValue, + updateMultiSelectValue, } }, }) diff --git a/src/types/index.ts b/src/types/index.ts index 7ee701e..afc532b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -19,7 +19,7 @@ type ApiOperationRequestBody = TOperation extends { type ApiRequestJsonBody = ApiJsonBody> -export type FieldType = ApiComponents['schemas']['Type'] +export type FieldType = ApiComponents['schemas']['Type'] | 'multiselect' export type FieldVisibility = ApiComponents['schemas']['Visibility'] export type FieldEditPolicy = 'admins' | 'users' export type FieldExposurePolicy = 'hidden' | FieldVisibility @@ -34,7 +34,7 @@ export type FieldDefinition = Omit. // Keep the surrounding contract generated and widen only this payload leaf for frontend use. export type FieldValuePayload = Omit & { - value?: string | number | null + value?: string | number | string[] | null } export type FieldValueRecord = Omit & { value: FieldValuePayload @@ -70,9 +70,13 @@ export type UpdateDefinitionPayload = { active?: boolean options?: string[] } -export type UpsertOwnValuePayload = ApiRequestJsonBody +export type UpsertOwnValuePayload = Omit, 'value'> & { + value?: string | number | string[] | null +} export type UpdateOwnVisibilityPayload = ApiRequestJsonBody -export type UpsertAdminUserValuePayload = ApiRequestJsonBody +export type UpsertAdminUserValuePayload = Omit, 'value'> & { + value?: string | number | string[] | null +} export type AdminEditableField = { definition: FieldDefinition diff --git a/src/types/openapi/openapi-administration.ts b/src/types/openapi/openapi-administration.ts index d050176..352a7ca 100644 --- a/src/types/openapi/openapi-administration.ts +++ b/src/types/openapi/openapi-administration.ts @@ -193,7 +193,7 @@ export type components = { }; }; /** @enum {string} */ - Type: "text" | "number" | "select"; + Type: "text" | "number" | "select" | "multiselect"; ValuePayload: { value: Record; }; diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 1603a5e..da7f5b1 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -276,7 +276,7 @@ export type components = { }; }; /** @enum {string} */ - Type: "text" | "number" | "select"; + Type: "text" | "number" | "select" | "multiselect"; ValuePayload: { value: Record; }; diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index aa0db2a..a27820c 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -100,7 +100,7 @@ export type components = { itemsperpage?: string; }; /** @enum {string} */ - Type: "text" | "number" | "select"; + Type: "text" | "number" | "select" | "multiselect"; ValuePayload: { value: Record; }; diff --git a/src/views/AdminSettings.vue b/src/views/AdminSettings.vue index c1a420b..3a54309 100644 --- a/src/views/AdminSettings.vue +++ b/src/views/AdminSettings.vue @@ -229,7 +229,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later - +
@@ -288,6 +288,7 @@ const fieldTypeOptions: Array<{ value: FieldType, label: string }> = [ { value: 'text', label: t('profile_fields', 'Text') }, { value: 'number', label: t('profile_fields', 'Number') }, { value: 'select', label: t('profile_fields', 'Select') }, + { value: 'multiselect', label: t('profile_fields', 'Multiselect') }, ] const editPolicyOptions: Array<{ value: FieldEditPolicy, label: string }> = [ @@ -407,7 +408,7 @@ const buildFormState = () => ({ exposurePolicy: form.exposurePolicy, sortOrder: Number(form.sortOrder), active: form.active, - options: form.type === 'select' ? extractEditableSelectOptionValues(form.options).filter((optionValue: string) => optionValue.trim() !== '') : [], + options: (form.type === 'select' || form.type === 'multiselect') ? extractEditableSelectOptionValues(form.options).filter((optionValue: string) => optionValue.trim() !== '') : [], }) const buildDefinitionState = (definition: FieldDefinition | null) => { @@ -432,7 +433,7 @@ const buildDefinitionState = (definition: FieldDefinition | null) => { exposurePolicy: definition.exposure_policy, sortOrder: definition.sort_order, active: definition.active, - options: definition.type === 'select' ? (definition.options ?? []) : [], + options: (definition.type === 'select' || definition.type === 'multiselect') ? (definition.options ?? []) : [], } } @@ -458,7 +459,7 @@ const hasRequiredFields = computed(() => { return false } - if (form.type === 'select' && normalizedOptionCount.value === 0) { + if ((form.type === 'select' || form.type === 'multiselect') && normalizedOptionCount.value === 0) { return false } @@ -546,7 +547,7 @@ const buildDefinitionUpdatePayload = (definition: FieldDefinition, sortOrder: nu exposurePolicy: definition.exposure_policy, sortOrder, active: definition.active, - ...(definition.type === 'select' ? { options: definition.options ?? [] } : {}), + ...((definition.type === 'select' || definition.type === 'multiselect') ? { options: definition.options ?? [] } : {}), }) const replaceDefinitionInState = (definition: FieldDefinition) => { @@ -573,7 +574,7 @@ const populateForm = (definition: FieldDefinition) => { form.exposurePolicy = definition.exposure_policy form.sortOrder = definition.sort_order form.active = definition.active - form.options = definition.type === 'select' + form.options = (definition.type === 'select' || definition.type === 'multiselect') ? createEditableSelectOptions(definition.options ?? [], createOptionId) : createEditableSelectOptions([], createOptionId) } @@ -611,7 +612,7 @@ const persistDefinition = async() => { exposurePolicy: form.exposurePolicy, sortOrder: Number(form.sortOrder), active: form.active, - ...(form.type === 'select' + ...((form.type === 'select' || form.type === 'multiselect') ? { options: extractEditableSelectOptionValues(form.options).filter((optionValue: string) => optionValue.trim() !== '') } : {}), } @@ -632,7 +633,7 @@ const persistDefinition = async() => { exposurePolicy: payload.exposurePolicy, sortOrder: payload.sortOrder, active: payload.active, - ...(payload.type === 'select' ? { options: payload.options } : {}), + ...((payload.type === 'select' || payload.type === 'multiselect') ? { options: payload.options } : {}), }) replaceDefinitionInState(updated) populateForm(updated) @@ -756,7 +757,7 @@ const removeDefinitionByItem = async(definition: FieldDefinition) => { } watch(() => form.type, (newType: FieldType) => { - if (newType === 'select') { + if (newType === 'select' || newType === 'multiselect') { if (form.options.length === 0) { form.options = createEditableSelectOptions([''], createOptionId) } diff --git a/src/views/PersonalSettings.vue b/src/views/PersonalSettings.vue index 6666f39..97496b4 100644 --- a/src/views/PersonalSettings.vue +++ b/src/views/PersonalSettings.vue @@ -84,6 +84,22 @@ SPDX-License-Identifier: AGPL-3.0-or-later :placeholder="placeholderForField(field)" @update:model-value="updateSelectValue(field.definition.id, $event)" /> + >({}) const autoSaveTimers = new Map() const embeddedVisibilityAnchorReady = ref(false) -const draftValues = reactive>({}) +const draftValues = reactive>({}) const draftVisibilities = reactive>({}) const inputModesByType: Record = { text: 'text', number: 'decimal', select: 'text', + multiselect: 'text', } const inputModeForType = (type: FieldType): 'text' | 'decimal' => { @@ -238,6 +255,7 @@ const componentInputTypesByType: Record = { text: 'text', number: 'number', select: 'text', + multiselect: 'text', } const componentInputTypeForType = (type: FieldType): 'text' | 'number' => { @@ -257,12 +275,20 @@ const placeholderForField = (field: EditableField) => { return t('profile_fields', 'Select an option') } + if (field.definition.type === 'multiselect') { + return t('profile_fields', 'Select one or more options') + } + return '' } const normaliseEditableField = (field: EditableField) => { const existingValue = field.value?.value - draftValues[field.definition.id] = existingValue?.value?.toString() ?? '' + if (Array.isArray(existingValue?.value)) { + draftValues[field.definition.id] = [...existingValue.value] + } else { + draftValues[field.definition.id] = existingValue?.value?.toString() ?? '' + } draftVisibilities[field.definition.id] = field.value?.current_visibility ?? definitionDefaultVisibility(field.definition) delete fieldErrors[field.definition.id] } @@ -327,7 +353,7 @@ const autosaveAnnouncement = (field: EditableField) => { return '' } -const currentStoredValue = (field: EditableField) => { +const currentStoredValue = (field: EditableField): string | string[] => { const existingValue = field.value?.value as unknown if (existingValue === null || existingValue === undefined) { return '' @@ -337,18 +363,36 @@ const currentStoredValue = (field: EditableField) => { return String(existingValue) } + if (Array.isArray(existingValue)) { + return existingValue.filter((candidate: unknown): candidate is string => typeof candidate === 'string') + } + if (typeof existingValue !== 'object') { return '' } + if ('value' in existingValue && Array.isArray(existingValue.value)) { + return existingValue.value.filter((candidate: unknown): candidate is string => typeof candidate === 'string') + } + return 'value' in existingValue && existingValue.value !== null && existingValue.value !== undefined ? String(existingValue.value) : '' } const resolvedDisplayValue = (field: EditableField) => { - const value = currentStoredValue(field) || draftValues[field.definition.id] || '' - return value === '' ? t('profile_fields', 'No value set') : value + const value = currentStoredValue(field) + if (Array.isArray(value)) { + return value.length === 0 ? t('profile_fields', 'No value set') : value.join(', ') + } + + const fallback = draftValues[field.definition.id] + if (Array.isArray(fallback)) { + return fallback.length === 0 ? t('profile_fields', 'No value set') : fallback.join(', ') + } + + const resolved = value || fallback || '' + return resolved === '' ? t('profile_fields', 'No value set') : resolved } const findField = (fieldId: number) => fields.value.find((field: EditableField) => field.definition.id === fieldId) @@ -360,6 +404,10 @@ const canAutosaveField = (field: EditableField) => { return true } + if (Array.isArray(rawValue)) { + return field.definition.type === 'multiselect' + } + if (field.definition.type === 'text' || field.definition.type === 'select') { return true } @@ -384,7 +432,13 @@ const hasFieldChanges = (field: EditableField) => { } const visibilityChanged = draftVisibilities[field.definition.id] !== (field.value?.current_visibility ?? definitionDefaultVisibility(field.definition)) - return visibilityChanged || currentDraftValue(field) !== currentStoredValue(field) + const draft = currentDraftValue(field) + const stored = currentStoredValue(field) + const valueChanged = Array.isArray(draft) || Array.isArray(stored) + ? JSON.stringify(draft) !== JSON.stringify(stored) + : draft !== stored + + return visibilityChanged || valueChanged } const updateDraftValue = (fieldId: number, value: string | number | null) => { @@ -411,6 +465,30 @@ const updateDraftValue = (fieldId: number, value: string | number | null) => { }, 900)) } +const updateMultiSelectValue = (fieldId: number, options: Array<{ value: string, label: string }> | null) => { + draftValues[fieldId] = options?.map((option) => option.value) ?? [] + delete fieldErrors[fieldId] + const existingTimer = autoSaveTimers.get(fieldId) + if (existingTimer !== undefined) { + window.clearTimeout(existingTimer) + autoSaveTimers.delete(fieldId) + } + + if (!embedded) { + return + } + + const field = findField(fieldId) + if (field === undefined || !field.can_edit || !hasFieldChanges(field)) { + return + } + + autoSaveTimers.set(fieldId, window.setTimeout(() => { + autoSaveTimers.delete(fieldId) + void saveField(field) + }, 900)) +} + const updateVisibility = (fieldId: number, option: { value: FieldVisibility, label: string } | null) => { if (option !== null) { draftVisibilities[fieldId] = option.value @@ -448,6 +526,13 @@ const buildPayload = (field: EditableField) => { } } + if (field.definition.type === 'multiselect') { + return { + value: Array.isArray(rawValue) ? rawValue : [], + currentVisibility, + } + } + return { value: rawValue === '' ? null : rawValue, currentVisibility, @@ -459,7 +544,23 @@ const selectOptionsFor = (definition: { options: string[] | null }) => const selectOptionFor = (field: EditableField) => { const value = draftValues[field.definition.id] - return value ? { value, label: value } : null + if (typeof value !== 'string' || value === '') { + return null + } + + return { value, label: value } +} + +const multiselectOptionsFor = (definition: { options: string[] | null }) => + (definition.options ?? []).map((opt: string) => ({ value: opt, label: opt })) + +const multiselectOptionValuesFor = (field: EditableField) => { + const value = draftValues[field.definition.id] + if (!Array.isArray(value)) { + return [] + } + + return value.map((item) => ({ value: item, label: item })) } const updateSelectValue = (fieldId: number, option: { value: string, label: string } | null) => { diff --git a/tests/php/Unit/Service/FieldDefinitionValidatorTest.php b/tests/php/Unit/Service/FieldDefinitionValidatorTest.php index f8cc9f9..966fb43 100644 --- a/tests/php/Unit/Service/FieldDefinitionValidatorTest.php +++ b/tests/php/Unit/Service/FieldDefinitionValidatorTest.php @@ -96,6 +96,30 @@ public function testRejectSelectWithBlankOption(): void { ]); } + public function testValidateMultiSelectFieldDefinition(): void { + $validated = $this->validator->validate([ + 'field_key' => 'support_regions', + 'label' => 'Support Regions', + 'type' => FieldType::MULTISELECT->value, + 'options' => ['LATAM', 'EMEA', 'APAC'], + ]); + + $this->assertSame(FieldType::MULTISELECT->value, $validated['type']); + $this->assertSame(['LATAM', 'EMEA', 'APAC'], $validated['options']); + } + + public function testRejectMultiSelectWithNoOptions(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('multiselect fields require at least one option'); + + $this->validator->validate([ + 'field_key' => 'support_regions', + 'label' => 'Support Regions', + 'type' => FieldType::MULTISELECT->value, + 'options' => [], + ]); + } + public function testNonSelectTypesDoNotRequireOptions(): void { $validated = $this->validator->validate([ 'field_key' => 'cpf', diff --git a/tests/php/Unit/Service/FieldValueServiceTest.php b/tests/php/Unit/Service/FieldValueServiceTest.php index de3c688..b0e541f 100644 --- a/tests/php/Unit/Service/FieldValueServiceTest.php +++ b/tests/php/Unit/Service/FieldValueServiceTest.php @@ -395,6 +395,32 @@ public function testNormalizeSelectValueRejectsFloat(): void { $this->service->normalizeValue($definition, 1.5); } + public function testNormalizeMultiSelectAcceptsArrayOfValidOptions(): void { + $definition = $this->buildMultiSelectDefinition(['LATAM', 'EMEA', 'APAC']); + + $normalized = $this->service->normalizeValue($definition, ['LATAM', 'APAC']); + + $this->assertSame(['value' => ['LATAM', 'APAC']], $normalized); + } + + public function testNormalizeMultiSelectRejectsUnknownOption(): void { + $definition = $this->buildMultiSelectDefinition(['LATAM', 'EMEA', 'APAC']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('"ANZ" is not a valid option for this field'); + + $this->service->normalizeValue($definition, ['LATAM', 'ANZ']); + } + + public function testNormalizeMultiSelectRejectsScalar(): void { + $definition = $this->buildMultiSelectDefinition(['LATAM', 'EMEA']); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Multiselect fields require one or more configured option values.'); + + $this->service->normalizeValue($definition, 'LATAM'); + } + private function buildDefinition(string $type): FieldDefinition { $definition = new FieldDefinition(); $definition->setType($type); @@ -410,4 +436,13 @@ private function buildSelectDefinition(array $options): FieldDefinition { $definition->setOptions(json_encode($options)); return $definition; } + + /** + * @param list $options + */ + private function buildMultiSelectDefinition(array $options): FieldDefinition { + $definition = $this->buildDefinition(FieldType::MULTISELECT->value); + $definition->setOptions(json_encode($options)); + return $definition; + } } diff --git a/tests/php/Unit/Workflow/UserProfileFieldCheckTest.php b/tests/php/Unit/Workflow/UserProfileFieldCheckTest.php index 8217f70..3b80de2 100644 --- a/tests/php/Unit/Workflow/UserProfileFieldCheckTest.php +++ b/tests/php/Unit/Workflow/UserProfileFieldCheckTest.php @@ -241,6 +241,44 @@ public function testValidateCheckRejectsContainsForSelectField(): void { $this->check->validateCheck('contains', $this->encodeConfig('contract_type', 'CL')); } + public function testExecuteCheckMatchesMultiselectWhenExpectedOptionIsPresent(): void { + $definition = $this->buildDefinition(7, 'support_regions', FieldType::MULTISELECT->value); + $definition->setOptions(json_encode(['LATAM', 'EMEA', 'APAC'])); + $value = $this->buildStoredValue(7, 'alice', '{"value":["LATAM","APAC"]}'); + + $this->fieldDefinitionService->expects($this->once()) + ->method('findByFieldKey') + ->with('support_regions') + ->willReturn($definition); + $this->fieldValueMapper->expects($this->once()) + ->method('findByFieldDefinitionIdAndUserUid') + ->with(7, 'alice') + ->willReturn($value); + + $this->userSession->method('getUser')->willReturn($this->buildUser('alice')); + + $this->assertTrue($this->check->executeCheck('is', $this->encodeConfig('support_regions', 'LATAM'))); + } + + public function testExecuteCheckMatchesMultiselectNegatedWhenExpectedOptionIsMissing(): void { + $definition = $this->buildDefinition(7, 'support_regions', FieldType::MULTISELECT->value); + $definition->setOptions(json_encode(['LATAM', 'EMEA', 'APAC'])); + $value = $this->buildStoredValue(7, 'alice', '{"value":["LATAM","APAC"]}'); + + $this->fieldDefinitionService->expects($this->once()) + ->method('findByFieldKey') + ->with('support_regions') + ->willReturn($definition); + $this->fieldValueMapper->expects($this->once()) + ->method('findByFieldDefinitionIdAndUserUid') + ->with(7, 'alice') + ->willReturn($value); + + $this->userSession->method('getUser')->willReturn($this->buildUser('alice')); + + $this->assertTrue($this->check->executeCheck('!is', $this->encodeConfig('support_regions', 'EMEA'))); + } + private function buildDefinition(int $id, string $fieldKey, string $type): FieldDefinition { $definition = new FieldDefinition(); $definition->setId($id);