@olenbetong/appframe-core 2.15.2 → 2.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,164 @@
1
+ import type { FieldMetadata, FilterExpression, FilterGroup } from "./types.js";
2
+ import { describe, expect, test } from "vitest";
3
+
4
+ import { describeFilter } from "./describeFilter.js";
5
+ import {
6
+ getColumnsForField,
7
+ getDynamicValuesForField,
8
+ getOperatorArity,
9
+ getOperatorsForField,
10
+ supportsColumnValue,
11
+ } from "./operators.js";
12
+ import { changeOperator, isCompleteExpression, needsFilterString } from "./tree.js";
13
+
14
+ const fields: FieldMetadata[] = [
15
+ { name: "Date", caption: "Dato", type: "date" },
16
+ { name: "DeliveryDate", caption: "Leveringsdato", type: "datetime" },
17
+ { name: "Amount", caption: "Beløp", type: "number" },
18
+ { name: "Quantity", type: "number" },
19
+ { name: "Hidden", type: "number", excludeFromFilter: true },
20
+ { name: "Name", type: "string" },
21
+ { name: "Active", type: "boolean" },
22
+ { name: "Archived", type: "boolean" },
23
+ ];
24
+
25
+ function expression(partial: Partial<FilterExpression>): FilterExpression {
26
+ return {
27
+ type: "expression",
28
+ column: "DeliveryDate",
29
+ operator: "greaterthan",
30
+ value: "",
31
+ valueType: "datetime",
32
+ ...partial,
33
+ };
34
+ }
35
+
36
+ function group(...items: FilterExpression[]): FilterGroup {
37
+ return { type: "group", mode: "and", items };
38
+ }
39
+
40
+ describe("getColumnsForField", () => {
41
+ test("offers the other fields of the same type, dates and datetimes together", () => {
42
+ expect(getColumnsForField(fields[1]!, fields).map((field) => field.name)).toEqual(["Date"]);
43
+ expect(getColumnsForField(fields[2]!, fields).map((field) => field.name)).toEqual(["Quantity"]);
44
+ });
45
+
46
+ test("offers nothing for booleans", () => {
47
+ expect(getColumnsForField(fields[6]!, fields)).toEqual([]);
48
+ });
49
+ });
50
+
51
+ describe("supportsColumnValue", () => {
52
+ test("allows comparisons and ranges, not text checks or lists", () => {
53
+ expect(supportsColumnValue("greaterthan")).toBe(true);
54
+ expect(supportsColumnValue("between")).toBe(true);
55
+ expect(supportsColumnValue("contains")).toBe(false);
56
+ expect(supportsColumnValue("inlist")).toBe(false);
57
+ });
58
+ });
59
+
60
+ describe("getDynamicValuesForField", () => {
61
+ test("includes the dates only the Windows editor had", () => {
62
+ expect(getDynamicValuesForField({ type: "date" })).toEqual(
63
+ expect.arrayContaining(["Now", "DayAfterTomorrow", "TwoWeeksAway"]),
64
+ );
65
+ });
66
+ });
67
+
68
+ describe("needsFilterString", () => {
69
+ test("is false for values the framework reads", () => {
70
+ expect(needsFilterString(group(expression({ value: "Today", valueType: "special" })))).toBe(false);
71
+ expect(needsFilterString(group(expression({ operator: "between", value: ["2026-01-01", "2026-02-01"] })))).toBe(
72
+ false,
73
+ );
74
+ });
75
+
76
+ test("is true for a column value", () => {
77
+ expect(needsFilterString(group(expression({ value: "Date", valueType: "column" })))).toBe(true);
78
+ });
79
+
80
+ test("is true for a date only the Windows editor had", () => {
81
+ expect(needsFilterString(group(expression({ value: "TwoWeeksAway", valueType: "special" })))).toBe(true);
82
+ });
83
+
84
+ test("is true for a range with an end that is not a plain value", () => {
85
+ let range = expression({
86
+ operator: "between",
87
+ value: [{ valueType: "special", value: "Tomorrow" }, "2026-02-01"],
88
+ });
89
+
90
+ expect(needsFilterString({ type: "group", mode: "or", items: [group(range)] })).toBe(true);
91
+ });
92
+ });
93
+
94
+ describe("isCompleteExpression", () => {
95
+ test("needs a value at both ends of a range", () => {
96
+ let complete = expression({ operator: "between", value: [{ valueType: "column", value: "Date" }, "2026-02-01"] });
97
+ let missing = expression({ operator: "between", value: [{ valueType: "column", value: "" }, "2026-02-01"] });
98
+
99
+ expect(isCompleteExpression(complete)).toBe(true);
100
+ expect(isCompleteExpression(missing)).toBe(false);
101
+ });
102
+ });
103
+
104
+ describe("changeOperator", () => {
105
+ test("keeps a column across comparisons", () => {
106
+ let changed = changeOperator(expression({ value: "Date", valueType: "column" }), "lessthan", fields[1]!);
107
+
108
+ expect(changed).toMatchObject({ value: "Date", valueType: "column" });
109
+ });
110
+
111
+ test("drops a column for an operator that cannot use one", () => {
112
+ let changed = changeOperator(
113
+ expression({ column: "Name", value: "Other", valueType: "column" }),
114
+ "contains",
115
+ fields[5]!,
116
+ );
117
+
118
+ expect(changed).toMatchObject({ value: "", valueType: "string" });
119
+ });
120
+ });
121
+
122
+ describe("describeFilter", () => {
123
+ test("names the compared column by its caption", () => {
124
+ expect(describeFilter(group(expression({ value: "Date", valueType: "column" })), { fields })).toBe(
125
+ "[Leveringsdato] After [Dato]",
126
+ );
127
+ });
128
+
129
+ test("describes each end of a range", () => {
130
+ let range = expression({
131
+ operator: "between",
132
+ value: [
133
+ { valueType: "special", value: "Tomorrow" },
134
+ { valueType: "special", value: "TwoWeeksAway" },
135
+ ],
136
+ });
137
+
138
+ expect(describeFilter(group(range), { fields })).toBe("[Leveringsdato] Between Tomorrow - Two weeks away");
139
+ });
140
+ });
141
+
142
+ describe("period operators", () => {
143
+ test("are offered for dates and take no value", () => {
144
+ let operators = getOperatorsForField({ type: "datetime" });
145
+
146
+ expect(operators).toContain("islastweek");
147
+ expect(operators).toContain("ispriorthisyear");
148
+ expect(getOperatorsForField({ type: "number" })).not.toContain("islastweek");
149
+ expect(getOperatorArity("islastweek")).toBe("none");
150
+ });
151
+
152
+ test("need a filter string", () => {
153
+ let period = expression({ operator: "islastweek", value: null, valueType: "null" });
154
+
155
+ expect(isCompleteExpression(period)).toBe(true);
156
+ expect(needsFilterString(group(period))).toBe(true);
157
+ });
158
+
159
+ test("are described without a value", () => {
160
+ let period = expression({ operator: "islastweek", value: null, valueType: "null" });
161
+
162
+ expect(describeFilter(group(period), { fields })).toBe("[Leveringsdato] Is last week");
163
+ });
164
+ });
@@ -1,6 +1,6 @@
1
1
  import type { FilterExpression, FilterGroup } from "./types.js";
