@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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,119 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { describeFilter } from "./describeFilter.js";
3
+ import { getColumnsForField, getDynamicValuesForField, getOperatorArity, getOperatorsForField, supportsColumnValue, } from "./operators.js";
4
+ import { changeOperator, isCompleteExpression, needsFilterString } from "./tree.js";
5
+ const fields = [
6
+ { name: "Date", caption: "Dato", type: "date" },
7
+ { name: "DeliveryDate", caption: "Leveringsdato", type: "datetime" },
8
+ { name: "Amount", caption: "Beløp", type: "number" },
9
+ { name: "Quantity", type: "number" },
10
+ { name: "Hidden", type: "number", excludeFromFilter: true },
11
+ { name: "Name", type: "string" },
12
+ { name: "Active", type: "boolean" },
13
+ { name: "Archived", type: "boolean" },
14
+ ];
15
+ function expression(partial) {
16
+ return {
17
+ type: "expression",
18
+ column: "DeliveryDate",
19
+ operator: "greaterthan",
20
+ value: "",
21
+ valueType: "datetime",
22
+ ...partial,
23
+ };
24
+ }
25
+ function group(...items) {
26
+ return { type: "group", mode: "and", items };
27
+ }
28
+ describe("getColumnsForField", () => {
29
+ test("offers the other fields of the same type, dates and datetimes together", () => {
30
+ expect(getColumnsForField(fields[1], fields).map((field) => field.name)).toEqual(["Date"]);
31
+ expect(getColumnsForField(fields[2], fields).map((field) => field.name)).toEqual(["Quantity"]);
32
+ });
33
+ test("offers nothing for booleans", () => {
34
+ expect(getColumnsForField(fields[6], fields)).toEqual([]);
35
+ });
36
+ });
37
+ describe("supportsColumnValue", () => {
38
+ test("allows comparisons and ranges, not text checks or lists", () => {
39
+ expect(supportsColumnValue("greaterthan")).toBe(true);
40
+ expect(supportsColumnValue("between")).toBe(true);
41
+ expect(supportsColumnValue("contains")).toBe(false);
42
+ expect(supportsColumnValue("inlist")).toBe(false);
43
+ });
44
+ });
45
+ describe("getDynamicValuesForField", () => {
46
+ test("includes the dates only the Windows editor had", () => {
47
+ expect(getDynamicValuesForField({ type: "date" })).toEqual(expect.arrayContaining(["Now", "DayAfterTomorrow", "TwoWeeksAway"]));
48
+ });
49
+ });
50
+ describe("needsFilterString", () => {
51
+ test("is false for values the framework reads", () => {
52
+ expect(needsFilterString(group(expression({ value: "Today", valueType: "special" })))).toBe(false);
53
+ expect(needsFilterString(group(expression({ operator: "between", value: ["2026-01-01", "2026-02-01"] })))).toBe(false);
54
+ });
55
+ test("is true for a column value", () => {
56
+ expect(needsFilterString(group(expression({ value: "Date", valueType: "column" })))).toBe(true);
57
+ });
58
+ test("is true for a date only the Windows editor had", () => {
59
+ expect(needsFilterString(group(expression({ value: "TwoWeeksAway", valueType: "special" })))).toBe(true);
60
+ });
61
+ test("is true for a range with an end that is not a plain value", () => {
62
+ let range = expression({
63
+ operator: "between",
64
+ value: [{ valueType: "special", value: "Tomorrow" }, "2026-02-01"],
65
+ });
66
+ expect(needsFilterString({ type: "group", mode: "or", items: [group(range)] })).toBe(true);
67
+ });
68
+ });
69
+ describe("isCompleteExpression", () => {
70
+ test("needs a value at both ends of a range", () => {
71
+ let complete = expression({ operator: "between", value: [{ valueType: "column", value: "Date" }, "2026-02-01"] });
72
+ let missing = expression({ operator: "between", value: [{ valueType: "column", value: "" }, "2026-02-01"] });
73
+ expect(isCompleteExpression(complete)).toBe(true);
74
+ expect(isCompleteExpression(missing)).toBe(false);
75
+ });
76
+ });
77
+ describe("changeOperator", () => {
78
+ test("keeps a column across comparisons", () => {
79
+ let changed = changeOperator(expression({ value: "Date", valueType: "column" }), "lessthan", fields[1]);
80
+ expect(changed).toMatchObject({ value: "Date", valueType: "column" });
81
+ });
82
+ test("drops a column for an operator that cannot use one", () => {
83
+ let changed = changeOperator(expression({ column: "Name", value: "Other", valueType: "column" }), "contains", fields[5]);
84
+ expect(changed).toMatchObject({ value: "", valueType: "string" });
85
+ });
86
+ });
87
+ describe("describeFilter", () => {
88
+ test("names the compared column by its caption", () => {
89
+ expect(describeFilter(group(expression({ value: "Date", valueType: "column" })), { fields })).toBe("[Leveringsdato] After [Dato]");
90
+ });
91
+ test("describes each end of a range", () => {
92
+ let range = expression({
93
+ operator: "between",
94
+ value: [
95
+ { valueType: "special", value: "Tomorrow" },
96
+ { valueType: "special", value: "TwoWeeksAway" },
97
+ ],
98
+ });
99
+ expect(describeFilter(group(range), { fields })).toBe("[Leveringsdato] Between Tomorrow - Two weeks away");
100
+ });
101
+ });
102
+ describe("period operators", () => {
103
+ test("are offered for dates and take no value", () => {
104
+ let operators = getOperatorsForField({ type: "datetime" });
105
+ expect(operators).toContain("islastweek");
106
+ expect(operators).toContain("ispriorthisyear");
107
+ expect(getOperatorsForField({ type: "number" })).not.toContain("islastweek");
108
+ expect(getOperatorArity("islastweek")).toBe("none");
109
+ });
110
+ test("need a filter string", () => {
111
+ let period = expression({ operator: "islastweek", value: null, valueType: "null" });
112
+ expect(isCompleteExpression(period)).toBe(true);
113
+ expect(needsFilterString(group(period))).toBe(true);
114
+ });
115
+ test("are described without a value", () => {
116
+ let period = expression({ operator: "islastweek", value: null, valueType: "null" });
117
+ expect(describeFilter(group(period), { fields })).toBe("[Leveringsdato] Is last week");
118
+ });
119
+ });
@@ -1,5 +1,5 @@
1
1
  import { getDynamicValueLabel, getOperatorArity, getOperatorLabel, normalizeOperator } from "./operators.js";
