diff --git a/img/screenshots/admin-catalog-thumb.png b/img/screenshots/admin-catalog-thumb.png index bd5b0a9..6513e3a 100644 Binary files a/img/screenshots/admin-catalog-thumb.png and b/img/screenshots/admin-catalog-thumb.png differ diff --git a/img/screenshots/boolean-field-admin-proof.png b/img/screenshots/boolean-field-admin-proof.png new file mode 100644 index 0000000..116467d Binary files /dev/null and b/img/screenshots/boolean-field-admin-proof.png differ diff --git a/img/screenshots/personal-settings-thumb.png b/img/screenshots/personal-settings-thumb.png index 1a1ded1..82df2a3 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 1078743..6b15335 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 2c8185c..f08c99f 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/workflow-notify-admins-thumb.png b/img/screenshots/workflow-notify-admins-thumb.png index d3aa118..2cd9665 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 ca2b395..2713a0e 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 BOOLEAN = 'boolean'; case DATE = 'date'; case SELECT = 'select'; case MULTISELECT = 'multiselect'; @@ -23,6 +24,7 @@ public static function values(): array { return [ self::TEXT->value, self::NUMBER->value, + self::BOOLEAN->value, self::DATE->value, self::SELECT->value, self::MULTISELECT->value, diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 4d949a7..832a05a 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -10,7 +10,7 @@ namespace OCA\ProfileFields; /** - * @psalm-type ProfileFieldsType = 'text'|'number'|'date'|'select'|'multiselect' + * @psalm-type ProfileFieldsType = 'text'|'number'|'boolean'|'date'|'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/DataImportService.php b/lib/Service/DataImportService.php index 6ce62fe..b915d7b 100644 --- a/lib/Service/DataImportService.php +++ b/lib/Service/DataImportService.php @@ -69,7 +69,7 @@ public function import(array $payload, bool $dryRun = false): array { * @param list $this->normalizeTextValue($rawValue), FieldType::NUMBER => $this->normalizeNumberValue($rawValue), + FieldType::BOOLEAN => $this->normalizeBooleanValue($rawValue), FieldType::DATE => $this->normalizeDateValue($rawValue), FieldType::SELECT => $this->normalizeSelectValue($rawValue, $definition), FieldType::MULTISELECT => $this->normalizeMultiSelectValue($rawValue, $definition), @@ -299,6 +300,37 @@ private function normalizeNumberValue(array|string|int|float|bool $rawValue): ar return ['value' => str_contains((string)$rawValue, '.') ? (float)$rawValue : (int)$rawValue]; } + /** + * @param array|scalar $rawValue + * @return array{value: bool} + */ + private function normalizeBooleanValue(array|string|int|float|bool $rawValue): array { + if (is_bool($rawValue)) { + return ['value' => $rawValue]; + } + + if (is_string($rawValue)) { + $value = strtolower(trim($rawValue)); + if ($value === 'true' || $value === '1') { + return ['value' => true]; + } + if ($value === 'false' || $value === '0') { + return ['value' => false]; + } + } + + if (is_int($rawValue) || is_float($rawValue)) { + if ((float)$rawValue === 1.0) { + return ['value' => true]; + } + if ((float)$rawValue === 0.0) { + return ['value' => false]; + } + } + + throw new InvalidArgumentException($this->l10n->t('Boolean fields require true or false values.')); + } + /** * @param array|scalar $rawValue * @return array{value: string} diff --git a/lib/Service/ImportPayloadValidator.php b/lib/Service/ImportPayloadValidator.php index 51fdab8..3f2837b 100644 --- a/lib/Service/ImportPayloadValidator.php +++ b/lib/Service/ImportPayloadValidator.php @@ -32,7 +32,7 @@ public function __construct( * definitions: listgetType())) { FieldType::TEXT => self::TEXT_OPERATORS, FieldType::NUMBER => self::NUMBER_OPERATORS, + FieldType::BOOLEAN => self::BOOLEAN_OPERATORS, FieldType::DATE => self::DATE_OPERATORS, FieldType::SELECT => self::SELECT_OPERATORS, FieldType::MULTISELECT => self::SELECT_OPERATORS, @@ -248,6 +255,11 @@ private function evaluate(FieldDefinition $definition, string $operator, string| return match ($fieldType) { FieldType::TEXT, FieldType::SELECT => $this->evaluateTextOperator($operator, (string)$expectedValue, (string)$actualValue), + FieldType::BOOLEAN => $this->evaluateBooleanOperator( + $operator, + $this->normalizeBooleanComparisonOperand($expectedValue), + $this->normalizeBooleanComparisonOperand($actualValue), + ), FieldType::NUMBER => $this->evaluateNumberOperator( $operator, $this->normalizeNumericComparisonOperand($expectedValue), @@ -297,6 +309,33 @@ private function normalizeNumericComparisonOperand(string|int|float|bool|null $v return str_contains((string)$value, '.') ? (float)$value : (int)$value; } + private function normalizeBooleanComparisonOperand(string|int|float|bool|null $value): bool { + if (is_bool($value)) { + return $value; + } + + if (is_string($value)) { + $normalized = strtolower(trim($value)); + if ($normalized === 'true' || $normalized === '1') { + return true; + } + if ($normalized === 'false' || $normalized === '0') { + return false; + } + } + + if (is_int($value) || is_float($value)) { + if ((float)$value === 1.0) { + return true; + } + if ((float)$value === 0.0) { + return false; + } + } + + throw new InvalidArgumentException('boolean comparison value must be true or false'); + } + private function normalizeDateComparisonOperand(string|int|float|bool|null $value): int { if (!is_string($value)) { throw new InvalidArgumentException('date comparison value must be a valid YYYY-MM-DD string'); @@ -332,6 +371,14 @@ private function evaluateNumberOperator(string $operator, int|float $expectedVal }; } + private function evaluateBooleanOperator(string $operator, bool $expectedValue, bool $actualValue): bool { + return match ($operator) { + 'is' => $actualValue === $expectedValue, + '!is' => $actualValue !== $expectedValue, + default => false, + }; + } + private function evaluateDateOperator(string $operator, int $expectedValue, int $actualValue): bool { return match ($operator) { 'is' => $actualValue === $expectedValue, diff --git a/openapi-administration.json b/openapi-administration.json index 0f9d54f..f0c947d 100644 --- a/openapi-administration.json +++ b/openapi-administration.json @@ -198,6 +198,7 @@ "enum": [ "text", "number", + "boolean", "date", "select", "multiselect" diff --git a/openapi-full.json b/openapi-full.json index 4da53e6..312f374 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -273,6 +273,7 @@ "enum": [ "text", "number", + "boolean", "date", "select", "multiselect" diff --git a/openapi.json b/openapi.json index 7e6a392..415c600 100644 --- a/openapi.json +++ b/openapi.json @@ -144,6 +144,7 @@ "enum": [ "text", "number", + "boolean", "date", "select", "multiselect" diff --git a/src/components/AdminUserFieldsDialog.vue b/src/components/AdminUserFieldsDialog.vue index e91edef..4e7ec25 100644 --- a/src/components/AdminUserFieldsDialog.vue +++ b/src/components/AdminUserFieldsDialog.vue @@ -73,6 +73,19 @@ SPDX-License-Identifier: AGPL-3.0-or-later :placeholder="placeholderForField(field.definition.type)" @update:model-value="updateMultiSelectValue(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) @@ -196,6 +209,7 @@ export default defineComponent({ const descriptionForType = (type: FieldType): string => ({ text: t('profile_fields', 'Free text stored as a scalar value.'), number: t('profile_fields', 'Only numeric values are accepted.'), + boolean: t('profile_fields', 'Choose either true or false.'), date: t('profile_fields', 'Use a valid date in YYYY-MM-DD format.'), select: t('profile_fields', 'Choose one of the predefined options.'), multiselect: t('profile_fields', 'Choose one or more predefined options.'), @@ -204,6 +218,7 @@ export default defineComponent({ const placeholderForField = (type: FieldType): string => ({ text: t('profile_fields', 'Enter a value'), number: t('profile_fields', 'Enter a number'), + boolean: t('profile_fields', 'Select true or false'), date: t('profile_fields', 'Select a date'), select: t('profile_fields', 'Select an option'), multiselect: t('profile_fields', 'Select one or more options'), @@ -215,6 +230,7 @@ export default defineComponent({ const inputModeForField = (type: FieldType): string => ({ text: 'text', number: 'decimal', + boolean: 'text', date: 'numeric', select: 'text', multiselect: 'text', @@ -223,11 +239,31 @@ export default defineComponent({ const componentInputTypeForField = (type: FieldType): string => ({ text: 'text', number: 'text', + boolean: 'text', date: 'date', select: 'text', multiselect: 'text', } as Record)[type] + const booleanOptions = [ + { value: true, label: t('profile_fields', 'True') }, + { value: false, label: t('profile_fields', 'False') }, + ] + + const booleanOptionFor = (fieldId: number) => { + const current = userDraftValues[fieldId] + if (typeof current !== 'boolean') { + return null + } + + return booleanOptions.find((option) => option.value === current) ?? null + } + + const updateBooleanValue = (fieldId: number, option: { value: boolean, label: string } | null) => { + userDraftValues[fieldId] = option?.value ?? '' + clearFieldError(fieldId) + } + const selectOptionsFor = (definition: FieldDefinition) => (definition.options ?? []).map((opt: string) => ({ value: opt, label: opt })) @@ -281,6 +317,13 @@ export default defineComponent({ } } + if (field.definition.type === 'boolean') { + const current = userDraftValues[field.definition.id] + if (current !== '' && typeof current !== 'boolean') { + return t('profile_fields', '{fieldLabel} must be either true or false.', { fieldLabel: field.definition.label }) + } + } + if (field.definition.type === 'select') { const options = field.definition.options ?? [] if (!options.includes(rawValue)) { @@ -312,7 +355,7 @@ export default defineComponent({ const hasInvalidFields = computed(() => invalidFields.value.length > 0) const helperTextForField = (field: AdminEditableField) => { - return field.definition.type === 'number' || field.definition.type === 'date' + return field.definition.type === 'number' || field.definition.type === 'date' || field.definition.type === 'boolean' ? descriptionForType(field.definition.type) : '' } @@ -323,6 +366,8 @@ export default defineComponent({ const currentValue = field.value?.value if (Array.isArray(currentValue?.value)) { userDraftValues[field.definition.id] = [...currentValue.value] + } else if (typeof currentValue?.value === 'boolean') { + userDraftValues[field.definition.id] = currentValue.value } else { userDraftValues[field.definition.id] = currentValue?.value?.toString() ?? '' } @@ -383,6 +428,7 @@ export default defineComponent({ return ({ 'text fields expect a scalar value': t('profile_fields', '{fieldLabel} must be plain text.', { fieldLabel: field.definition.label }), 'number fields expect a numeric value': t('profile_fields', '{fieldLabel} must be a numeric value.', { fieldLabel: field.definition.label }), + 'Boolean fields require true or false values.': t('profile_fields', '{fieldLabel} must be either true or false.', { fieldLabel: field.definition.label }), 'Date fields require a valid ISO-8601 date in YYYY-MM-DD format.': t('profile_fields', '{fieldLabel} must be a valid date in YYYY-MM-DD format.', { fieldLabel: field.definition.label }), 'current_visibility is not supported': t('profile_fields', 'The selected visibility is not supported.'), }[message] ?? (message.includes('is not a valid option') @@ -445,6 +491,13 @@ export default defineComponent({ } } + if (field.definition.type === 'boolean') { + return { + value: typeof current === 'boolean' ? current : null, + visibility: userDraftVisibilities[field.definition.id], + } + } + if (field.definition.type === 'multiselect') { const selectedValues = userDraftValues[field.definition.id] if (!Array.isArray(selectedValues)) { @@ -582,6 +635,9 @@ export default defineComponent({ componentInputTypeForField, inputModeForField, placeholderForField, + booleanOptions, + booleanOptionFor, + updateBooleanValue, saveAllFields, successMessage, updateOpen, diff --git a/src/tests/components/AdminSettings.spec.ts b/src/tests/components/AdminSettings.spec.ts index b55b046..4a2c873 100644 --- a/src/tests/components/AdminSettings.spec.ts +++ b/src/tests/components/AdminSettings.spec.ts @@ -90,4 +90,19 @@ describe('AdminSettings', () => { expect(wrapper.text()).toContain('tr:Date') }) + + it('offers the Boolean field type in the editor', async() => { + const wrapper = mount(AdminSettings, { + global: { + stubs: { + Draggable: defineComponent({ template: '
' }), + }, + }, + }) + + await flushPromises() + await wrapper.get('button').trigger('click') + + expect(wrapper.text()).toContain('tr:Boolean') + }) }) \ No newline at end of file diff --git a/src/tests/components/AdminUserFieldsDialog.spec.ts b/src/tests/components/AdminUserFieldsDialog.spec.ts index b156e79..95682ae 100644 --- a/src/tests/components/AdminUserFieldsDialog.spec.ts +++ b/src/tests/components/AdminUserFieldsDialog.spec.ts @@ -37,6 +37,17 @@ vi.mock('../../api', () => ({ active: true, options: null, }, + { + id: 2, + field_key: 'is_manager', + label: 'Is manager', + type: 'boolean', + edit_policy: 'users', + exposure_policy: 'private', + sort_order: 1, + active: true, + options: null, + }, ]), listAdminUserValues: vi.fn().mockResolvedValue([ { @@ -48,6 +59,15 @@ vi.mock('../../api', () => ({ updated_by_uid: 'admin', updated_at: '2026-03-20T12:00:00+00:00', }, + { + id: 11, + field_definition_id: 2, + user_uid: 'alice', + value: { value: true }, + current_visibility: 'private', + updated_by_uid: 'admin', + updated_at: '2026-03-20T12:00:00+00:00', + }, ]), upsertAdminUserValue: vi.fn(), })) @@ -72,7 +92,7 @@ vi.mock('@nextcloud/vue', () => ({ props: { options: { type: Array, default: () => [] }, }, - template: '', + template: '
{{ option.label }}
', }), })) @@ -96,4 +119,19 @@ describe('PersonalSettings', () => { expect(input.exists()).toBe(true) expect(input.attributes('type')).toBe('date') }) + + it('renders boolean fields with true and false options', async() => { + const wrapper = mount(PersonalSettings, { + global: { + stubs: { + Teleport: true, + }, + }, + }) + + await flushPromises() + + expect(wrapper.text()).toContain('tr:True') + expect(wrapper.text()).toContain('tr:False') + }) }) \ No newline at end of file diff --git a/src/tests/utils/workflowProfileFieldCheck.spec.ts b/src/tests/utils/workflowProfileFieldCheck.spec.ts index 359e399..22fd719 100644 --- a/src/tests/utils/workflowProfileFieldCheck.spec.ts +++ b/src/tests/utils/workflowProfileFieldCheck.spec.ts @@ -14,6 +14,7 @@ const definitions = [ { field_key: 'region', label: 'Region', type: 'text', active: true }, { field_key: 'score', label: 'Score', type: 'number', active: true }, { field_key: 'start_date', label: 'Start date', type: 'date', active: true }, + { field_key: 'is_manager', label: 'Is manager', type: 'boolean', active: true }, ] as const describe('workflowProfileFieldCheck', () => { @@ -60,6 +61,15 @@ describe('workflowProfileFieldCheck', () => { ]) }) + it('returns exact-match operators for boolean definitions', () => { + expect(getWorkflowOperatorKeys(serializeWorkflowCheckValue({ field_key: 'is_manager', value: true }), definitions)).toEqual([ + 'is-set', + '!is-set', + 'is', + '!is', + ]) + }) + it('rejects unsupported operators for the selected field type', () => { expect(isWorkflowOperatorSupported('contains', serializeWorkflowCheckValue({ field_key: 'score', value: '9' }), definitions)).toBe(false) expect(isWorkflowOperatorSupported('greater', serializeWorkflowCheckValue({ field_key: 'score', value: '9' }), definitions)).toBe(true) diff --git a/src/types/index.ts b/src/types/index.ts index 7964e15..b1c460c 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'] | 'multiselect' | 'date' +export type FieldType = ApiComponents['schemas']['Type'] | 'multiselect' | 'date' | 'boolean' 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 | string[] | null + value?: string | number | boolean | string[] | null } export type FieldValueRecord = Omit & { value: FieldValuePayload @@ -71,11 +71,11 @@ export type UpdateDefinitionPayload = { options?: string[] } export type UpsertOwnValuePayload = Omit, 'value'> & { - value?: string | number | string[] | null + value?: string | number | boolean | string[] | null } export type UpdateOwnVisibilityPayload = ApiRequestJsonBody export type UpsertAdminUserValuePayload = Omit, 'value'> & { - value?: string | number | string[] | null + value?: string | number | boolean | string[] | null } export type AdminEditableField = { diff --git a/src/types/openapi/openapi-administration.ts b/src/types/openapi/openapi-administration.ts index 439298c..ad1fcaa 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" | "date" | "select" | "multiselect"; + Type: "text" | "number" | "boolean" | "date" | "select" | "multiselect"; ValuePayload: { value: Record; }; diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 15517c5..74a1eb3 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" | "date" | "select" | "multiselect"; + Type: "text" | "number" | "boolean" | "date" | "select" | "multiselect"; ValuePayload: { value: Record; }; diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 333128a..c87e0bb 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" | "date" | "select" | "multiselect"; + Type: "text" | "number" | "boolean" | "date" | "select" | "multiselect"; ValuePayload: { value: Record; }; diff --git a/src/utils/workflowProfileFieldCheck.ts b/src/utils/workflowProfileFieldCheck.ts index bc54f97..6937721 100644 --- a/src/utils/workflowProfileFieldCheck.ts +++ b/src/utils/workflowProfileFieldCheck.ts @@ -17,6 +17,7 @@ export type WorkflowCheckDefinition = { const textOperatorKeys = ['is-set', '!is-set', 'is', '!is', 'contains', '!contains'] as const const numberOperatorKeys = ['is-set', '!is-set', 'is', '!is', 'less', '!greater', 'greater', '!less'] as const +const booleanOperatorKeys = ['is-set', '!is-set', 'is', '!is'] as const const fallbackOperatorKeys = ['is-set', '!is-set', 'is', '!is', 'contains', '!contains', 'less', '!greater', 'greater', '!less'] as const export const parseWorkflowCheckValue = (rawValue: string | null | undefined): WorkflowCheckValue | null => { @@ -65,6 +66,8 @@ export const getWorkflowOperatorKeys = (rawValue: string | null | undefined, def return definition.type === 'number' || definition.type === 'date' ? [...numberOperatorKeys] + : definition.type === 'boolean' + ? [...booleanOperatorKeys] : [...textOperatorKeys] } diff --git a/src/views/AdminSettings.vue b/src/views/AdminSettings.vue index 73a3f29..e714132 100644 --- a/src/views/AdminSettings.vue +++ b/src/views/AdminSettings.vue @@ -287,6 +287,7 @@ import { createEditableSelectOptions, extractEditableSelectOptionValues } from ' const fieldTypeOptions: Array<{ value: FieldType, label: string }> = [ { value: 'text', label: t('profile_fields', 'Text') }, { value: 'number', label: t('profile_fields', 'Number') }, + { value: 'boolean', label: t('profile_fields', 'Boolean') }, { value: 'date', label: t('profile_fields', 'Date') }, { value: 'select', label: t('profile_fields', 'Select') }, { value: 'multiselect', label: t('profile_fields', 'Multiselect') }, diff --git a/src/views/PersonalSettings.vue b/src/views/PersonalSettings.vue index d06da8e..9e45480 100644 --- a/src/views/PersonalSettings.vue +++ b/src/views/PersonalSettings.vue @@ -100,6 +100,21 @@ SPDX-License-Identifier: AGPL-3.0-or-later :placeholder="placeholderForField(field)" @update:model-value="updateMultiSelectValue(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', + boolean: 'text', date: 'numeric', select: 'text', multiselect: 'text', @@ -255,6 +271,7 @@ const fieldInputId = (fieldId: number) => `profile-fields-personal-value-${field const componentInputTypesByType: Record = { text: 'text', number: 'number', + boolean: 'text', date: 'date', select: 'text', multiselect: 'text', @@ -277,6 +294,10 @@ const placeholderForField = (field: EditableField) => { return '' } + if (field.definition.type === 'boolean') { + return t('profile_fields', 'Select true or false') + } + if (field.definition.type === 'select') { return t('profile_fields', 'Select an option') } @@ -292,6 +313,8 @@ const normaliseEditableField = (field: EditableField) => { const existingValue = field.value?.value if (Array.isArray(existingValue?.value)) { draftValues[field.definition.id] = [...existingValue.value] + } else if (typeof existingValue?.value === 'boolean') { + draftValues[field.definition.id] = existingValue.value } else { draftValues[field.definition.id] = existingValue?.value?.toString() ?? '' } @@ -359,7 +382,7 @@ const autosaveAnnouncement = (field: EditableField) => { return '' } -const currentStoredValue = (field: EditableField): string | string[] => { +const currentStoredValue = (field: EditableField): string | string[] | boolean => { const existingValue = field.value?.value as unknown if (existingValue === null || existingValue === undefined) { return '' @@ -369,6 +392,10 @@ const currentStoredValue = (field: EditableField): string | string[] => { return String(existingValue) } + if (typeof existingValue === 'boolean') { + return existingValue + } + if (Array.isArray(existingValue)) { return existingValue.filter((candidate: unknown): candidate is string => typeof candidate === 'string') } @@ -381,6 +408,10 @@ const currentStoredValue = (field: EditableField): string | string[] => { return existingValue.value.filter((candidate: unknown): candidate is string => typeof candidate === 'string') } + if ('value' in existingValue && typeof existingValue.value === 'boolean') { + return existingValue.value + } + return 'value' in existingValue && existingValue.value !== null && existingValue.value !== undefined ? String(existingValue.value) : '' @@ -392,11 +423,19 @@ const resolvedDisplayValue = (field: EditableField) => { return value.length === 0 ? t('profile_fields', 'No value set') : value.join(', ') } + if (typeof value === 'boolean') { + return value ? t('profile_fields', 'True') : t('profile_fields', 'False') + } + const fallback = draftValues[field.definition.id] if (Array.isArray(fallback)) { return fallback.length === 0 ? t('profile_fields', 'No value set') : fallback.join(', ') } + if (typeof fallback === 'boolean') { + return fallback ? t('profile_fields', 'True') : t('profile_fields', 'False') + } + const resolved = value || fallback || '' return resolved === '' ? t('profile_fields', 'No value set') : resolved } @@ -414,7 +453,7 @@ const canAutosaveField = (field: EditableField) => { return field.definition.type === 'multiselect' } - if (field.definition.type === 'text' || field.definition.type === 'select' || field.definition.type === 'date') { + if (field.definition.type === 'text' || field.definition.type === 'select' || field.definition.type === 'date' || field.definition.type === 'boolean') { return true } @@ -495,6 +534,44 @@ const updateMultiSelectValue = (fieldId: number, options: Array<{ value: string, }, 900)) } +const booleanOptions = [ + { value: true, label: t('profile_fields', 'True') }, + { value: false, label: t('profile_fields', 'False') }, +] + +const booleanOptionFor = (field: EditableField) => { + const value = draftValues[field.definition.id] + if (typeof value !== 'boolean') { + return null + } + + return booleanOptions.find((option) => option.value === value) ?? null +} + +const updateBooleanValue = (fieldId: number, option: { value: boolean, label: string } | null) => { + draftValues[fieldId] = 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 @@ -539,6 +616,13 @@ const buildPayload = (field: EditableField) => { } } + if (field.definition.type === 'boolean') { + return { + value: typeof rawValue === 'boolean' ? rawValue : null, + currentVisibility, + } + } + if (field.definition.type === 'multiselect') { return { value: Array.isArray(rawValue) ? rawValue : [], diff --git a/tests/php/Unit/Service/FieldDefinitionValidatorTest.php b/tests/php/Unit/Service/FieldDefinitionValidatorTest.php index 4dbbc80..95dfe66 100644 --- a/tests/php/Unit/Service/FieldDefinitionValidatorTest.php +++ b/tests/php/Unit/Service/FieldDefinitionValidatorTest.php @@ -119,6 +119,17 @@ public function testValidateDateFieldDefinition(): void { $this->assertNull($validated['options']); } + public function testValidateBooleanFieldDefinition(): void { + $validated = $this->validator->validate([ + 'field_key' => 'is_manager', + 'label' => 'Is manager', + 'type' => FieldType::BOOLEAN->value, + ]); + + $this->assertSame(FieldType::BOOLEAN->value, $validated['type']); + $this->assertNull($validated['options']); + } + public function testRejectMultiSelectWithNoOptions(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('multiselect fields require at least one option'); diff --git a/tests/php/Unit/Service/FieldValueServiceTest.php b/tests/php/Unit/Service/FieldValueServiceTest.php index 03043bd..98348ab 100644 --- a/tests/php/Unit/Service/FieldValueServiceTest.php +++ b/tests/php/Unit/Service/FieldValueServiceTest.php @@ -65,6 +65,22 @@ public function testNormalizeDateValueRejectsInvalidDate(): void { $this->service->normalizeValue($definition, '2026-02-30'); } + public function testNormalizeBooleanValueAcceptsBooleanAndString(): void { + $definition = $this->buildDefinition(FieldType::BOOLEAN->value); + + $this->assertSame(['value' => true], $this->service->normalizeValue($definition, true)); + $this->assertSame(['value' => false], $this->service->normalizeValue($definition, 'false')); + } + + public function testNormalizeBooleanValueRejectsInvalidValue(): void { + $definition = $this->buildDefinition(FieldType::BOOLEAN->value); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Boolean fields require true or false values.'); + + $this->service->normalizeValue($definition, 'yes'); + } + public function testNormalizeMissingValueAsNull(): void { $definition = $this->buildDefinition(FieldType::TEXT->value); diff --git a/tests/php/Unit/Workflow/UserProfileFieldCheckTest.php b/tests/php/Unit/Workflow/UserProfileFieldCheckTest.php index 700839a..37aaf2d 100644 --- a/tests/php/Unit/Workflow/UserProfileFieldCheckTest.php +++ b/tests/php/Unit/Workflow/UserProfileFieldCheckTest.php @@ -163,6 +163,36 @@ public function testValidateCheckRejectsContainsForDateField(): void { $this->check->validateCheck('contains', $this->encodeConfig('start_date', '2026-03-20')); } + public function testExecuteCheckMatchesBooleanExactValue(): void { + $definition = $this->buildDefinition(7, 'is_manager', FieldType::BOOLEAN->value); + $value = $this->buildStoredValue(7, 'alice', '{"value":true}'); + + $this->fieldDefinitionService->expects($this->once()) + ->method('findByFieldKey') + ->with('is_manager') + ->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('is_manager', true))); + } + + public function testValidateCheckRejectsContainsForBooleanField(): void { + $this->fieldDefinitionService->expects($this->once()) + ->method('findByFieldKey') + ->with('is_manager') + ->willReturn($this->buildDefinition(7, 'is_manager', FieldType::BOOLEAN->value)); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('The selected operator is not supported for this profile field'); + + $this->check->validateCheck('contains', $this->encodeConfig('is_manager', true)); + } + public function testExecuteCheckTreatsMissingValueAsNotSet(): void { $definition = $this->buildDefinition(7, 'region', FieldType::TEXT->value); @@ -345,7 +375,7 @@ private function buildUser(string $uid): IUser&MockObject { return $user; } - private function encodeConfig(string $fieldKey, string|int|float|null $value): string { + private function encodeConfig(string $fieldKey, string|int|float|bool|null $value): string { try { return json_encode([ 'field_key' => $fieldKey,