2
2
  import { getDynamicValueLabel, getOperatorArity, getOperatorLabel, normalizeOperator } from "./operators.js";
3
- import { isExpression } from "./tree.js";
3
+ import { isExpression, isRangeEnd } from "./tree.js";
4
4
  import type { DynamicValue, FieldMetadata } from "./types.js";
5
5
 
6
6
  export type DescribeFilterOptions = {
@@ -50,26 +50,32 @@ function describeExpression(expression: FilterExpression, fields: Map<string, Fi
50
50
 
51
51
  if (arity === "none") return `[${caption}] ${label}`;
52
52
 
53
- return `[${caption}] ${label} ${formatValue(expression, arity)}`;
53
+ return `[${caption}] ${label} ${formatValue(expression, arity, fields)}`;
54
54
  }
55
55
 
56
- function formatValue(expression: FilterExpression, arity: ReturnType<typeof getOperatorArity>): string {
56
+ function formatValue(
57
+ expression: FilterExpression,
58
+ arity: ReturnType<typeof getOperatorArity>,
59
+ fields: Map<string, FieldMetadata>,
60
+ ): string {
57
61
  let { value, valueType } = expression;
58
62
 
59
- if (valueType === "special" && typeof value === "string") {
60
- return getDynamicValueLabel(value as DynamicValue);
61
- }
62
-
63
63
  if (Array.isArray(value)) {
64
- let formatted = value.map((item) => formatScalar(item, valueType));
64
+ let formatted = value.map((item: unknown) =>
65
+ isRangeEnd(item) ? formatScalar(item.value, item.valueType, fields) : formatScalar(item, valueType, fields),
66
+ );
65
67
 
66
68
  return arity === "pair" ? formatted.join(" - ") : `(${formatted.join(", ")})`;
67
69
  }
68
70
 
69
- return formatScalar(value, valueType);
71
+ return formatScalar(value, valueType, fields);
70
72
  }
71
73
 
72
- function formatScalar(value: unknown, valueType: string): string {
74
+ function formatScalar(value: unknown, valueType: string, fields: Map<string, FieldMetadata>): string {
75
+ if (valueType === "special" && typeof value === "string") return getDynamicValueLabel(value as DynamicValue);
76
+ if (valueType === "column" && typeof value === "string") {
77
+ return `[${fields.get(value.toLowerCase())?.caption ?? value}]`;
78
+ }
73
79
  if (value === null || value === undefined) return "";
74
80
  if (value instanceof Date) return value.toISOString().slice(0, 10);
75
81
  if (typeof value === "number" || typeof value === "boolean") return String(value);
@@ -2,6 +2,7 @@ export { type ConvertRequest, convertFilter, type FilterConversionResult } from
2
2
  export { describeFilter, type DescribeFilterOptions } from "./describeFilter.js";
3
3
  export { toFieldMetadata, toFieldMetadataList, toFilterFieldType, type ViewFieldRecord } from "./fields.js";
4
4
  export {
5
+ getColumnsForField,
5
6
  getDefaultOperator,
6
7
  getDynamicValueLabel,
7
8
  getDynamicValuesForField,
@@ -11,7 +12,9 @@ export {
11
12
  getValueType,
12
13
  normalizeOperator,
13
14
  type OperatorArity,
15
+ PERIOD_OPERATORS,
14
16
  RELATIVE_DATE_VALUES,
17
+ supportsColumnValue,
15
18
  } from "./operators.js";
16
19
  export { coerceToStandardLayout, isStandardLayout } from "./standardLayout.js";
17
20
  export {
@@ -43,6 +46,8 @@ export {
43
46
  isEmptyFilter,
44
47
  isExpression,
45
48
  isGroup,
49
+ isRangeEnd,
50
+ needsFilterString,
46
51
  pruneFilter,
47
52
  removeNodeAtPath,
48
53
  setGroupMode,
@@ -58,6 +63,7 @@ export type {
58
63
  FilterFieldType,
59
64
  FilterGroup,
60
65
  FilterOperator,
66
+ FilterRangeEnd,
61
67
  FilterTree,
62
68
  FilterValueType,
63
69
  } from "./types.js";
@@ -8,6 +8,27 @@ import type { DynamicValue, FieldMetadata, FilterFieldType, FilterOperator, Filt
8
8
  */
9
9
  const PARAMETERLESS_OPERATORS: FilterOperator[] = ["isnull", "isnotnull", "istrue", "isfalse", "isblank", "isnotblank"];
10
10
 
11
+ /**
12
+ * Whole-period checks for dates, such as "Is last week", in the order of the
13
+ * Windows filter editor. They take no value. The framework has no operator for
14
+ * them, so a filter using one is sent as a filter string (`needsFilterString`).
15
+ */
16
+ export const PERIOD_OPERATORS: FilterOperator[] = [
17
+ "isbeyondthisyear",
18
+ "islaterthisyear",
19
+ "islaterthismonth",
20
+ "isnextweek",
21
+ "islaterthisweek",
22
+ "istomorrow",
23
+ "istoday",
24
+ "isyesterday",
25
+ "isearlierthisweek",
26
+ "islastweek",
27
+ "isearlierthismonth",
28
+ "isearlierthisyear",
29
+ "ispriorthisyear",
30
+ ];
31
+
11
32
  /** Operators whose value is a two element `[from, to]` array. */
12
33
  const PAIR_OPERATORS: FilterOperator[] = ["between", "notbetween", "datebetween", "datenotbetween"];
13
34
 
@@ -18,7 +39,7 @@ const LIST_OPERATORS: FilterOperator[] = ["inlist", "notinlist"];
18
39
  export type OperatorArity = "none" | "single" | "pair" | "list";
19
40
 
20
41
  export function getOperatorArity(operator: FilterOperator): OperatorArity {
21
- if (PARAMETERLESS_OPERATORS.includes(operator)) return "none";
42
+ if (PARAMETERLESS_OPERATORS.includes(operator) || PERIOD_OPERATORS.includes(operator)) return "none";
22
43
  if (PAIR_OPERATORS.includes(operator)) return "pair";
23
44
  if (LIST_OPERATORS.includes(operator)) return "list";
24
45
  return "single";
@@ -63,6 +84,19 @@ const OPERATOR_LABELS: Record<FilterOperator, string> = {
63
84
  datelessthanorequal: "Up to",
64
85
  datebetween: "Between",
65
86
  datenotbetween: "Not between",
87
+ isbeyondthisyear: "Is beyond this year",
88
+ islaterthisyear: "Is later this year",
89
+ islaterthismonth: "Is later this month",
90
+ isnextweek: "Is next week",
91
+ islaterthisweek: "Is later this week",
92
+ istomorrow: "Is tomorrow",
93
+ istoday: "Is today",
94
+ isyesterday: "Is yesterday",
95
+ isearlierthisweek: "Is earlier this week",
96
+ islastweek: "Is last week",
97
+ isearlierthismonth: "Is earlier this month",
98
+ isearlierthisyear: "Is earlier this year",
99
+ ispriorthisyear: "Is prior to this year",
66
100
  };
67
101
 
68
102
  /** Comparison operator labels used for date and time fields. */
@@ -104,8 +138,8 @@ const STRING_CHECKS: FilterOperator[] = [
104
138
  * presents them.
105
139
  */
106
140
  const OPERATORS_BY_TYPE: Record<FilterFieldType, FilterOperator[]> = {
107
- date: [...EQUALITY, ...LIST_CHECKS, ...RANGE, ...COMPARISONS],
108
- datetime: [...EQUALITY, ...LIST_CHECKS, ...RANGE, ...COMPARISONS],
141
+ date: [...EQUALITY, ...LIST_CHECKS, ...RANGE, ...COMPARISONS, ...PERIOD_OPERATORS],
142
+ datetime: [...EQUALITY, ...LIST_CHECKS, ...RANGE, ...COMPARISONS, ...PERIOD_OPERATORS],
109
143
  number: [...EQUALITY, ...LIST_CHECKS, ...RANGE, ...COMPARISONS],
110
144
  boolean: ["istrue", "isfalse"],
111
145
  string: [...EQUALITY, ...STRING_CHECKS, ...LIST_CHECKS, ...RANGE, ...COMPARISONS],
@@ -175,7 +209,10 @@ export function getValueType(field: Pick<FieldMetadata, "type">, operator: Filte
175
209
  export const RELATIVE_DATE_VALUES: DynamicValue[] = [
176
210
  "Yesterday",
177
211
  "Today",
212
+ "Now",
178
213
  "Tomorrow",
214
+ "DayAfterTomorrow",
215
+ "TwoWeeksAway",
179
216
  "ThisWeek",
180
217
  "ThisMonth",
181
218
  "ThisYear",
@@ -191,7 +228,10 @@ const DYNAMIC_VALUE_LABELS: Record<DynamicValue, string> = {
191
228
  CurrentUser: "Current user",
192
229
  Yesterday: "Yesterday",
193
230
  Today: "Today",
231
+ Now: "Now",
194
232
  Tomorrow: "Tomorrow",
233
+ DayAfterTomorrow: "Day after tomorrow",
234
+ TwoWeeksAway: "Two weeks away",
195
235
  ThisWeek: "This week",
196
236
  ThisMonth: "This month",
197
237
  ThisYear: "This year",
@@ -231,6 +271,37 @@ export function getDynamicValuesForField(field: Pick<FieldMetadata, "type">): Dy
231
271
  return [];
232
272
  }
233
273
 
274
+ /** Operators that can compare a field with another column. */
275
+ const COLUMN_OPERATORS: FilterOperator[] = [...EQUALITY, ...RANGE, ...COMPARISONS];
276
+
277
+ /** Whether an operator can take another column as its value. */
278
+ export function supportsColumnValue(operator: FilterOperator): boolean {
279
+ return COLUMN_OPERATORS.includes(operator);
280
+ }
281
+
282
+ /** Dates and datetimes compare with each other; other types only with their own. */
283
+ function getComparableType(type: FilterFieldType): FilterFieldType {
284
+ return type === "datetime" ? "date" : type;
285
+ }
286
+
287
+ /**
288
+ * The columns a field can be compared with: the other fields of the same type,
289
+ * as in the Windows filter editor. Booleans have only true and false checks,
290
+ * so they get none.
291
+ */
292
+ export function getColumnsForField(
293
+ field: Pick<FieldMetadata, "name" | "type">,
294
+ fields: FieldMetadata[],
295
+ ): FieldMetadata[] {
296
+ if (field.type === "boolean") return [];
297
+
298
+ let type = getComparableType(field.type);
299
+
300
+ return fields.filter(
301
+ (other) => other.name !== field.name && !other.excludeFromFilter && getComparableType(other.type) === type,
302
+ );
303
+ }
304
+
234
305
  /**
235
306
  * Aliases for names `eOperators.ToString()` can produce that are not operators
236
307
  * in their own right. `Not` is the bare negation flag (i.e. "not equal to"),
@@ -1,5 +1,19 @@
1
- import { getDefaultOperator, getOperatorArity, getValueType, normalizeOperator } from "./operators.js";
2
- import type { FieldMetadata, FilterExpression, FilterGroup, FilterOperator, FilterTree } from "./types.js";
1
+ import {
2
+ getDefaultOperator,
3
+ getOperatorArity,
4
+ getValueType,
5
+ normalizeOperator,
6
+ PERIOD_OPERATORS,
7
+ supportsColumnValue,
8
+ } from "./operators.js";
9
+ import type {
10
+ FieldMetadata,
11
+ FilterExpression,
12
+ FilterGroup,
13
+ FilterOperator,
14
+ FilterRangeEnd,
15
+ FilterTree,
16
+ } from "./types.js";
3
17
 
4
18
  /**
5
19
  * Path to a node in a filter tree, as a list of item indexes from the root
@@ -111,7 +125,14 @@ export function isCompleteExpression(item: FilterNode): boolean {
111
125
  if (item.value === null || item.value === undefined || item.value === "") return false;
112
126
 
113
127
  if (arity === "pair") {
114
- return Array.isArray(item.value) && item.value.length === 2 && item.value.every((v) => v !== "" && v != null);
128
+ return (
129
+ Array.isArray(item.value) &&
130
+ item.value.length === 2 &&
131
+ item.value.every((v: unknown) => {
132
+ let end = isRangeEnd(v) ? v.value : v;
133
+ return end !== "" && end != null;
134
+ })
135
+ );
115
136
  }
116
137
 
117
138
  if (arity === "list") {
@@ -121,6 +142,35 @@ export function isCompleteExpression(item: FilterNode): boolean {
121
142
  return true;
122
143
  }
123
144
 
145
+ /** Whether a range value end is a `FilterRangeEnd` rather than a plain value. */
146
+ export function isRangeEnd(value: unknown): value is FilterRangeEnd {
147
+ return typeof value === "object" && value !== null && !(value instanceof Date) && "valueType" in value;
148
+ }
149
+
150
+ /** Dynamic values the framework has no `eDynamicValue` member for. */
151
+ const DYNAMIC_VALUES_WITHOUT_FRAMEWORK_SUPPORT = new Set<string>(["Now", "DayAfterTomorrow", "TwoWeeksAway"]);
152
+
153
+ function needsConversion(valueType: string, value: unknown): boolean {
154
+ if (valueType === "column") return true;
155
+
156
+ return valueType === "special" && typeof value === "string" && DYNAMIC_VALUES_WITHOUT_FRAMEWORK_SUPPORT.has(value);
157
+ }
158
+
159
+ /**
160
+ * True when the filter holds something the framework's data API cannot read
161
+ * from a filter object: another column, a range end that is not a plain value,
162
+ * one of the dates only the Windows editor offers, or a whole-period check. Such a filter must be
163
+ * converted with `convertFilter` and sent as a filter string.
164
+ */
165
+ export function needsFilterString(filter: FilterNode | null | undefined): boolean {
166
+ if (!filter) return false;
167
+ if (isGroup(filter)) return filter.items.some(needsFilterString);
168
+ if (PERIOD_OPERATORS.includes(normalizeOperator(filter.operator))) return true;
169
+ if (needsConversion(filter.valueType, filter.value)) return true;
170
+
171
+ return Array.isArray(filter.value) && filter.value.some((item: unknown) => isRangeEnd(item));
172
+ }
173
+
124
174
  /**
125
175
  * Creates an expression for a field, using the field's default operator and an
126
176
  * empty value of the right shape.
@@ -163,13 +213,20 @@ export function changeOperator(
163
213
  let arity = getOperatorArity(operator);
164
214
  let value = expression.value;
165
215
 
166
- if (arity !== previousArity) {
216
+ // A column only makes sense for comparisons, not for text checks such as
217
+ // "contains".
218
+ let holdsColumn =
219
+ expression.valueType === "column" ||
220
+ (Array.isArray(value) && value.some((item: unknown) => isRangeEnd(item) && item.valueType === "column"));
221
+
222
+ if (arity !== previousArity || (holdsColumn && !supportsColumnValue(operator))) {
167
223
  value = createEmptyValue(operator);
168
224
  }
169
225
 
170
- // A dynamic value stays valid across operators of the same arity.
171
- let valueType =
172
- expression.valueType === "special" && arity === previousArity ? "special" : getValueType(field, operator);
226
+ // A dynamic value or a column stays valid across operators of the same arity.
227
+ let keepsValueType =
228
+ (expression.valueType === "special" || expression.valueType === "column") && value === expression.value;
229
+ let valueType = keepsValueType ? expression.valueType : getValueType(field, operator);
173
230
 
174
231
  return { ...expression, operator, value, valueType };
175
232
  }
@@ -16,14 +16,30 @@ export type FilterExpression = {
16
16
  type: "expression";
17
17
  column: string;
18
18
  operator: string;
19
- value: string | string[] | Date | number | null;
19
+ value: string | string[] | Date | number | null | Array<string | FilterRangeEnd>;
20
20
  valueType: string;
21
21
  };
22
22
 
23
23
  /**
24
- * The value types understood by the Appframe filter element converter.
24
+ * One end of a range whose ends are not both plain values, such as a fixed
25
+ * date and another column. Plain ends may stay plain values next to it; they
26
+ * then take the expression's `valueType`.
27
+ *
28
+ * The framework's data API cannot read these, so a filter holding one is sent
29
+ * as a filter string. See `needsFilterString`.
30
+ */
31
+ export type FilterRangeEnd = {
32
+ valueType: FilterValueType;
33
+ value: string | number | null;
34
+ };
35
+
36
+ /**
37
+ * The value types understood by the Appframe filter element converter, plus
38
+ * `column`, which compares with another column. Only the `api/filter/convert`
39
+ * endpoint reads `column`, so a filter using it is sent as a filter string.
25
40
  *
26
41
  * @see `appframe/Appframe.Web.Core/Filtering/FilterElementConverter.vb`
42
+ * @see `lib/FilterConversion/FilterObjectReader.cs`
27
43
  */
28
44
  export type FilterValueType =
29
45
  | "string"
@@ -33,12 +49,15 @@ export type FilterValueType =
33
49
  | "array"
34
50
  | "datetime"
35
51
  | "special"
52
+ | "column"
36
53
  | "null";
37
54
 
38
55
  /**
39
56
  * Canonical operator names. These are the lower-cased `eOperators` enum member
40
57
  * names as emitted by the server, including the two aliases the server
41
- * special-cases (`notequals` for `Not`, `notdatequals` for `NotDateEquals`).
58
+ * special-cases (`notequals` for `Not`, `notdatequals` for `NotDateEquals`),
59
+ * plus the whole-period checks (`islastweek` and so on), which only the
60
+ * `api/filter/convert` endpoint reads.
42
61
  *
43
62
  * @see `appframe/Appframe3.Common.Data/Filtering/eOperators.vb`
44
63
  */
@@ -74,20 +93,40 @@ export type FilterOperator =
74
93
  | "datelessthan"
75
94
  | "datelessthanorequal"
76
95
  | "datebetween"
77
- | "datenotbetween";
96
+ | "datenotbetween"
97
+ | "isbeyondthisyear"
98
+ | "islaterthisyear"
99
+ | "islaterthismonth"
100
+ | "isnextweek"
101
+ | "islaterthisweek"
102
+ | "istomorrow"
103
+ | "istoday"
104
+ | "isyesterday"
105
+ | "isearlierthisweek"
106
+ | "islastweek"
107
+ | "isearlierthismonth"
108
+ | "isearlierthisyear"
109
+ | "ispriorthisyear";
78
110
 
79
111
  /**
80
112
  * Dynamic ("special") filter values. These are resolved server-side into a
81
113
  * relative date or the current user name, and are sent with
82
114
  * `valueType: "special"`.
83
115
  *
116
+ * `Now`, `DayAfterTomorrow` and `TwoWeeksAway` are offered by the Windows
117
+ * filter editor but have no `eDynamicValue` member, so a filter using them is
118
+ * sent as a filter string.
119
+ *
84
120
  * @see `appframe/Appframe3.Common.Data/Filtering/eDynamicValue.vb`
85
121
  */
86
122
  export type DynamicValue =
87
123
  | "CurrentUser"
88
124
  | "Yesterday"
89
125
  | "Today"
126
+ | "Now"
90
127
  | "Tomorrow"
128
+ | "DayAfterTomorrow"
129
+ | "TwoWeeksAway"
91
130
  | "ThisWeek"
92
131
  | "ThisMonth"
93
132
  | "ThisYear"
@@ -97,6 +97,20 @@ function readSelectColumns(xml: string): ReportRecordSourceColumn[] {
97
97
  return columns;
98
98
  }
99
99
 
100
+ /**
101
+ * The record source a document holds. Parameter lookups store one `<afRecordSource>`; a report
102
+ * stores its record sources as `<ArrayOfAfReportRecordSource>` with one `<afReportRecordSource>`
103
+ * per data source, of which the one with the ID `Master` feeds the report (else the first).
104
+ */
105
+ function pickRecordSource(document: string | null | undefined): string | null {
106
+ if (!document) return null;
107
+ let sources = [...document.matchAll(/<(afRecordSource|afReportRecordSource)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/g)].map(
108
+ (match) => match[2],
109
+ );
110
+ if (sources.length === 0) return null;
111
+ return sources.find((source) => readElement(source, "ID") === "Master") ?? sources[0];
112
+ }
113
+
100
114
  /**
101
115
  * Reads the parts of an `afRecordSource` a web client can act on: the view, the
102
116
  * selected columns and the design-time filter.
@@ -104,8 +118,9 @@ function readSelectColumns(xml: string): ReportRecordSourceColumn[] {
104
118
  * Returns `null` for anything that is not a record source, so a missing or
105
119
  * empty `RecordSource` column simply means "no lookup".
106
120
  */
107
- export function parseRecordSource(xml: string | null | undefined): ReportRecordSource | null {
108
- if (!xml || !xml.includes("<afRecordSource")) return null;
121
+ export function parseRecordSource(document: string | null | undefined): ReportRecordSource | null {
122
+ let xml = pickRecordSource(document);
123
+ if (!xml) return null;
109
124
 
110
125
  let viewName = readElement(xml, "ViewName");
111
126
  if (!viewName) return null;
@@ -47,6 +47,45 @@ describe("parseRecordSource", () => {
47
47
  expect(parseRecordSource(xml)?.filterString).toBe("[Name] Like '%a & b%'");
48
48
  });
49
49
 
50
+ it("reads a report's record sources, taking the master one", () => {
51
+ let report = `<?xml version="1.0" encoding="utf-16"?>
52
+ <ArrayOfAfReportRecordSource xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
53
+ <afReportRecordSource>
54
+ <ViewName>aviw_Production_ElementsSub</ViewName>
55
+ <FilterString />
56
+ <ID>Sub</ID>
57
+ </afReportRecordSource>
58
+ <afReportRecordSource>
59
+ <IsFilterChanging>false</IsFilterChanging>
60
+ <ViewName>aviw_Production_ElementsProductionPerCutOffWithPrevious</ViewName>
61
+ <SelectColumns>
62
+ <afSelectColumn><Name>ProjectID</Name></afSelectColumn>
63
+ <afSelectColumn><Name>Area</Name><DataType>Decimal</DataType></afSelectColumn>
64
+ </SelectColumns>
65
+ <WhereClause>[Type] = 'Element'</WhereClause>
66
+ <FilterString>[ProjectID] = 'PT19-1004' AND [Period] = '2021-49'</FilterString>
67
+ <MaxRecords>0</MaxRecords>
68
+ <ID>Master</ID>
69
+ </afReportRecordSource>
70
+ </ArrayOfAfReportRecordSource>`;
71
+
72
+ expect(parseRecordSource(report)).toEqual({
73
+ viewName: "aviw_Production_ElementsProductionPerCutOffWithPrevious",
74
+ columns: [
75
+ { name: "ProjectID", type: "string" },
76
+ { name: "Area", type: "number" },
77
+ ],
78
+ whereClause: "[Type] = 'Element'",
79
+ filterString: "[ProjectID] = 'PT19-1004' AND [Period] = '2021-49'",
80
+ maxRecords: null,
81
+ });
82
+ });
83
+
84
+ it("takes the first record source when none is the master", () => {
85
+ let report = `<ArrayOfAfReportRecordSource><afReportRecordSource><ViewName>aviw_A</ViewName></afReportRecordSource><afReportRecordSource><ViewName>aviw_B</ViewName></afReportRecordSource></ArrayOfAfReportRecordSource>`;
86
+ expect(parseRecordSource(report)?.viewName).toBe("aviw_A");
87
+ });
88
+
50
89
  it("returns nothing for anything that is not a record source", () => {
51
90
  expect(parseRecordSource(null)).toBeNull();
52
91
  expect(parseRecordSource("")).toBeNull();