Skip to content

Commit

Permalink
Cleanup in Date component + formatDate expression (#2959)
Browse files Browse the repository at this point in the history
Co-authored-by: Ole Martin Handeland <[email protected]>
  • Loading branch information
olemartinorg and Ole Martin Handeland authored Jan 31, 2025
1 parent b489108 commit 9412d83
Show file tree
Hide file tree
Showing 135 changed files with 1,474 additions and 49 deletions.
41 changes: 13 additions & 28 deletions src/app-components/Date/DisplayDate.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,23 @@
import React from 'react';

import { formatDate, isValid, parseISO } from 'date-fns';

import classes from 'src/app-components/Date/Date.module.css';

interface DateProps {
format?: string;
value: string;
value: React.ReactNode;
iconUrl?: string;
iconAltText?: string;
labelId?: string;
}

export const DisplayDate = ({ value, format, iconUrl, iconAltText, labelId }: DateProps) => {
const parsedValue = parseISO(value);
let displayData = parsedValue.toDateString();
if (!isValid(parsedValue)) {
window.logErrorOnce(`Ugyldig datoformat gitt til Date-komponent: "${value}"`);
displayData = '';
} else if (format) {
displayData = formatDate(parsedValue, format);
}

return (
<>
{iconUrl && (
<img
src={iconUrl}
className={classes.icon}
alt={iconAltText}
/>
)}
{labelId && <span aria-labelledby={labelId}>{displayData}</span>}
{!labelId && <span>{displayData}</span>}
</>
);
};
export const DisplayDate = ({ value, iconUrl, iconAltText, labelId }: DateProps) => (
<>
{iconUrl && (
<img
src={iconUrl}
className={classes.icon}
alt={iconAltText}
/>
)}
<span aria-labelledby={labelId}>{value}</span>
</>
);
2 changes: 2 additions & 0 deletions src/codegen/dataTypes/GenerateExpressionOr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ const toTsMap: { [key in ExprVal]: string } = {
[ExprVal.Boolean]: 'ExprValToActualOrExpr<ExprVal.Boolean>',
[ExprVal.Number]: 'ExprValToActualOrExpr<ExprVal.Number>',
[ExprVal.String]: 'ExprValToActualOrExpr<ExprVal.String>',
[ExprVal.Date]: 'ExprValToActualOrExpr<ExprVal.Date>',
};

const toSchemaMap: { [key in ExprVal]: JSONSchema7 } = {
[ExprVal.Any]: { $ref: 'expression.schema.v1.json#/definitions/any' },
[ExprVal.Boolean]: { $ref: 'expression.schema.v1.json#/definitions/boolean' },
[ExprVal.Number]: { $ref: 'expression.schema.v1.json#/definitions/number' },
[ExprVal.String]: { $ref: 'expression.schema.v1.json#/definitions/string' },
[ExprVal.Date]: { $ref: 'expression.schema.v1.json#/definitions/string' },
};

type TypeMap<Val extends ExprVal> = Val extends ExprVal.Boolean
Expand Down
7 changes: 3 additions & 4 deletions src/features/expressions/expression-functions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import dot from 'dot-object';

import { isDate } from 'src/app-components/Datepicker/utils/dateHelpers';
import { ExprRuntimeError, NodeNotFound, NodeNotFoundWithoutContext } from 'src/features/expressions/errors';
import { ExprVal } from 'src/features/expressions/types';
import { addError } from 'src/features/expressions/validation';
Expand Down Expand Up @@ -153,7 +152,7 @@ export const ExprFunctionDefinitions = {
returns: ExprVal.String,
},
formatDate: {
args: args(required(ExprVal.String), optional(ExprVal.String)),
args: args(required(ExprVal.Date), optional(ExprVal.String)),
returns: ExprVal.String,
},
round: {
Expand Down Expand Up @@ -468,10 +467,10 @@ export const ExprFunctionImplementations: { [K in Names]: Implementation<K> } =
});
},
formatDate(date, format) {
if (date === null || !isDate(date)) {
if (date === null) {
return null;
}
const result = formatDateLocale(this.dataSources.currentLanguage, new Date(date), format ?? undefined);
const result = formatDateLocale(this.dataSources.currentLanguage, date, format ?? undefined);
if (result.includes('Unsupported: ')) {
throw new ExprRuntimeError(this.expr, this.path, `Unsupported date format token in '${format}'`);
}
Expand Down
8 changes: 8 additions & 0 deletions src/features/expressions/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ describe('Expressions', () => {
}
}

if (def.returns === ExprVal.Date) {
throw new Error(
'Date is not a valid return type for an expression function. It is only possible to receive a Date as ' +
'an argument, and if you need to return a Date, you should return it as a string (formatted in a way ' +
'that lets the date parser parse it).',
);
}

expect(def.returns).toBeDefined();
expect(def.args).toBeDefined();
});
Expand Down
84 changes: 84 additions & 0 deletions src/features/expressions/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isValid, parseISO } from 'date-fns';
import dot from 'dot-object';

