diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c2b1c075..e0ec4331f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ the detailed section referring to by linking pull requests or issues. - Data Offer details now display the contract ID for each contract offer - Added validator for the Policy Id - Added validator for the Data Offer Id +- Fixed time restriction upper bound "local day to datetime" conversion issues + (https://github.com/sovity/edc-ui/issues/815) ## [v4.1.3] - 2024-09-03 diff --git a/src/app/component-library/policy-editor/editor/expression-form-controls.ts b/src/app/component-library/policy-editor/editor/expression-form-controls.ts index f980317f0..f7ae76a1d 100644 --- a/src/app/component-library/policy-editor/editor/expression-form-controls.ts +++ b/src/app/component-library/policy-editor/editor/expression-form-controls.ts @@ -50,7 +50,9 @@ export class ExpressionFormControls { const operatorControl = new UntypedFormControl(operatorConfig); const valueControl = expr.verb!.adapter.fromControlFactory(); - valueControl.reset(expr.verb!.adapter.buildFormValueFn(value)); + valueControl.reset( + expr.verb!.adapter.buildFormValueFn(value, operatorConfig), + ); this.formGroup.addControl(`${nodeId}-value`, valueControl); this.formGroup.addControl(`${nodeId}-op`, operatorControl); diff --git a/src/app/component-library/policy-editor/editor/recipes/timespan-restriction-dialog/timespan-restriction-expression.ts b/src/app/component-library/policy-editor/editor/recipes/timespan-restriction-dialog/timespan-restriction-expression.ts index 684a9920f..ddb2b20e8 100644 --- a/src/app/component-library/policy-editor/editor/recipes/timespan-restriction-dialog/timespan-restriction-expression.ts +++ b/src/app/component-library/policy-editor/editor/recipes/timespan-restriction-dialog/timespan-restriction-expression.ts @@ -17,6 +17,6 @@ export const buildTimespanRestriction = ( return multi( 'AND', evaluationTimeConstraint('GEQ', firstDay), - evaluationTimeConstraint('LT', addDays(lastDay, 1)), + evaluationTimeConstraint('LEQ', addDays(lastDay, 1)), ); }; diff --git a/src/app/component-library/policy-editor/model/policy-form-adapter.ts b/src/app/component-library/policy-editor/model/policy-form-adapter.ts index d9fc0383e..df447a278 100644 --- a/src/app/component-library/policy-editor/model/policy-form-adapter.ts +++ b/src/app/component-library/policy-editor/model/policy-form-adapter.ts @@ -1,14 +1,24 @@ import {UntypedFormControl, Validators} from '@angular/forms'; import {UiPolicyConstraint, UiPolicyLiteral} from '@sovity.de/edc-client'; -import {format} from 'date-fns-tz'; import {filterNonNull} from '../../../core/utils/array-utils'; +import { + localTzDayToIsoString, + truncateToLocalTzDay, + truncateToLocalTzDayRaw, +} from '../../../core/utils/date-utils'; import {jsonValidator} from '../../../core/validators/json-validator'; import {PolicyOperatorConfig} from './policy-operators'; export interface PolicyFormAdapter { - displayText: (value: UiPolicyLiteral) => string | null; + displayText: ( + value: UiPolicyLiteral, + operator: PolicyOperatorConfig, + ) => string | null; fromControlFactory: () => UntypedFormControl; - buildFormValueFn: (literal: UiPolicyLiteral) => T; + buildFormValueFn: ( + literal: UiPolicyLiteral, + operator: PolicyOperatorConfig, + ) => T; buildValueFn: ( formValue: T, operator: PolicyOperatorConfig, @@ -49,30 +59,35 @@ const stringLiteral = (value: string | null | undefined): UiPolicyLiteral => ({ }); export const localDateAdapter: PolicyFormAdapter = { - displayText: (literal): string | null => { - const value = readSingleStringLiteral(literal); - try { - if (!value) { - return value; - } - return format(new Date(value), 'dd/MM/yyyy'); - } catch (e) { - return '' + value; - } + displayText: (literal, operator): string | null => { + const stringOrNull = readSingleStringLiteral(literal); + return safeConversion(stringOrNull, (string) => { + const date = new Date(string); + const upperBound = isUpperBound(operator); + + return truncateToLocalTzDay(date, upperBound); + }); }, fromControlFactory: () => new UntypedFormControl(null, Validators.required), - buildFormValueFn: (literal): Date | null => { - const value = readSingleStringLiteral(literal); - try { - if (!value) { - return null; - } - return new Date(value); - } catch (e) { - return null; - } + buildFormValueFn: (literal, operator): Date | null => { + const stringOrNull = readSingleStringLiteral(literal); + return safeConversion(stringOrNull, (string) => { + const date = new Date(string); + const upperBound = isUpperBound(operator); + + // Editing datetimes from a different TZ as days has no good solution + return truncateToLocalTzDayRaw(date, upperBound); + }); + }, + buildValueFn: (valueOrNull, operator) => { + return stringLiteral( + safeConversion(valueOrNull, (value) => { + const upperBound = isUpperBound(operator); + + return localTzDayToIsoString(value, upperBound); + }), + ); }, - buildValueFn: (value) => stringLiteral(value?.toISOString()), emptyConstraintValue: () => ({ operator: 'LT', right: { @@ -146,3 +161,30 @@ export const jsonAdapter: PolicyFormAdapter = { }, }), }; + +const isUpperBound = (operator: PolicyOperatorConfig) => + operator.id === 'LT' || operator.id === 'LEQ'; + +/** + * Helper function for reducing mental complexity of mapping code: + * - Handles null input + * - Handles undefined output + * - Catches exceptions and returns null + * + * @param valueOrNull value + * @param mapper mapper + */ +const safeConversion = ( + valueOrNull: T | null | undefined, + mapper: (it: T) => R | null | undefined, +): R | null => { + if (valueOrNull == null) { + return null; + } + + try { + return mapper(valueOrNull) ?? null; + } catch (e) { + return null; + } +}; diff --git a/src/app/component-library/policy-editor/model/policy-mapper.ts b/src/app/component-library/policy-editor/model/policy-mapper.ts index 173ba4e78..6b254bf33 100644 --- a/src/app/component-library/policy-editor/model/policy-mapper.ts +++ b/src/app/component-library/policy-editor/model/policy-mapper.ts @@ -56,7 +56,7 @@ export class PolicyMapper { operator, valueRaw: value, valueJson: this.formatJson(value!), - displayValue: this.formatValue(value, verb) ?? 'null', + displayValue: this.formatValue(value, verb, operator) ?? 'null', }; } @@ -110,12 +110,13 @@ export class PolicyMapper { private formatValue( value: UiPolicyLiteral | undefined, verbConfig: PolicyVerbConfig, + operatorConfig: PolicyOperatorConfig, ) { if (value == null) { return ''; } - return verbConfig.adapter.displayText(value); + return verbConfig.adapter.displayText(value, operatorConfig); } private formatJson(value: UiPolicyLiteral): string { diff --git a/src/app/core/adapters/custom-date-adapter.ts b/src/app/core/adapters/custom-date-adapter.ts index d9355d450..d920b162c 100644 --- a/src/app/core/adapters/custom-date-adapter.ts +++ b/src/app/core/adapters/custom-date-adapter.ts @@ -1,7 +1,9 @@ +import {Injectable} from '@angular/core'; import {NativeDateAdapter} from '@angular/material/core'; import {isValid, parse as parseDate} from 'date-fns'; import {format as formateDate} from 'date-fns-tz'; +@Injectable() export class CustomDateAdapter extends NativeDateAdapter { parse(value: any): Date | null { if (typeof value === 'string' && value.indexOf('/') > -1) { diff --git a/src/app/core/services/api/fake-backend/connector-fake-impl/data/test-policies.ts b/src/app/core/services/api/fake-backend/connector-fake-impl/data/test-policies.ts index 43585777d..7c3f72f90 100644 --- a/src/app/core/services/api/fake-backend/connector-fake-impl/data/test-policies.ts +++ b/src/app/core/services/api/fake-backend/connector-fake-impl/data/test-policies.ts @@ -26,12 +26,12 @@ export namespace TestPolicies { constraint( policyLeftExpressions.policyEvaluationTime, 'GEQ', - '2020-12-31T23:00:00.000Z', + '2020-11-30T23:00:00.000Z', ), constraint( policyLeftExpressions.policyEvaluationTime, 'LT', - '2024-12-31T23:00:00.000Z', + '2020-12-07T23:00:00.000Z', ), multi( 'OR', diff --git a/src/app/core/utils/date-utils.ts b/src/app/core/utils/date-utils.ts index b286c83f0..d442f46ea 100644 --- a/src/app/core/utils/date-utils.ts +++ b/src/app/core/utils/date-utils.ts @@ -4,8 +4,67 @@ * Can be used to ensure dates are displayed identically across different timezones when stringified in JSON payloads. * @param date date to convert */ +import {addDays, subDays} from 'date-fns'; import {format} from 'date-fns-tz'; export function toGmtZeroHourDate(date: Date): Date { return new Date(format(date, 'yyyy-MM-dd')); } + +export function isMidnightInCurrentTz(date: Date): boolean { + return format(date, 'HH:mm:ss') === '00:00:00'; +} + +/** + * Helper for dealing with the problem: + * - Our API does date comparisons based on Date + Time + TZ + * - Our UI tries to simplify it to "select full days only" + * + * Here we try to reverse the ISO UTC DateTime String to a day, while considering different TZs: + * - We accept only midnight in the local tz + * - If the date is used for an upper bound, we subtract a day + */ +export function truncateToLocalTzDay( + date: Date, + isUpperBound: boolean, +): string { + date = truncateToLocalTzDayRaw(date, isUpperBound); + + if (isMidnightInCurrentTz(date)) { + return format(date, 'dd/MM/yyyy'); + } + + // Fallback + return format(date, 'dd/MM/yyyy HH:mm:ss'); +} + +export function truncateToLocalTzDayRaw( + date: Date, + isUpperBound: boolean, +): Date { + if (isMidnightInCurrentTz(date) && isUpperBound) { + // Transform "x <= 2000-01-02 00:00:00" to "x <= 2000-01-01" + date = subDays(date, 1); + } + return date; +} + +/** + * Helper for dealing with the problem: + * - Our API does date comparisons based on Date + Time + TZ + * - Our UI tries to simplify it to "select full days only" + * + * Here we take a local tz "day" and convert it to an ISO UTC DateTime String: + * - If the date is used for an upper bound, we add a day + */ +export function localTzDayToIsoString( + date: Date, + isUpperBound: boolean, +): string { + if (isUpperBound) { + // Transform "x <= 2000-01-01" to "x <= 2000-01-02 00:00:00" + date = addDays(date, 1); + } + + return date.toISOString(); +}