2
- import { isExpression } from "./tree.js";
2
+ import { isExpression, isRangeEnd } from "./tree.js";
3
3
  /**
4
4
  * Builds a human-readable description of a filter, without contacting the
5
5
  * server. Use this for read-only summaries; the authoritative filter string is
@@ -34,20 +34,22 @@ function describeExpression(expression, fields) {
34
34
  let arity = getOperatorArity(operator);
35
35
  if (arity === "none")
36
36
  return `[${caption}] ${label}`;
37
- return `[${caption}] ${label} ${formatValue(expression, arity)}`;
37
+ return `[${caption}] ${label} ${formatValue(expression, arity, fields)}`;
38
38
  }
39
- function formatValue(expression, arity) {
39
+ function formatValue(expression, arity, fields) {
40
40
  let { value, valueType } = expression;
41
- if (valueType === "special" && typeof value === "string") {
42
- return getDynamicValueLabel(value);
43
- }
44
41
  if (Array.isArray(value)) {
45
- let formatted = value.map((item) => formatScalar(item, valueType));
42
+ let formatted = value.map((item) => isRangeEnd(item) ? formatScalar(item.value, item.valueType, fields) : formatScalar(item, valueType, fields));
46
43
  return arity === "pair" ? formatted.join(" - ") : `(${formatted.join(", ")})`;
47
44
  }
48
- return formatScalar(value, valueType);
45
+ return formatScalar(value, valueType, fields);
49
46
  }
50
- function formatScalar(value, valueType) {
47
+ function formatScalar(value, valueType, fields) {
48
+ if (valueType === "special" && typeof value === "string")
49
+ return getDynamicValueLabel(value);
50
+ if (valueType === "column" && typeof value === "string") {
51
+ return `[${fields.get(value.toLowerCase())?.caption ?? value}]`;
52
+ }
51
53
  if (value === null || value === undefined)
52
54
  return "";
53
55
  if (value instanceof Date)
@@ -1,8 +1,8 @@
1
1
  export { type ConvertRequest, convertFilter, type FilterConversionResult } from "./convertFilter.js";
2
2
  export { describeFilter, type DescribeFilterOptions } from "./describeFilter.js";
3
3
  export { toFieldMetadata, toFieldMetadataList, toFilterFieldType, type ViewFieldRecord } from "./fields.js";
4
- export { getDefaultOperator, getDynamicValueLabel, getDynamicValuesForField, getOperatorArity, getOperatorLabel, getOperatorsForField, getValueType, normalizeOperator, type OperatorArity, RELATIVE_DATE_VALUES, } from "./operators.js";
4
+ export { getColumnsForField, getDefaultOperator, getDynamicValueLabel, getDynamicValuesForField, getOperatorArity, getOperatorLabel, getOperatorsForField, getValueType, normalizeOperator, type OperatorArity, PERIOD_OPERATORS, RELATIVE_DATE_VALUES, supportsColumnValue, } from "./operators.js";
5
5
  export { coerceToStandardLayout, isStandardLayout } from "./standardLayout.js";
6
6
  export { canDeleteSavedFilter, canEditSavedFilter, canRenameSavedFilter, canSetCrossDomain, canShareSavedFilter, findSavedFilter, NO_FILTER_ID, partitionSavedFilters, type SavedFilter, type SavedFilterRecord, toSavedFilter, toSavedFilters, } from "./savedFilters.js";
7
- export { addGroupAtPath, changeOperator, countExpressions, createEmptyFilter, createEmptyGroup, createExpression, type FilterNode, type FilterPath, getNodeAtPath, insertNodeAtPath, isCompleteExpression, isEmptyFilter, isExpression, isGroup, pruneFilter, removeNodeAtPath, setGroupMode, toFilterTree, unwrapSingleAndGroup, updateNodeAtPath, } from "./tree.js";
8
- export type { DynamicValue, FieldMetadata, FilterAccessLevel, FilterExpression, FilterFieldType, FilterGroup, FilterOperator, FilterTree, FilterValueType, } from "./types.js";
7
+ export { addGroupAtPath, changeOperator, countExpressions, createEmptyFilter, createEmptyGroup, createExpression, type FilterNode, type FilterPath, getNodeAtPath, insertNodeAtPath, isCompleteExpression, isEmptyFilter, isExpression, isGroup, isRangeEnd, needsFilterString, pruneFilter, removeNodeAtPath, setGroupMode, toFilterTree, unwrapSingleAndGroup, updateNodeAtPath, } from "./tree.js";
8
+ export type { DynamicValue, FieldMetadata, FilterAccessLevel, FilterExpression, FilterFieldType, FilterGroup, FilterOperator, FilterRangeEnd, FilterTree, FilterValueType, } from "./types.js";
@@ -1,7 +1,7 @@
1
1
  export { convertFilter } from "./convertFilter.js";
2
2
  export { describeFilter } from "./describeFilter.js";
3
3
  export { toFieldMetadata, toFieldMetadataList, toFilterFieldType } from "./fields.js";
4
- export { getDefaultOperator, getDynamicValueLabel, getDynamicValuesForField, getOperatorArity, getOperatorLabel, getOperatorsForField, getValueType, normalizeOperator, RELATIVE_DATE_VALUES, } from "./operators.js";
4
+ export { getColumnsForField, getDefaultOperator, getDynamicValueLabel, getDynamicValuesForField, getOperatorArity, getOperatorLabel, getOperatorsForField, getValueType, normalizeOperator, PERIOD_OPERATORS, RELATIVE_DATE_VALUES, supportsColumnValue, } from "./operators.js";
5
5
  export { coerceToStandardLayout, isStandardLayout } from "./standardLayout.js";
6
6
  export { canDeleteSavedFilter, canEditSavedFilter, canRenameSavedFilter, canSetCrossDomain, canShareSavedFilter, findSavedFilter, NO_FILTER_ID, partitionSavedFilters, toSavedFilter, toSavedFilters, } from "./savedFilters.js";
7
- export { addGroupAtPath, changeOperator, countExpressions, createEmptyFilter, createEmptyGroup, createExpression, getNodeAtPath, insertNodeAtPath, isCompleteExpression, isEmptyFilter, isExpression, isGroup, pruneFilter, removeNodeAtPath, setGroupMode, toFilterTree, unwrapSingleAndGroup, updateNodeAtPath, } from "./tree.js";
7
+ export { addGroupAtPath, changeOperator, countExpressions, createEmptyFilter, createEmptyGroup, createExpression, getNodeAtPath, insertNodeAtPath, isCompleteExpression, isEmptyFilter, isExpression, isGroup, isRangeEnd, needsFilterString, pruneFilter, removeNodeAtPath, setGroupMode, toFilterTree, unwrapSingleAndGroup, updateNodeAtPath, } from "./tree.js";
@@ -1,4 +1,10 @@
1
1
  import type { DynamicValue, FieldMetadata, FilterFieldType, FilterOperator, FilterValueType } from "./types.js";
2
+ /**
3
+ * Whole-period checks for dates, such as "Is last week", in the order of the
4
+ * Windows filter editor. They take no value. The framework has no operator for
5
+ * them, so a filter using one is sent as a filter string (`needsFilterString`).
6
+ */
7
+ export declare const PERIOD_OPERATORS: FilterOperator[];
2
8
  /** How many values an operator needs. */
