diff --git a/img/screenshots/admin-catalog-thumb.png b/img/screenshots/admin-catalog-thumb.png index a4dba7d..bd5b0a9 100644 Binary files a/img/screenshots/admin-catalog-thumb.png and b/img/screenshots/admin-catalog-thumb.png differ diff --git a/img/screenshots/date-field-admin-proof.png b/img/screenshots/date-field-admin-proof.png new file mode 100644 index 0000000..99c1a7f Binary files /dev/null and b/img/screenshots/date-field-admin-proof.png differ diff --git a/img/screenshots/personal-settings-thumb.png b/img/screenshots/personal-settings-thumb.png index 470e041..1a1ded1 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 872e6fb..1078743 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 bba7c7d..2c8185c 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 207af30..d3aa118 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 e1f4d9c..ca2b395 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 DATE = 'date'; case SELECT = 'select'; case MULTISELECT = 'multiselect'; @@ -22,6 +23,7 @@ public static function values(): array { return [ self::TEXT->value, self::NUMBER->value, + self::DATE->value, self::SELECT->value, self::MULTISELECT->value, ]; diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 60f5a7d..4d949a7 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -10,7 +10,7 @@ namespace OCA\ProfileFields; /** - * @psalm-type ProfileFieldsType = 'text'|'number'|'select'|'multiselect' + * @psalm-type ProfileFieldsType = 'text'|'number'|'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 1c3cd42..6ce62fe 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::DATE => $this->normalizeDateValue($rawValue), FieldType::SELECT => $this->normalizeSelectValue($rawValue, $definition), FieldType::MULTISELECT => $this->normalizeMultiSelectValue($rawValue, $definition), }; @@ -298,6 +299,24 @@ 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: string} + */ + private function normalizeDateValue(array|string|int|float|bool $rawValue): array { + if (!is_string($rawValue)) { + throw new InvalidArgumentException($this->l10n->t('Date fields require a valid ISO-8601 date in YYYY-MM-DD format.')); + } + + $value = trim($rawValue); + $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value); + if ($date === false || $date->format('Y-m-d') !== $value) { + throw new InvalidArgumentException($this->l10n->t('Date fields require a valid ISO-8601 date in YYYY-MM-DD format.')); + } + + return ['value' => $value]; + } + /** * @param array $value */ diff --git a/lib/Service/ImportPayloadValidator.php b/lib/Service/ImportPayloadValidator.php index b164cb8..51fdab8 100644 --- a/lib/Service/ImportPayloadValidator.php +++ b/lib/Service/ImportPayloadValidator.php @@ -32,7 +32,7 @@ public function __construct( * definitions: list|null, * } $definition */ private function isCompatibleDefinition(FieldDefinition $existingDefinition, array $definition): bool { diff --git a/lib/Workflow/UserProfileFieldCheck.php b/lib/Workflow/UserProfileFieldCheck.php index af4cfd7..f7be6cd 100644 --- a/lib/Workflow/UserProfileFieldCheck.php +++ b/lib/Workflow/UserProfileFieldCheck.php @@ -43,6 +43,16 @@ class UserProfileFieldCheck implements ICheck { 'greater', '!less', ]; + private const DATE_OPERATORS = [ + self::OPERATOR_IS_SET, + self::OPERATOR_IS_NOT_SET, + 'is', + '!is', + 'less', + '!greater', + 'greater', + '!less', + ]; private const SELECT_OPERATORS = [ self::OPERATOR_IS_SET, self::OPERATOR_IS_NOT_SET, @@ -166,6 +176,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::DATE => self::DATE_OPERATORS, FieldType::SELECT => self::SELECT_OPERATORS, FieldType::MULTISELECT => self::SELECT_OPERATORS, }; @@ -242,6 +253,11 @@ private function evaluate(FieldDefinition $definition, string $operator, string| $this->normalizeNumericComparisonOperand($expectedValue), $this->normalizeNumericComparisonOperand($actualValue), ), + FieldType::DATE => $this->evaluateDateOperator( + $operator, + $this->normalizeDateComparisonOperand($expectedValue), + $this->normalizeDateComparisonOperand($actualValue), + ), FieldType::MULTISELECT => false, }; } @@ -281,6 +297,19 @@ private function normalizeNumericComparisonOperand(string|int|float|bool|null $v return str_contains((string)$value, '.') ? (float)$value : (int)$value; } + 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'); + } + + $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value); + if ($date === false || $date->format('Y-m-d') !== $value) { + throw new InvalidArgumentException('date comparison value must be a valid YYYY-MM-DD string'); + } + + return $date->getTimestamp(); + } + private function evaluateTextOperator(string $operator, string $expectedValue, string $actualValue): bool { return match ($operator) { 'is' => $actualValue === $expectedValue, @@ -302,4 +331,16 @@ private function evaluateNumberOperator(string $operator, int|float $expectedVal default => false, }; } + + private function evaluateDateOperator(string $operator, int $expectedValue, int $actualValue): bool { + return match ($operator) { + 'is' => $actualValue === $expectedValue, + '!is' => $actualValue !== $expectedValue, + 'less' => $actualValue < $expectedValue, + '!greater' => $actualValue <= $expectedValue, + 'greater' => $actualValue > $expectedValue, + '!less' => $actualValue >= $expectedValue, + default => false, + }; + } } diff --git a/openapi-administration.json b/openapi-administration.json index 5e4d492..0f9d54f 100644 --- a/openapi-administration.json +++ b/openapi-administration.json @@ -198,6 +198,7 @@ "enum": [ "text", "number", + "date", "select", "multiselect" ] diff --git a/openapi-full.json b/openapi-full.json index 450afe6..4da53e6 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -273,6 +273,7 @@ "enum": [ "text", "number", + "date", "select", "multiselect" ] diff --git a/openapi.json b/openapi.json index ee04377..7e6a392 100644 --- a/openapi.json +++ b/openapi.json @@ -144,6 +144,7 @@ "enum": [ "text", "number", + "date", "select", "multiselect" ] diff --git a/src/components/AdminUserFieldsDialog.vue b/src/components/AdminUserFieldsDialog.vue index 61ad08c..e91edef 100644 --- a/src/components/AdminUserFieldsDialog.vue +++ b/src/components/AdminUserFieldsDialog.vue @@ -82,7 +82,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later :error="fieldHasError(field)" :helper-text="helperMessageForField(field)" label-outside - type="text" + :type="componentInputTypeForField(field.definition.type)" :inputmode="inputModeForField(field.definition.type)" :placeholder="placeholderForField(field.definition.type)" @update:model-value="clearFieldError(field.definition.id)" @@ -196,6 +196,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.'), + 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.'), } as Record)[type] @@ -203,15 +204,26 @@ export default defineComponent({ const placeholderForField = (type: FieldType): string => ({ text: t('profile_fields', 'Enter a value'), number: t('profile_fields', 'Enter a number'), + date: t('profile_fields', 'Select a date'), select: t('profile_fields', 'Select an option'), multiselect: t('profile_fields', 'Select one or more options'), } as Record)[type] const plainNumberPattern = /^-?\d+(\.\d+)?$/ + const isoDatePattern = /^\d{4}-\d{2}-\d{2}$/ const inputModeForField = (type: FieldType): string => ({ text: 'text', number: 'decimal', + date: 'numeric', + select: 'text', + multiselect: 'text', + } as Record)[type] + + const componentInputTypeForField = (type: FieldType): string => ({ + text: 'text', + number: 'text', + date: 'date', select: 'text', multiselect: 'text', } as Record)[type] @@ -261,6 +273,14 @@ export default defineComponent({ return t('profile_fields', '{fieldLabel} must be a plain numeric value.', { fieldLabel: field.definition.label }) } + if (field.definition.type === 'date') { + const parsed = isoDatePattern.test(rawValue) ? new Date(`${rawValue}T00:00:00Z`) : null + const normalized = parsed instanceof Date && !Number.isNaN(parsed.getTime()) ? parsed.toISOString().slice(0, 10) : '' + if (normalized !== rawValue) { + return t('profile_fields', '{fieldLabel} must be a valid date in YYYY-MM-DD format.', { fieldLabel: field.definition.label }) + } + } + if (field.definition.type === 'select') { const options = field.definition.options ?? [] if (!options.includes(rawValue)) { @@ -292,7 +312,7 @@ export default defineComponent({ const hasInvalidFields = computed(() => invalidFields.value.length > 0) const helperTextForField = (field: AdminEditableField) => { - return field.definition.type === 'number' + return field.definition.type === 'number' || field.definition.type === 'date' ? descriptionForType(field.definition.type) : '' } @@ -363,6 +383,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 }), + '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') ? t('profile_fields', '{fieldLabel}: invalid option selected.', { fieldLabel: field.definition.label }) @@ -417,6 +438,13 @@ export default defineComponent({ return { value: numericValue, visibility: userDraftVisibilities[field.definition.id] } } + if (field.definition.type === 'date') { + return { + value: rawValue === '' ? null : rawValue, + visibility: userDraftVisibilities[field.definition.id], + } + } + if (field.definition.type === 'multiselect') { const selectedValues = userDraftValues[field.definition.id] if (!Array.isArray(selectedValues)) { @@ -551,6 +579,7 @@ export default defineComponent({ helperTextForField, isLoading, isSavingAny, + componentInputTypeForField, inputModeForField, placeholderForField, saveAllFields, diff --git a/src/tests/components/AdminSettings.spec.ts b/src/tests/components/AdminSettings.spec.ts new file mode 100644 index 0000000..b55b046 --- /dev/null +++ b/src/tests/components/AdminSettings.spec.ts @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import { defineComponent } from 'vue' +import AdminSettings from '../../views/AdminSettings.vue' + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation(() => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })), +}) + +vi.mock('@nextcloud/l10n', () => ({ + n: (_app: string, singular: string, plural: string, count: number, parameters?: Record) => { + const template = count === 1 ? singular : plural + if (parameters === undefined) { + return `tr:${template}` + } + + return Object.entries(parameters).reduce((translated, [key, value]) => translated.replace(`{${key}}`, String(value)), `tr:${template}`) + }, + t: (_app: string, text: string, parameters?: Record) => { + if (parameters === undefined) { + return `tr:${text}` + } + + return Object.entries(parameters).reduce((translated, [key, value]) => translated.replace(`{${key}}`, String(value)), `tr:${text}`) + }, +})) + +vi.mock('../../api', () => ({ + createDefinition: vi.fn(), + deleteDefinition: vi.fn(), + listDefinitions: vi.fn().mockResolvedValue([]), + updateDefinition: vi.fn(), +})) + +vi.mock('@nextcloud/vue', () => ({ + NcActionButton: defineComponent({ template: '' }), + NcActions: defineComponent({ template: '
' }), + NcButton: defineComponent({ + emits: ['click'], + template: '', + }), + NcCheckboxRadioSwitch: defineComponent({ template: '
' }), + NcChip: defineComponent({ template: '
' }), + NcEmptyContent: defineComponent({ template: '
' }), + NcIconSvgWrapper: defineComponent({ template: '' }), + NcInputField: defineComponent({ template: '' }), + NcListItem: defineComponent({ template: '
' }), + NcLoadingIcon: defineComponent({ template: '
' }), + NcNoteCard: defineComponent({ template: '
' }), + NcSelect: defineComponent({ + props: { + options: { type: Array, default: () => [] }, + }, + template: '
{{ option.label }}
', + }), +})) + +vi.mock('@nextcloud/vue/components/NcDialog', () => ({ + default: defineComponent({ template: '
' }), +})) + +vi.mock('../../components/AdminSupportBanner.vue', () => ({ + default: defineComponent({ template: '
' }), +})) + +vi.mock('../../components/admin/AdminSelectOptionsSection.vue', () => ({ + default: defineComponent({ template: '
' }), +})) + +describe('AdminSettings', () => { + it('offers the Date 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:Date') + }) +}) \ No newline at end of file diff --git a/src/tests/components/AdminUserFieldsDialog.spec.ts b/src/tests/components/AdminUserFieldsDialog.spec.ts new file mode 100644 index 0000000..b156e79 --- /dev/null +++ b/src/tests/components/AdminUserFieldsDialog.spec.ts @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import { defineComponent } from 'vue' +import AdminUserFieldsDialog from '../../components/AdminUserFieldsDialog.vue' + +vi.mock('@nextcloud/l10n', () => ({ + n: (_app: string, singular: string, plural: string, count: number, parameters?: Record) => { + const template = count === 1 ? singular : plural + if (parameters === undefined) { + return `tr:${template}` + } + + return Object.entries(parameters).reduce((translated, [key, value]) => translated.replace(`{${key}}`, String(value)), `tr:${template}`) + }, + t: (_app: string, text: string, parameters?: Record) => { + if (parameters === undefined) { + return `tr:${text}` + } + + return Object.entries(parameters).reduce((translated, [key, value]) => translated.replace(`{${key}}`, String(value)), `tr:${text}`) + }, +})) + +vi.mock('../../api', () => ({ + listDefinitions: vi.fn().mockResolvedValue([ + { + id: 1, + field_key: 'start_date', + label: 'Start date', + type: 'date', + edit_policy: 'users', + exposure_policy: 'private', + sort_order: 0, + active: true, + options: null, + }, + ]), + listAdminUserValues: vi.fn().mockResolvedValue([ + { + id: 10, + field_definition_id: 1, + user_uid: 'alice', + value: { value: '2026-03-20' }, + current_visibility: 'private', + updated_by_uid: 'admin', + updated_at: '2026-03-20T12:00:00+00:00', + }, + ]), + upsertAdminUserValue: vi.fn(), +})) + +vi.mock('@nextcloud/vue', () => ({ + NcButton: defineComponent({ template: '' }), + NcDialog: defineComponent({ template: '
' }), + NcEmptyContent: defineComponent({ template: '
' }), + NcInputField: defineComponent({ + inheritAttrs: false, + props: { + id: { type: String, default: '' }, + type: { type: String, default: 'text' }, + inputmode: { type: String, default: 'text' }, + modelValue: { type: [String, Number], default: '' }, + }, + template: '', + }), + NcLoadingIcon: defineComponent({ template: '
' }), + NcNoteCard: defineComponent({ template: '
' }), + NcSelect: defineComponent({ + props: { + options: { type: Array, default: () => [] }, + }, + template: '', + }), + NcLoadingIcon: defineComponent({ template: '
' }), + NcNoteCard: defineComponent({ template: '
' }), + NcPopover: defineComponent({ template: '
' }), + NcSelect: defineComponent({ + props: { + options: { type: Array, default: () => [] }, + }, + template: '