Skip to content

Commit

Permalink
fix: time restriction upper bound conversion issues (#832)
Browse files Browse the repository at this point in the history
  • Loading branch information
richardtreier authored Sep 17, 2024
1 parent 930083b commit f36aa68
Show file tree
Hide file tree
Showing 8 changed files with 140 additions and 47 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
([#815](https://github.com/sovity/edc-ui/issues/815))

## [v4.1.3] - 2024-09-03

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,6 @@ export const buildTimespanRestriction = (
return multi(
'AND',
evaluationTimeConstraint('GEQ', firstDay),
evaluationTimeConstraint('LT', addDays(lastDay, 1)),
evaluationTimeConstraint('LEQ', addDays(lastDay, 1)),
);
};
105 changes: 66 additions & 39 deletions src/app/component-library/policy-editor/model/policy-form-adapter.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
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,
Expand Down Expand Up @@ -49,49 +59,39 @@ const stringLiteral = (value: string | null | undefined): UiPolicyLiteral => ({
});

export const localDateAdapter: PolicyFormAdapter<Date | null> = {
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: (value) => stringLiteral(value?.toISOString()),
emptyConstraintValue: () => ({
operator: 'LT',
right: {
type: 'STRING',
},
}),
};
buildValueFn: (valueOrNull, operator) => {
return stringLiteral(
safeConversion(valueOrNull, (value) => {
const upperBound = isUpperBound(operator);

export const stringAdapter: PolicyFormAdapter<string> = {
displayText: (literal): string | null =>
readSingleStringLiteral(literal) ?? '',
fromControlFactory: () => new UntypedFormControl('', Validators.required),
buildFormValueFn: (literal): string => readSingleStringLiteral(literal) ?? '',
buildValueFn: (value) => stringLiteral(value),
return localTzDayToIsoString(value, upperBound);
}),
);
},
emptyConstraintValue: () => ({
operator: 'EQ',
operator: 'LT',
right: {
type: 'STRING',
value: '',
},
}),
};
Expand Down Expand Up @@ -146,3 +146,30 @@ export const jsonAdapter: PolicyFormAdapter<string> = {
},
}),
};

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 = <T, R>(
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;
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
}

Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions src/app/core/adapters/custom-date-adapter.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
63 changes: 61 additions & 2 deletions src/app/core/utils/date-utils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,70 @@
import {addDays, subDays} from 'date-fns';
import {format} from 'date-fns-tz';

/**
* Takes the year/month/day information of a local date and creates a new Date object from it.
* Hour offset context is removed.
* Can be used to ensure dates are displayed identically across different timezones when stringified in JSON payloads.
* @param date date to convert
*/
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();
}

0 comments on commit f36aa68

Please sign in to comment.