3
9
  export type OperatorArity = "none" | "single" | "pair" | "list";
4
10
  export declare function getOperatorArity(operator: FilterOperator): OperatorArity;
@@ -30,6 +36,14 @@ export declare function getDynamicValueLabel(value: DynamicValue): string;
30
36
  * user name.
31
37
  */
32
38
  export declare function getDynamicValuesForField(field: Pick<FieldMetadata, "type">): DynamicValue[];
39
+ /** Whether an operator can take another column as its value. */
40
+ export declare function supportsColumnValue(operator: FilterOperator): boolean;
41
+ /**
42
+ * The columns a field can be compared with: the other fields of the same type,
43
+ * as in the Windows filter editor. Booleans have only true and false checks,
44
+ * so they get none.
45
+ */
46
+ export declare function getColumnsForField(field: Pick<FieldMetadata, "name" | "type">, fields: FieldMetadata[]): FieldMetadata[];
33
47
  /**
34
48
  * Normalizes an operator string coming from the server or from another filter
35
49
  * producer (`AfGrid` emits PascalCase names) to the canonical lower-case form.
@@ -5,12 +5,32 @@ import { getLocalizedString } from "../getLocalizedString.js";
5
5
  * `eOperators.ParameterLessChecks` plus the blank checks.
6
6
  */
