From 1adfd6d5542e8362f2faa8481a8053ca6a479125 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:33:22 -0300 Subject: [PATCH 01/31] feat(date): add date field type enum Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Enum/FieldType.php | 2 ++ 1 file changed, 2 insertions(+) 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, ]; From c2680b090109c52b5dc9b6f7f7ee1e14bd97ac28 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:33:23 -0300 Subject: [PATCH 02/31] chore(date): extend response type union Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/ResponseDefinitions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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' From ed4febd9e91d3e1e3fc2836fa32df9a14759daa4 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:33:23 -0300 Subject: [PATCH 03/31] chore(date): widen import service type docs Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/DataImportService.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 Date: Fri, 20 Mar 2026 18:33:23 -0300 Subject: [PATCH 04/31] feat(date): accept date field definitions Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/FieldDefinitionValidator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Service/FieldDefinitionValidator.php b/lib/Service/FieldDefinitionValidator.php index 853af15..b7e41d0 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'|'multiselect', + * type: 'text'|'number'|'date'|'select'|'multiselect', * edit_policy: 'admins'|'users', * exposure_policy: 'hidden'|'private'|'users'|'public', * sort_order: int, From ba80fdf6961029ddd392145bc636d8ccdad8600e Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:33:23 -0300 Subject: [PATCH 05/31] feat(date): normalize date field values Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/FieldValueService.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/lib/Service/FieldValueService.php b/lib/Service/FieldValueService.php index 5b25160..d7fc5b3 100644 --- a/lib/Service/FieldValueService.php +++ b/lib/Service/FieldValueService.php @@ -106,6 +106,7 @@ public function normalizeValue(FieldDefinition $definition, array|string|int|flo return match ($type) { FieldType::TEXT => $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 */ From 6fdd650587b78ea6233d7711a8a7db7b7974f291 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:33:23 -0300 Subject: [PATCH 06/31] fix(date): allow date definitions in imports Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/ImportPayloadValidator.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 { From 9030b3767010abc487432d3280151f1db79433fd Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:33:23 -0300 Subject: [PATCH 07/31] feat(date): support date workflow comparisons Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Workflow/UserProfileFieldCheck.php | 41 ++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) 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, + }; + } } From cd4790d2624348de4d778ec782ae1dfe18e3715f Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:33:23 -0300 Subject: [PATCH 08/31] chore(openapi): regenerate admin schema for date Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- openapi-administration.json | 1 + 1 file changed, 1 insertion(+) 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" ] From e0c735fb6e1b0b30fd6a4dc6b3ffa504cad35601 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:33:23 -0300 Subject: [PATCH 09/31] chore(openapi): regenerate full schema for date Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- openapi-full.json | 1 + 1 file changed, 1 insertion(+) 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" ] From 1a033e33dbfbaa4dad035f046bb17de19cc4d6d5 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:33:24 -0300 Subject: [PATCH 10/31] chore(openapi): regenerate schema for date Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- openapi.json | 1 + 1 file changed, 1 insertion(+) 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" ] From b93f5023d2c5470d860e942b7f0f8b311c3f15f6 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:33:24 -0300 Subject: [PATCH 11/31] feat(date): add date input to admin dialog Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/components/AdminUserFieldsDialog.vue | 33 ++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) 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, From de49c9a6845d1043cf3c31b217ccb1d6b0e66596 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:33:24 -0300 Subject: [PATCH 12/31] test(date): cover workflow date operators Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/tests/utils/workflowProfileFieldCheck.spec.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/tests/utils/workflowProfileFieldCheck.spec.ts b/src/tests/utils/workflowProfileFieldCheck.spec.ts index 3feddb4..359e399 100644 --- a/src/tests/utils/workflowProfileFieldCheck.spec.ts +++ b/src/tests/utils/workflowProfileFieldCheck.spec.ts @@ -13,6 +13,7 @@ import { 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 }, ] as const describe('workflowProfileFieldCheck', () => { @@ -46,6 +47,19 @@ describe('workflowProfileFieldCheck', () => { ]) }) + it('returns numeric-style comparison operators for date definitions', () => { + expect(getWorkflowOperatorKeys(serializeWorkflowCheckValue({ field_key: 'start_date', value: '2026-03-20' }), definitions)).toEqual([ + 'is-set', + '!is-set', + 'is', + '!is', + 'less', + '!greater', + 'greater', + '!less', + ]) + }) + 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) From 0cd45e022e3d437f43a4e1875c3683d7fe9dfccd Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:33:24 -0300 Subject: [PATCH 13/31] feat(date): expose date field in frontend types Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/types/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/index.ts b/src/types/index.ts index afc532b..7964e15 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' +export type FieldType = ApiComponents['schemas']['Type'] | 'multiselect' | 'date' export type FieldVisibility = ApiComponents['schemas']['Visibility'] export type FieldEditPolicy = 'admins' | 'users' export type FieldExposurePolicy = 'hidden' | FieldVisibility From 95755a4938bad1a182324a5fcde217d4383fee25 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:33:24 -0300 Subject: [PATCH 14/31] chore(openapi): regenerate admin frontend types Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/types/openapi/openapi-administration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/openapi/openapi-administration.ts b/src/types/openapi/openapi-administration.ts index 352a7ca..439298c 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" | "multiselect"; + Type: "text" | "number" | "date" | "select" | "multiselect"; ValuePayload: { value: Record; }; From df49ca07919f43a7ca0cde3cc1779cc4144cc60c Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:33:24 -0300 Subject: [PATCH 15/31] chore(openapi): regenerate full frontend types Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/types/openapi/openapi-full.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index da7f5b1..15517c5 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" | "multiselect"; + Type: "text" | "number" | "date" | "select" | "multiselect"; ValuePayload: { value: Record; }; From 04303b79f2ad7277c50712d42af7cd560d280743 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:33:24 -0300 Subject: [PATCH 16/31] chore(openapi): regenerate frontend types Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/types/openapi/openapi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index a27820c..333128a 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" | "multiselect"; + Type: "text" | "number" | "date" | "select" | "multiselect"; ValuePayload: { value: Record; }; From f044636c1e22b0de2487d1c8d07011944e4a2f33 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:33:24 -0300 Subject: [PATCH 17/31] feat(date): map date workflow operators Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/utils/workflowProfileFieldCheck.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/utils/workflowProfileFieldCheck.ts b/src/utils/workflowProfileFieldCheck.ts index 12634af..bc54f97 100644 --- a/src/utils/workflowProfileFieldCheck.ts +++ b/src/utils/workflowProfileFieldCheck.ts @@ -63,6 +63,7 @@ export const getWorkflowOperatorKeys = (rawValue: string | null | undefined, def } return definition.type === 'number' + || definition.type === 'date' ? [...numberOperatorKeys] : [...textOperatorKeys] } From 9e9ca39400600669e2161a926de6a5d049e2a0ad Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:33:24 -0300 Subject: [PATCH 18/31] feat(date): offer date type in admin settings Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/views/AdminSettings.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/src/views/AdminSettings.vue b/src/views/AdminSettings.vue index 3a54309..73a3f29 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: 'date', label: t('profile_fields', 'Date') }, { value: 'select', label: t('profile_fields', 'Select') }, { value: 'multiselect', label: t('profile_fields', 'Multiselect') }, ] From aefb20a67a4704e901dd18423cbded0b5035e929 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:33:24 -0300 Subject: [PATCH 19/31] feat(date): render date input in personal settings Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/views/PersonalSettings.vue | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/views/PersonalSettings.vue b/src/views/PersonalSettings.vue index 97496b4..d06da8e 100644 --- a/src/views/PersonalSettings.vue +++ b/src/views/PersonalSettings.vue @@ -234,14 +234,15 @@ const embeddedVisibilityAnchorReady = ref(false) const draftValues = reactive>({}) const draftVisibilities = reactive>({}) -const inputModesByType: Record = { +const inputModesByType: Record = { text: 'text', number: 'decimal', + date: 'numeric', select: 'text', multiselect: 'text', } -const inputModeForType = (type: FieldType): 'text' | 'decimal' => { +const inputModeForType = (type: FieldType): 'text' | 'decimal' | 'numeric' => { return inputModesByType[type] } @@ -251,14 +252,15 @@ const managedByAdminAriaLabel = (fieldLabel: string) => t('profile_fields', '{fi const fieldInputId = (fieldId: number) => `profile-fields-personal-value-${fieldId}` -const componentInputTypesByType: Record = { +const componentInputTypesByType: Record = { text: 'text', number: 'number', + date: 'date', select: 'text', multiselect: 'text', } -const componentInputTypeForType = (type: FieldType): 'text' | 'number' => { +const componentInputTypeForType = (type: FieldType): 'text' | 'number' | 'date' => { return componentInputTypesByType[type] } @@ -271,6 +273,10 @@ const placeholderForField = (field: EditableField) => { return '' } + if (field.definition.type === 'date') { + return '' + } + if (field.definition.type === 'select') { return t('profile_fields', 'Select an option') } @@ -408,7 +414,7 @@ const canAutosaveField = (field: EditableField) => { return field.definition.type === 'multiselect' } - if (field.definition.type === 'text' || field.definition.type === 'select') { + if (field.definition.type === 'text' || field.definition.type === 'select' || field.definition.type === 'date') { return true } @@ -526,6 +532,13 @@ const buildPayload = (field: EditableField) => { } } + if (field.definition.type === 'date') { + return { + value: rawValue === '' ? null : rawValue, + currentVisibility, + } + } + if (field.definition.type === 'multiselect') { return { value: Array.isArray(rawValue) ? rawValue : [], From 0a0130c666661a814e8a835334230dba51cdcfed Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:33:25 -0300 Subject: [PATCH 20/31] test(date): cover date definition validation Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../php/Unit/Service/FieldDefinitionValidatorTest.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/php/Unit/Service/FieldDefinitionValidatorTest.php b/tests/php/Unit/Service/FieldDefinitionValidatorTest.php index 966fb43..4dbbc80 100644 --- a/tests/php/Unit/Service/FieldDefinitionValidatorTest.php +++ b/tests/php/Unit/Service/FieldDefinitionValidatorTest.php @@ -108,6 +108,17 @@ public function testValidateMultiSelectFieldDefinition(): void { $this->assertSame(['LATAM', 'EMEA', 'APAC'], $validated['options']); } + public function testValidateDateFieldDefinition(): void { + $validated = $this->validator->validate([ + 'field_key' => 'start_date', + 'label' => 'Start Date', + 'type' => FieldType::DATE->value, + ]); + + $this->assertSame(FieldType::DATE->value, $validated['type']); + $this->assertNull($validated['options']); + } + public function testRejectMultiSelectWithNoOptions(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('multiselect fields require at least one option'); From b32cd509a7bc54deb3df699d9b0eb016f2077425 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:33:25 -0300 Subject: [PATCH 21/31] test(date): cover date value normalization Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../php/Unit/Service/FieldValueServiceTest.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/php/Unit/Service/FieldValueServiceTest.php b/tests/php/Unit/Service/FieldValueServiceTest.php index b0e541f..03043bd 100644 --- a/tests/php/Unit/Service/FieldValueServiceTest.php +++ b/tests/php/Unit/Service/FieldValueServiceTest.php @@ -48,6 +48,23 @@ public function testNormalizeNumberValue(): void { $this->assertSame(['value' => 42], $normalized); } + public function testNormalizeDateValueAcceptsIsoDate(): void { + $definition = $this->buildDefinition(FieldType::DATE->value); + + $normalized = $this->service->normalizeValue($definition, '2026-03-20'); + + $this->assertSame(['value' => '2026-03-20'], $normalized); + } + + public function testNormalizeDateValueRejectsInvalidDate(): void { + $definition = $this->buildDefinition(FieldType::DATE->value); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Date fields require a valid ISO-8601 date in YYYY-MM-DD format.'); + + $this->service->normalizeValue($definition, '2026-02-30'); + } + public function testNormalizeMissingValueAsNull(): void { $definition = $this->buildDefinition(FieldType::TEXT->value); From 7b6650d589cdfafcee1fb282148fd30dd8296dbf Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:33:25 -0300 Subject: [PATCH 22/31] test(date): cover workflow date checks Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Workflow/UserProfileFieldCheckTest.php | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/php/Unit/Workflow/UserProfileFieldCheckTest.php b/tests/php/Unit/Workflow/UserProfileFieldCheckTest.php index 3b80de2..700839a 100644 --- a/tests/php/Unit/Workflow/UserProfileFieldCheckTest.php +++ b/tests/php/Unit/Workflow/UserProfileFieldCheckTest.php @@ -133,6 +133,36 @@ public function testExecuteCheckMatchesNumericComparison(): void { $this->assertTrue($this->check->executeCheck('greater', $this->encodeConfig('score', '9'))); } + public function testExecuteCheckMatchesDateComparison(): void { + $definition = $this->buildDefinition(7, 'start_date', FieldType::DATE->value); + $value = $this->buildStoredValue(7, 'alice', '{"value":"2026-03-20"}'); + + $this->fieldDefinitionService->expects($this->once()) + ->method('findByFieldKey') + ->with('start_date') + ->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('greater', $this->encodeConfig('start_date', '2026-03-19'))); + } + + public function testValidateCheckRejectsContainsForDateField(): void { + $this->fieldDefinitionService->expects($this->once()) + ->method('findByFieldKey') + ->with('start_date') + ->willReturn($this->buildDefinition(7, 'start_date', FieldType::DATE->value)); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('The selected operator is not supported for this profile field'); + + $this->check->validateCheck('contains', $this->encodeConfig('start_date', '2026-03-20')); + } + public function testExecuteCheckTreatsMissingValueAsNotSet(): void { $definition = $this->buildDefinition(7, 'region', FieldType::TEXT->value); From b842c1aedf06668e737b6014fc030a4a59f26cd1 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:33:25 -0300 Subject: [PATCH 23/31] test(date): cover admin settings date type Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/tests/components/AdminSettings.spec.ts | 93 ++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 src/tests/components/AdminSettings.spec.ts 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 From 66741dba67875244938da9b8651b2b20bd630417 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:33:25 -0300 Subject: [PATCH 24/31] test(date): cover admin dialog date input Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../components/AdminUserFieldsDialog.spec.ts | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 src/tests/components/AdminUserFieldsDialog.spec.ts 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: '