Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
1adfd6d
feat(date): add date field type enum
vitormattos Mar 20, 2026
c2680b0
chore(date): extend response type union
vitormattos Mar 20, 2026
ed4febd
chore(date): widen import service type docs
vitormattos Mar 20, 2026
0c4da6d
feat(date): accept date field definitions
vitormattos Mar 20, 2026
ba80fdf
feat(date): normalize date field values
vitormattos Mar 20, 2026
6fdd650
fix(date): allow date definitions in imports
vitormattos Mar 20, 2026
9030b37
feat(date): support date workflow comparisons
vitormattos Mar 20, 2026
cd4790d
chore(openapi): regenerate admin schema for date
vitormattos Mar 20, 2026
e0c735f
chore(openapi): regenerate full schema for date
vitormattos Mar 20, 2026
1a033e3
chore(openapi): regenerate schema for date
vitormattos Mar 20, 2026
b93f502
feat(date): add date input to admin dialog
vitormattos Mar 20, 2026
de49c9a
test(date): cover workflow date operators
vitormattos Mar 20, 2026
0cd45e0
feat(date): expose date field in frontend types
vitormattos Mar 20, 2026
95755a4
chore(openapi): regenerate admin frontend types
vitormattos Mar 20, 2026
df49ca0
chore(openapi): regenerate full frontend types
vitormattos Mar 20, 2026
04303b7
chore(openapi): regenerate frontend types
vitormattos Mar 20, 2026
f044636
feat(date): map date workflow operators
vitormattos Mar 20, 2026
9e9ca39
feat(date): offer date type in admin settings
vitormattos Mar 20, 2026
aefb20a
feat(date): render date input in personal settings
vitormattos Mar 20, 2026
0a0130c
test(date): cover date definition validation
vitormattos Mar 20, 2026
b32cd50
test(date): cover date value normalization
vitormattos Mar 20, 2026
7b6650d
test(date): cover workflow date checks
vitormattos Mar 20, 2026
b842c1a
test(date): cover admin settings date type
vitormattos Mar 20, 2026
66741db
test(date): cover admin dialog date input
vitormattos Mar 20, 2026
d42f4e3
test(date): cover personal settings date input
vitormattos Mar 20, 2026
52a2414
docs(screenshots): refresh admin catalog thumb
vitormattos Mar 20, 2026
7b823ab
docs(screenshots): refresh personal settings thumb
vitormattos Mar 20, 2026
103e5ea
docs(screenshots): refresh personal settings screenshot
vitormattos Mar 20, 2026
810719c
docs(screenshots): refresh user dialog thumb
vitormattos Mar 20, 2026
d1a59be
docs(screenshots): refresh workflow thumb
vitormattos Mar 20, 2026
573972d
docs(screenshots): add date field admin proof
vitormattos Mar 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified img/screenshots/admin-catalog-thumb.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added img/screenshots/date-field-admin-proof.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified img/screenshots/personal-settings-thumb.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified img/screenshots/personal-settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified img/screenshots/user-management-dialog-thumb.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified img/screenshots/workflow-notify-admins-thumb.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions lib/Enum/FieldType.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
enum FieldType: string {
case TEXT = 'text';
case NUMBER = 'number';
case DATE = 'date';
case SELECT = 'select';
case MULTISELECT = 'multiselect';

Expand All @@ -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,
];
Expand Down
2 changes: 1 addition & 1 deletion lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
6 changes: 3 additions & 3 deletions lib/Service/DataImportService.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ public function import(array $payload, bool $dryRun = false): array {
* @param list<array{
* field_key: non-empty-string,
* label: non-empty-string,
* type: 'text'|'number',
* type: 'text'|'number'|'date'|'select'|'multiselect',
* edit_policy: 'admins'|'users',
* exposure_policy: 'hidden'|'private'|'users'|'public',
* sort_order: int,
Expand Down Expand Up @@ -148,7 +148,7 @@ private function collectValueSummary(array $values, array &$summary): void {
* @param list<array{
* field_key: non-empty-string,
* label: non-empty-string,
* type: 'text'|'number',
* type: 'text'|'number'|'date'|'select'|'multiselect',
* edit_policy: 'admins'|'users',
* exposure_policy: 'hidden'|'private'|'users'|'public',
* sort_order: int,
Expand Down Expand Up @@ -248,7 +248,7 @@ private function persistValues(array $values, array $definitionsByFieldKey, arra
* @param array{
* field_key: non-empty-string,
* label: non-empty-string,
* type: 'text'|'number',
* type: 'text'|'number'|'date'|'select'|'multiselect',
* edit_policy: 'admins'|'users',
* exposure_policy: 'hidden'|'private'|'users'|'public',
* sort_order: int,
Expand Down
2 changes: 1 addition & 1 deletion lib/Service/FieldDefinitionValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions lib/Service/FieldValueService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
Expand Down Expand Up @@ -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<string, mixed>|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<string, mixed> $value
*/
Expand Down
9 changes: 5 additions & 4 deletions lib/Service/ImportPayloadValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public function __construct(
* definitions: list<array{
* field_key: non-empty-string,
* label: non-empty-string,
* type: 'text'|'number',
* type: 'text'|'number'|'date'|'select'|'multiselect',
* edit_policy: 'admins'|'users',
* exposure_policy: 'hidden'|'private'|'users'|'public',
* sort_order: int,
Expand Down Expand Up @@ -71,7 +71,7 @@ public function validate(array $payload): array {
* @return array<non-empty-string, array{
* field_key: non-empty-string,
* label: non-empty-string,
* type: 'text'|'number',
* type: 'text'|'number'|'date'|'select'|'multiselect',
* edit_policy: 'admins'|'users',
* exposure_policy: 'hidden'|'private'|'users'|'public',
* sort_order: int,
Expand Down Expand Up @@ -119,7 +119,7 @@ private function validateDefinitions(array $definitions): array {
* @param array<non-empty-string, array{
* field_key: non-empty-string,
* label: non-empty-string,
* type: 'text'|'number',
* type: 'text'|'number'|'date'|'select'|'multiselect',
* edit_policy: 'admins'|'users',
* exposure_policy: 'hidden'|'private'|'users'|'public',
* sort_order: int,
Expand Down Expand Up @@ -255,11 +255,12 @@ private function normalizeOptionalDate(array $payload, string $key, string $mess
* @param array{
* field_key: non-empty-string,
* label: non-empty-string,
* type: 'text'|'number',
* type: 'text'|'number'|'date'|'select'|'multiselect',
* edit_policy: 'admins'|'users',
* exposure_policy: 'hidden'|'private'|'users'|'public',
* sort_order: int,
* active: bool,
* options?: list<string>|null,
* } $definition
*/
private function isCompatibleDefinition(FieldDefinition $existingDefinition, array $definition): bool {
Expand Down
41 changes: 41 additions & 0 deletions lib/Workflow/UserProfileFieldCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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,
};
}
Expand Down Expand Up @@ -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,
Expand All @@ -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,
};
}
}
1 change: 1 addition & 0 deletions openapi-administration.json
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@
"enum": [
"text",
"number",
"date",
"select",
"multiselect"
]
Expand Down
1 change: 1 addition & 0 deletions openapi-full.json
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@
"enum": [
"text",
"number",
"date",
"select",
"multiselect"
]
Expand Down
1 change: 1 addition & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@
"enum": [
"text",
"number",
"date",
"select",
"multiselect"
]
Expand Down
33 changes: 31 additions & 2 deletions src/components/AdminUserFieldsDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down Expand Up @@ -196,22 +196,34 @@ 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<FieldType, string>)[type]

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<FieldType, string>)[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<FieldType, string>)[type]

const componentInputTypeForField = (type: FieldType): string => ({
text: 'text',
number: 'text',
date: 'date',
select: 'text',
multiselect: 'text',
} as Record<FieldType, string>)[type]
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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)
: ''
}
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -551,6 +579,7 @@ export default defineComponent({
helperTextForField,
isLoading,
isSavingAny,
componentInputTypeForField,
inputModeForField,
placeholderForField,
saveAllFields,
Expand Down
Loading
Loading