@olenbetong/appframe-core 2.15.2 → 2.16.0
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.
- package/dist/esm/af.common.js +147 -86
- package/dist/esm/af.common.legacy.js +143 -78
- package/dist/esm/af.common.legacy.min.js +3 -3
- package/dist/esm/af.common.legacy.min.js.map +1 -1
- package/dist/esm/af.common.min.js +3 -3
- package/dist/esm/af.common.min.js.map +1 -1
- package/dist/iife/af.common.js +151 -85
- package/dist/iife/af.common.legacy.js +147 -77
- package/dist/iife/af.common.legacy.min.js +3 -3
- package/dist/iife/af.common.legacy.min.js.map +1 -1
- package/dist/iife/af.common.min.js +3 -3
- package/dist/iife/af.common.min.js.map +1 -1
- package/es/filter/columnValues.test.d.ts +1 -0
- package/es/filter/columnValues.test.js +119 -0
- package/es/filter/describeFilter.js +11 -9
- package/es/filter/index.d.ts +3 -3
- package/es/filter/index.js +2 -2
- package/es/filter/operators.d.ts +14 -0
- package/es/filter/operators.js +63 -3
- package/es/filter/tree.d.ts +10 -1
- package/es/filter/tree.js +43 -5
- package/es/filter/types.d.ts +27 -6
- package/package.json +6 -4
- package/src/filter/columnValues.test.ts +164 -0
- package/src/filter/describeFilter.ts +16 -10
- package/src/filter/index.ts +6 -0
- package/src/filter/operators.ts +74 -3
- package/src/filter/tree.ts +64 -7
- package/src/filter/types.ts +43 -4
|
@@ -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(
|
|
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) =>
|
|
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);
|
package/src/filter/index.ts
CHANGED
|
@@ -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";
|
package/src/filter/operators.ts
CHANGED
|
@@ -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"),
|
package/src/filter/tree.ts
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
172
|
-
expression.valueType === "special"
|
|
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
|
}
|
package/src/filter/types.ts
CHANGED
|
@@ -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
|
-
*
|
|
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"
|