import {
Expand Down Expand Up @@ -325,4 +326,87 @@ export const ExprTypes: {
accepts: [ExprVal.Boolean, ExprVal.String, ExprVal.Number, ExprVal.Any],
impl: (arg) => arg,
},
[ExprVal.Date]: {
nullable: true,
accepts: [ExprVal.String, ExprVal.Number, ExprVal.Date, ExprVal.Any],
impl(arg) {
if (typeof arg === 'number') {
return exprParseDate(this, String(arg)); // Might be just a 4-digit year
}

if (typeof arg === 'string') {
return arg ? exprParseDate(this, arg) : null;
}

throw new UnexpectedType(this.expr, this.path, 'date', arg);
},
},
};

/**
* Strict date parser. We don't want to support all the formats that Date.parse() supports, because that
* would make it more difficult to implement the same functionality on the backend. For that reason, we
* limit ourselves to simple ISO 8601 dates + the format DateTime is serialized to JSON in.
*/
const datePatterns = [
/^(\d{4})$/,
/^(\d{4})-(\d{2})-(\d{2})T?$/,
/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})Z?([+-]\d{2}:\d{2})?$/,
/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})Z?([+-]\d{2}:\d{2})?$/,
/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})\.(\d+)Z?([+-]\d{2}:\d{2})?$/,
];