7
7
  const PARAMETERLESS_OPERATORS = ["isnull", "isnotnull", "istrue", "isfalse", "isblank", "isnotblank"];
8
+ /**
9
+ * Whole-period checks for dates, such as "Is last week", in the order of the
10
+ * Windows filter editor. They take no value. The framework has no operator for
11
+ * them, so a filter using one is sent as a filter string (`needsFilterString`).
12
+ */
13
+ export const PERIOD_OPERATORS = [
14
+ "isbeyondthisyear",
15
+ "islaterthisyear",
16
+ "islaterthismonth",
17
+ "isnextweek",
18
+ "islaterthisweek",
19
+ "istomorrow",
20
+ "istoday",
21
+ "isyesterday",
22
+ "isearlierthisweek",
23
+ "islastweek",
24
+ "isearlierthismonth",
25
+ "isearlierthisyear",
26
+ "ispriorthisyear",
27
+ ];
8
28
  /** Operators whose value is a two element `[from, to]` array. */
9
29
  const PAIR_OPERATORS = ["between", "notbetween", "datebetween", "datenotbetween"];
10
30
  /** Operators whose value is an array of values. */
11
31
  const LIST_OPERATORS = ["inlist", "notinlist"];
12
32
  export function getOperatorArity(operator) {
13
- if (PARAMETERLESS_OPERATORS.includes(operator))
33
+ if (PARAMETERLESS_OPERATORS.includes(operator) || PERIOD_OPERATORS.includes(operator))
14
34
  return "none";
15
35
  if (PAIR_OPERATORS.includes(operator))
16
36
  return "pair";
@@ -57,6 +77,19 @@ const OPERATOR_LABELS = {
57
77
  datelessthanorequal: "Up to",
58
78
  datebetween: "Between",
59
79
  datenotbetween: "Not between",
80
+ isbeyondthisyear: "Is beyond this year",
81
+ islaterthisyear: "Is later this year",
82
+ islaterthismonth: "Is later this month",
83
+ isnextweek: "Is next week",
84
+ islaterthisweek: "Is later this week",
85
+ istomorrow: "Is tomorrow",
86
+ istoday: "Is today",
87
+ isyesterday: "Is yesterday",
88
+ isearlierthisweek: "Is earlier this week",
89
+ islastweek: "Is last week",
90
+ isearlierthismonth: "Is earlier this month",
91
+ isearlierthisyear: "Is earlier this year",
92
+ ispriorthisyear: "Is prior to this year",
60
93
  };
61
94
  /** Comparison operator labels used for date and time fields. */
62
95
  const DATE_OPERATOR_LABELS = {
@@ -93,8 +126,8 @@ const STRING_CHECKS = [
93
126
  * presents them.
94
127
  */
95
128
  const OPERATORS_BY_TYPE = {
96
- date: [...EQUALITY, ...LIST_CHECKS, ...RANGE, ...COMPARISONS],
97
- datetime: [...EQUALITY, ...LIST_CHECKS, ...RANGE, ...COMPARISONS],
129
+ date: [...EQUALITY, ...LIST_CHECKS, ...RANGE, ...COMPARISONS, ...PERIOD_OPERATORS],
130
+ datetime: [...EQUALITY, ...LIST_CHECKS, ...RANGE, ...COMPARISONS, ...PERIOD_OPERATORS],
98
131
  number: [...EQUALITY, ...LIST_CHECKS, ...RANGE, ...COMPARISONS],
99
132
  boolean: ["istrue", "isfalse"],
100
133
  string: [...EQUALITY, ...STRING_CHECKS, ...LIST_CHECKS, ...RANGE, ...COMPARISONS],
@@ -157,7 +190,10 @@ export function getValueType(field, operator) {
157
190
  export const RELATIVE_DATE_VALUES = [
158
191
  "Yesterday",
159
192
  "Today",
193
+ "Now",
160
194
  "Tomorrow",
195
+ "DayAfterTomorrow",
196
+ "TwoWeeksAway",
161
197
  "ThisWeek",
162
198
  "ThisMonth",
163
199
  "ThisYear",
@@ -172,7 +208,10 @@ const DYNAMIC_VALUE_LABELS = {
172
208
  CurrentUser: "Current user",
173
209
  Yesterday: "Yesterday",
174
210
  Today: "Today",
211
+ Now: "Now",
175
212
  Tomorrow: "Tomorrow",
213
+ DayAfterTomorrow: "Day after tomorrow",
214
+ TwoWeeksAway: "Two weeks away",
176
215
  ThisWeek: "This week",
177
216
  ThisMonth: "This month",
178
217
  ThisYear: "This year",
@@ -210,6 +249,27 @@ export function getDynamicValuesForField(field) {
210
249
  return ["CurrentUser"];
211
250
  return [];
212
251
  }
252
+ /** Operators that can compare a field with another column. */
253
+ const COLUMN_OPERATORS = [...EQUALITY, ...RANGE, ...COMPARISONS];
254
+ /** Whether an operator can take another column as its value. */
255
+ export function supportsColumnValue(operator) {
256
+ return COLUMN_OPERATORS.includes(operator);
257
+ }
258
+ /** Dates and datetimes compare with each other; other types only with their own. */
259
+ function getComparableType(type) {
260
+ return type === "datetime" ? "date" : type;
261
+ }
262
+ /**
263
+ * The columns a field can be compared with: the other fields of the same type,
264
+ * as in the Windows filter editor. Booleans have only true and false checks,
265
+ * so they get none.
266
+ */
267
+ export function getColumnsForField(field, fields) {
268
+ if (field.type === "boolean")
269
+ return [];
270
+ let type = getComparableType(field.type);
271
+ return fields.filter((other) => other.name !== field.name && !other.excludeFromFilter && getComparableType(other.type) === type);
272
+ }
213
273
  /**
214
274
  * Aliases for names `eOperators.ToString()` can produce that are not operators
215
275
  * in their own right. `Not` is the bare negation flag (i.e. "not equal to"),
@@ -1,4 +1,4 @@
1
- import type { FieldMetadata, FilterExpression, FilterGroup, FilterOperator, FilterTree } from "./types.js";
1
+ import type { FieldMetadata, FilterExpression, FilterGroup, FilterOperator, FilterRangeEnd, FilterTree } from "./types.js";
2
2
  /**
3
3
  * Path to a node in a filter tree, as a list of item indexes from the root
4
4
  * group. The root itself is the empty path.
@@ -49,6 +49,15 @@ export declare function pruneFilter(filter: FilterNode | null | undefined): Filt
49
49
  * True when an expression has everything it needs to be sent to the server.
50
50
  */
51
51
  export declare function isCompleteExpression(item: FilterNode): boolean;
52
+ /** Whether a range value end is a `FilterRangeEnd` rather than a plain value. */
53
+ export declare function isRangeEnd(value: unknown): value is FilterRangeEnd;
54
+ /**
55
+ * True when the filter holds something the framework's data API cannot read
56
+ * from a filter object: another column, a range end that is not a plain value,
57
+ * one of the dates only the Windows editor offers, or a whole-period check. Such a filter must be
58
+ * converted with `convertFilter` and sent as a filter string.
59
+ */
60
+ export declare function needsFilterString(filter: FilterNode | null | undefined): boolean;
52
61
  /**
53
62
  * Creates an expression for a field, using the field's default operator and an
54
63
  * empty value of the right shape.
package/es/filter/tree.js CHANGED
@@ -1,4 +1,4 @@
1
- import { getDefaultOperator, getOperatorArity, getValueType, normalizeOperator } from "./operators.js";
1
+ import { getDefaultOperator, getOperatorArity, getValueType, normalizeOperator, PERIOD_OPERATORS, supportsColumnValue, } from "./operators.js";
2
2
  /**
3
3
  * Creates the default empty filter: a single `and` group with no conditions.
4
4
  */
@@ -86,13 +86,46 @@ export function isCompleteExpression(item) {
86
86
  if (item.value === null || item.value === undefined || item.value === "")
87
87
  return false;
88
88
  if (arity === "pair") {
89
- return Array.isArray(item.value) && item.value.length === 2 && item.value.every((v) => v !== "" && v != null);
89
+ return (Array.isArray(item.value) &&
90
+ item.value.length === 2 &&
91
+ item.value.every((v) => {
92
+ let end = isRangeEnd(v) ? v.value : v;
93
+ return end !== "" && end != null;
94
+ }));
90
95
  }
91
96
  if (arity === "list") {
92
97
  return Array.isArray(item.value) && item.value.length > 0;
93
98
  }
94
99
  return true;
95
100
  }
101
+ /** Whether a range value end is a `FilterRangeEnd` rather than a plain value. */
102
+ export function isRangeEnd(value) {
103
+ return typeof value === "object" && value !== null && !(value instanceof Date) && "valueType" in value;
104
+ }
105
+ /** Dynamic values the framework has no `eDynamicValue` member for. */
106
+ const DYNAMIC_VALUES_WITHOUT_FRAMEWORK_SUPPORT = new Set(["Now", "DayAfterTomorrow", "TwoWeeksAway"]);
107
+ function needsConversion(valueType, value) {
108
+ if (valueType === "column")
109
+ return true;
110
+ return valueType === "special" && typeof value === "string" && DYNAMIC_VALUES_WITHOUT_FRAMEWORK_SUPPORT.has(value);
111
+ }
112
+ /**
113
+ * True when the filter holds something the framework's data API cannot read
114
+ * from a filter object: another column, a range end that is not a plain value,
115
+ * one of the dates only the Windows editor offers, or a whole-period check. Such a filter must be
116
+ * converted with `convertFilter` and sent as a filter string.
117
+ */
118
+ export function needsFilterString(filter) {
119
+ if (!filter)
120
+ return false;
121
+ if (isGroup(filter))
122
+ return filter.items.some(needsFilterString);
123
+ if (PERIOD_OPERATORS.includes(normalizeOperator(filter.operator)))
124
+ return true;
125
+ if (needsConversion(filter.valueType, filter.value))
126
+ return true;
127
+ return Array.isArray(filter.value) && filter.value.some((item) => isRangeEnd(item));
128
+ }
96
129
  /**
97
130
  * Creates an expression for a field, using the field's default operator and an
98
131
  * empty value of the right shape.
@@ -127,11 +160,16 @@ export function changeOperator(expression, operator, field) {
127
160
  let previousArity = getOperatorArity(normalizeOperator(expression.operator));
128
161
  let arity = getOperatorArity(operator);
129
162
  let value = expression.value;
130
- if (arity !== previousArity) {
163
+ // A column only makes sense for comparisons, not for text checks such as
164
+ // "contains".
165
+ let holdsColumn = expression.valueType === "column" ||
166
+ (Array.isArray(value) && value.some((item) => isRangeEnd(item) && item.valueType === "column"));
167
+ if (arity !== previousArity || (holdsColumn && !supportsColumnValue(operator))) {
131
168
  value = createEmptyValue(operator);
132
169
  }
133
- // A dynamic value stays valid across operators of the same arity.
134
- let valueType = expression.valueType === "special" && arity === previousArity ? "special" : getValueType(field, operator);
170
+ // A dynamic value or a column stays valid across operators of the same arity.
171
+ let keepsValueType = (expression.valueType === "special" || expression.valueType === "column") && value === expression.value;
172
+ let valueType = keepsValueType ? expression.valueType : getValueType(field, operator);
135
173
  return { ...expression, operator, value, valueType };
136
174
  }
137
175
  /** Returns the node at `path`, or null when the path does not exist. */
@@ -15,31 +15,52 @@ export type FilterExpression = {
15
15
  type: "expression";
16
16
  column: string;
17
17
  operator: string;
18
- value: string | string[] | Date | number | null;
18
+ value: string | string[] | Date | number | null | Array<string | FilterRangeEnd>;
19
19
  valueType: string;
20
20
  };
21
21
  /**
22
- * The value types understood by the Appframe filter element converter.
22
+ * One end of a range whose ends are not both plain values, such as a fixed
23
+ * date and another column. Plain ends may stay plain values next to it; they
24
+ * then take the expression's `valueType`.
25
+ *
26
+ * The framework's data API cannot read these, so a filter holding one is sent
27
+ * as a filter string. See `needsFilterString`.
28
+ */
29
+ export type FilterRangeEnd = {
30
+ valueType: FilterValueType;
31
+ value: string | number | null;
32
+ };
33
+ /**
34
+ * The value types understood by the Appframe filter element converter, plus
35
+ * `column`, which compares with another column. Only the `api/filter/convert`
36
+ * endpoint reads `column`, so a filter using it is sent as a filter string.
23
37
  *
24
38
  * @see `appframe/Appframe.Web.Core/Filtering/FilterElementConverter.vb`
39
+ * @see `lib/FilterConversion/FilterObjectReader.cs`
25
40
  */
26
- export type FilterValueType = "string" | "number" | "boolean" | "uniqueidentifier" | "array" | "datetime" | "special" | "null";
41
+ export type FilterValueType = "string" | "number" | "boolean" | "uniqueidentifier" | "array" | "datetime" | "special" | "column" | "null";
27
42
  /**
28
43
  * Canonical operator names. These are the lower-cased `eOperators` enum member
29
44
  * names as emitted by the server, including the two aliases the server
30
- * special-cases (`notequals` for `Not`, `notdatequals` for `NotDateEquals`).
45
+ * special-cases (`notequals` for `Not`, `notdatequals` for `NotDateEquals`),
46
+ * plus the whole-period checks (`islastweek` and so on), which only the
47
+ * `api/filter/convert` endpoint reads.
31
48
  *
32
49
  * @see `appframe/Appframe3.Common.Data/Filtering/eOperators.vb`
33
50
  */
34
- export type FilterOperator = "equals" | "notequals" | "greaterthan" | "greaterthanorequal" | "lessthan" | "lessthanorequal" | "beginswith" | "notbeginswith" | "endswith" | "notendswith" | "contains" | "notcontains" | "like" | "notlike" | "isnull" | "isnotnull" | "isblank" | "isnotblank" | "istrue" | "isfalse" | "inlist" | "notinlist" | "between" | "notbetween" | "dateequals" | "notdatequals" | "dategreaterthan" | "dategreaterthanorequal" | "datelessthan" | "datelessthanorequal" | "datebetween" | "datenotbetween";
51
+ export type FilterOperator = "equals" | "notequals" | "greaterthan" | "greaterthanorequal" | "lessthan" | "lessthanorequal" | "beginswith" | "notbeginswith" | "endswith" | "notendswith" | "contains" | "notcontains" | "like" | "notlike" | "isnull" | "isnotnull" | "isblank" | "isnotblank" | "istrue" | "isfalse" | "inlist" | "notinlist" | "between" | "notbetween" | "dateequals" | "notdatequals" | "dategreaterthan" | "dategreaterthanorequal" | "datelessthan" | "datelessthanorequal" | "datebetween" | "datenotbetween" | "isbeyondthisyear" | "islaterthisyear" | "islaterthismonth" | "isnextweek" | "islaterthisweek" | "istomorrow" | "istoday" | "isyesterday" | "isearlierthisweek" | "islastweek" | "isearlierthismonth" | "isearlierthisyear" | "ispriorthisyear";
35
52
  /**
36
53
  * Dynamic ("special") filter values. These are resolved server-side into a
37
54
  * relative date or the current user name, and are sent with
38
55
  * `valueType: "special"`.
39
56
  *
57
+ * `Now`, `DayAfterTomorrow` and `TwoWeeksAway` are offered by the Windows
58
+ * filter editor but have no `eDynamicValue` member, so a filter using them is
59
+ * sent as a filter string.
60
+ *
40
61
  * @see `appframe/Appframe3.Common.Data/Filtering/eDynamicValue.vb`
41
62
  */
42
- export type DynamicValue = "CurrentUser" | "Yesterday" | "Today" | "Tomorrow" | "ThisWeek" | "ThisMonth" | "ThisYear" | "LastWeek" | "LastMonth" | "LastYear" | "NextWeek" | "NextMonth" | "NextYear" | "UserYesterday" | "UserToday" | "UserTomorrow" | "UserThisWeek" | "UserThisMonth" | "UserThisYear" | "UserLastWeek" | "UserLastMonth" | "UserLastYear" | "UserNextWeek" | "UserNextMonth" | "UserNextYear";
63
+ export type DynamicValue = "CurrentUser" | "Yesterday" | "Today" | "Now" | "Tomorrow" | "DayAfterTomorrow" | "TwoWeeksAway" | "ThisWeek" | "ThisMonth" | "ThisYear" | "LastWeek" | "LastMonth" | "LastYear" | "NextWeek" | "NextMonth" | "NextYear" | "UserYesterday" | "UserToday" | "UserTomorrow" | "UserThisWeek" | "UserThisMonth" | "UserThisYear" | "UserLastWeek" | "UserLastMonth" | "UserLastYear" | "UserNextWeek" | "UserNextMonth" | "UserNextYear";
43
64
  /**
44
65
  * The simplified data types the filter builder groups fields into. Mirrors the
45
66
  * normalized `DataType` column on `sviw_WebSiteCMS_FilterBuilder_ViewFields`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olenbetong/appframe-core",
3
- "version": "2.15.2",
3
+ "version": "2.16.0",
4
4
  "description": "Javascript libraries to utilize the Appframe framework",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -40,9 +40,11 @@
40
40
  "mitt": "^3.0.1"
41
41
  },
42
42
  "devDependencies": {
43
- "@happy-dom/global-registrator": "20.14.5",
44
- "@types/bun": "^1.4.2",
45
- "chalk": "^6.0.1"
43
+ "@happy-dom/global-registrator": "21.0.4",
44
+ "@types/bun": "^1.4.4",
45
+ "chalk": "^6.0.1",
46
+ "typescript": "7.0.2",
47
+ "vitest": "5.0.5"
46
48
  },
47
49
  "peerDependencies": {},
48
50
  "optionalDependencies": {},