function exprParseDate(ctx: EvaluateExpressionParams, _date: string): Date | null {
const date = _date.toUpperCase();
for (const regexIdx in datePatterns) {
const regex = datePatterns[regexIdx];
const match = regex.exec(date);
if (!match) {
// To maintain compatibility with the backend, we only allow the above regexes to be parsed
continue;
}

// Special case that parseISO doesn't catch: Time zone offset cannot be +- >= 24 hours
const lastGroup = match[match.length - 1];
if (lastGroup && (lastGroup.startsWith('-') || lastGroup.startsWith('+'))) {
const offsetHours = parseInt(lastGroup.substring(1, 3), 10);
if (offsetHours >= 24) {
throw new ExprRuntimeError(
ctx.expr,
ctx.path,
`Unable to parse date "${date}": Format was recognized, but the date/time is invalid`,
);
}
}

const parsed = parseISO(date);
if (!isValid(parsed.getTime())) {
throw new ExprRuntimeError(
ctx.expr,
ctx.path,
`Unable to parse date "${date}": Format was recognized, but the date/time is invalid`,
);
}

// Special case that parseISO gets wrong: Fractional seconds with more than 3 digits
// https://github.com/date-fns/date-fns/issues/3194
// https://github.com/date-fns/date-fns/pull/3199
if (regexIdx === '4' && match[7] && match[7].length > 3) {
// This is a sloppy workaround, and not really a fix. By just setting the correct amount of milliseconds we
// fix our shared tests to match the backend, but if you have an edge-case like 31.12.2021 23:59:59.9999999
// the parseISO function will think it's 2022. Saying it's really 01.01.2022 00:00:00.999 (like we're doing here)
// may look like we're just making things worse, but in most cases high precision fractionals will not roll you
// over to the next second (let alone the next year).
const ms = parseInt(match[7].substring(0, 3), 10);
parsed.setMilliseconds(ms);
}

return parsed;
}

if (date.trim() !== '') {
throw new ExprRuntimeError(ctx.expr, ctx.path, `Unable to parse date "${date}": Unknown format`);
}

return null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "Should format date when only given a year",
"expression": ["formatDate", "2023"],
"expects": "1/1/23",
"profileSettings": {
"language": "en"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "Should format date in english",
"expression": ["formatDate", "2023-10-26T13:12:38.069Z"],
"expects": "10/26/23",
"profileSettings": {
"language": "en"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "Should format date in format specified (2)",
"expression": ["formatDate", "2023-10-26T13:12:38.069Z", "dd.MM"],
"expects": "26.10"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "Should format date-time from backend",
"expression": ["formatDate", "2025-01-23T10:25:33.9729397+01:00"],
"expects": "23.01.2025"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "Should format date when only a date + offset is given",
"expression": ["formatDate", "2023-01-01-08:00", "yyyy-MM-dd HH:mm:ss"],
"expectsFailure": "Unable to parse date \"2023-01-01-08:00\": Unknown format"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "Should format date when only given a year",
"expression": ["formatDate", "2023"],
"expects": "01.01.2023"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"comment": "This tests our compliance to the date format from the JSON Schema Test Suite.",
"source": "https://github.com/json-schema-org/JSON-Schema-Test-Suite/blob/main/tests/draft2020-12/optional/format/date.json",
"name": "Compliance: 2020 is a leap year",
"expression": [
"formatDate",
"2020-02-29",
"G yyyy-MM-dd EEEE HH:mm:ss.SSS a"
],
"expects": "e.Kr. 2020-02-29 lørdag 00:00:00.000 a.m."
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"comment": "This tests our compliance to the date format from the JSON Schema Test Suite.",
"source": "https://github.com/json-schema-org/JSON-Schema-Test-Suite/blob/main/tests/draft2020-12/optional/format/date.json",
"name": "Compliance: 2021 is not a leap year",
"expression": [
"formatDate",
"2021-02-29",
"G yyyy-MM-dd EEEE HH:mm:ss.SSS a"
],
"expectsFailure": "Unable to parse date \"2021-02-29\": Format was recognized, but the date/time is invalid"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"comment": "This tests our compliance to the date format from the JSON Schema Test Suite.",
"source": "https://github.com/json-schema-org/JSON-Schema-Test-Suite/blob/main/tests/draft2020-12/optional/format/date.json",
"name": "Compliance: a invalid date string with 29 days in February (normal)",
"expression": [
"formatDate",
"2021-02-29",
"G yyyy-MM-dd EEEE HH:mm:ss.SSS a"
],
"expectsFailure": "Unable to parse date \"2021-02-29\": Format was recognized, but the date/time is invalid"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"comment": "This tests our compliance to the date format from the JSON Schema Test Suite.",
"source": "https://github.com/json-schema-org/JSON-Schema-Test-Suite/blob/main/tests/draft2020-12/optional/format/date.json",
"name": "Compliance: a invalid date string with 30 days in February (leap)",
"expression": [
"formatDate",
"2020-02-30",
"G yyyy-MM-dd EEEE HH:mm:ss.SSS a"
],
"expectsFailure": "Unable to parse date \"2020-02-30\": Format was recognized, but the date/time is invalid"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"comment": "This tests our compliance to the date format from the JSON Schema Test Suite.",
"source": "https://github.com/json-schema-org/JSON-Schema-Test-Suite/blob/main/tests/draft2020-12/optional/format/date.json",
"name": "Compliance: a invalid date string with 31 days in April",
"expression": [
"formatDate",
"2020-04-31",
"G yyyy-MM-dd EEEE HH:mm:ss.SSS a"
],
"expectsFailure": "Unable to parse date \"2020-04-31\": Format was recognized, but the date/time is invalid"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"comment": "This tests our compliance to the date format from the JSON Schema Test Suite.",
"source": "https://github.com/json-schema-org/JSON-Schema-Test-Suite/blob/main/tests/draft2020-12/optional/format/date.json",
"name": "Compliance: a invalid date string with 31 days in June",
"expression": [
"formatDate",
"2020-06-31",
"G yyyy-MM-dd EEEE HH:mm:ss.SSS a"
],
"expectsFailure": "Unable to parse date \"2020-06-31\": Format was recognized, but the date/time is invalid"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"comment": "This tests our compliance to the date format from the JSON Schema Test Suite.",
"source": "https://github.com/json-schema-org/JSON-Schema-Test-Suite/blob/main/tests/draft2020-12/optional/format/date.json",
"name": "Compliance: a invalid date string with 31 days in November",
"expression": [
"formatDate",
"2020-11-31",
"G yyyy-MM-dd EEEE HH:mm:ss.SSS a"
],
"expectsFailure": "Unable to parse date \"2020-11-31\": Format was recognized, but the date/time is invalid"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"comment": "This tests our compliance to the date format from the JSON Schema Test Suite.",
"source": "https://github.com/json-schema-org/JSON-Schema-Test-Suite/blob/main/tests/draft2020-12/optional/format/date.json",
"name": "Compliance: a invalid date string with 31 days in September",
"expression": [
"formatDate",
"2020-09-31",
"G yyyy-MM-dd EEEE HH:mm:ss.SSS a"
],
"expectsFailure": "Unable to parse date \"2020-09-31\": Format was recognized, but the date/time is invalid"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"comment": "This tests our compliance to the date format from the JSON Schema Test Suite.",
"source": "https://github.com/json-schema-org/JSON-Schema-Test-Suite/blob/main/tests/draft2020-12/optional/format/date.json",
"name": "Compliance: a invalid date string with 32 days in August",
"expression": [
"formatDate",
"2020-08-32",
"G yyyy-MM-dd EEEE HH:mm:ss.SSS a"
],
"expectsFailure": "Unable to parse date \"2020-08-32\": Format was recognized, but the date/time is invalid"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"comment": "This tests our compliance to the date format from the JSON Schema Test Suite.",
"source": "https://github.com/json-schema-org/JSON-Schema-Test-Suite/blob/main/tests/draft2020-12/optional/format/date.json",
"name": "Compliance: a invalid date string with 32 days in December",
"expression": [
"formatDate",
"2020-12-32",
"G yyyy-MM-dd EEEE HH:mm:ss.SSS a"
],
"expectsFailure": "Unable to parse date \"2020-12-32\": Format was recognized, but the date/time is invalid"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"comment": "This tests our compliance to the date format from the JSON Schema Test Suite.",
"source": "https://github.com/json-schema-org/JSON-Schema-Test-Suite/blob/main/tests/draft2020-12/optional/format/date.json",
"name": "Compliance: a invalid date string with 32 days in January",
"expression": [
"formatDate",
"2020-01-32",
"G yyyy-MM-dd EEEE HH:mm:ss.SSS a"
],
"expectsFailure": "Unable to parse date \"2020-01-32\": Format was recognized, but the date/time is invalid"
}
Loading

0 comments on commit 9412d83

Please sign in to comment.