@olenbetong/appframe-core 2.11.10 → 2.13.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.
Files changed (95) hide show
  1. package/dist/esm/af.common.js +1988 -626
  2. package/dist/esm/af.common.legacy.js +1942 -626
  3. package/dist/esm/af.common.legacy.min.js +4 -3
  4. package/dist/esm/af.common.legacy.min.js.map +1 -7
  5. package/dist/esm/af.common.min.js +4 -3
  6. package/dist/esm/af.common.min.js.map +1 -7
  7. package/dist/iife/af.common.js +2146 -708
  8. package/dist/iife/af.common.legacy.js +2100 -708
  9. package/dist/iife/af.common.legacy.min.js +4 -3
  10. package/dist/iife/af.common.legacy.min.js.map +1 -7
  11. package/dist/iife/af.common.min.js +4 -3
  12. package/dist/iife/af.common.min.js.map +1 -7
  13. package/es/alert.js +16 -1
  14. package/es/filter/convertFilter.d.ts +23 -0
  15. package/es/filter/convertFilter.js +26 -0
  16. package/es/filter/describeFilter.d.ts +13 -0
  17. package/es/filter/describeFilter.js +60 -0
  18. package/es/filter/describeFilter.test.d.ts +1 -0
  19. package/es/filter/describeFilter.test.js +68 -0
  20. package/es/filter/fields.d.ts +28 -0
  21. package/es/filter/fields.js +37 -0
  22. package/es/filter/fields.test.d.ts +1 -0
  23. package/es/filter/fields.test.js +49 -0
  24. package/es/filter/index.d.ts +8 -0
  25. package/es/filter/index.js +7 -0
  26. package/es/filter/operators.d.ts +37 -0
  27. package/es/filter/operators.js +235 -0
  28. package/es/filter/operators.test.d.ts +1 -0
  29. package/es/filter/operators.test.js +107 -0
  30. package/es/filter/savedFilters.d.ts +111 -0
  31. package/es/filter/savedFilters.js +118 -0
  32. package/es/filter/savedFilters.test.d.ts +1 -0
  33. package/es/filter/savedFilters.test.js +108 -0
  34. package/es/filter/standardLayout.d.ts +18 -0
  35. package/es/filter/standardLayout.js +55 -0
  36. package/es/filter/standardLayout.test.d.ts +1 -0
  37. package/es/filter/standardLayout.test.js +66 -0
  38. package/es/filter/tree.d.ts +70 -0
  39. package/es/filter/tree.js +185 -0
  40. package/es/filter/tree.test.d.ts +1 -0
  41. package/es/filter/tree.test.js +184 -0
  42. package/es/filter/types.d.ts +73 -0
  43. package/es/filter/types.js +1 -0
  44. package/es/index.d.ts +2 -0
  45. package/es/index.js +4 -2
  46. package/es/parseFunc.test.js +5 -6
  47. package/es/report/index.d.ts +6 -0
  48. package/es/report/index.js +5 -0
  49. package/es/report/parameterDefaults.d.ts +26 -0
  50. package/es/report/parameterDefaults.js +77 -0
  51. package/es/report/parameters.d.ts +46 -0
  52. package/es/report/parameters.js +188 -0
  53. package/es/report/parameters.test.d.ts +1 -0
  54. package/es/report/parameters.test.js +161 -0
  55. package/es/report/recordSource.d.ts +30 -0
  56. package/es/report/recordSource.js +105 -0
  57. package/es/report/render.d.ts +81 -0
  58. package/es/report/render.js +219 -0
  59. package/es/report/render.test.d.ts +1 -0
  60. package/es/report/render.test.js +144 -0
  61. package/es/report/report.d.ts +16 -0
  62. package/es/report/report.js +50 -0
  63. package/es/report/report.test.d.ts +1 -0
  64. package/es/report/report.test.js +86 -0
  65. package/es/report/types.d.ts +124 -0
  66. package/es/report/types.js +8 -0
  67. package/package.json +7 -7
  68. package/src/alert.ts +16 -1
  69. package/src/filter/convertFilter.ts +47 -0
  70. package/src/filter/describeFilter.test.ts +103 -0
  71. package/src/filter/describeFilter.ts +79 -0
  72. package/src/filter/fields.test.ts +58 -0
  73. package/src/filter/fields.ts +56 -0
  74. package/src/filter/index.ts +62 -0
  75. package/src/filter/operators.test.ts +137 -0
  76. package/src/filter/operators.ts +258 -0
  77. package/src/filter/savedFilters.test.ts +145 -0
  78. package/src/filter/savedFilters.ts +192 -0
  79. package/src/filter/standardLayout.test.ts +88 -0
  80. package/src/filter/standardLayout.ts +65 -0
  81. package/src/filter/tree.test.ts +258 -0
  82. package/src/filter/tree.ts +238 -0
  83. package/src/filter/types.ts +146 -0
  84. package/src/index.ts +3 -1
  85. package/src/parseFunc.test.ts +5 -6
  86. package/src/report/index.ts +37 -0
  87. package/src/report/parameterDefaults.ts +101 -0
  88. package/src/report/parameters.test.ts +218 -0
  89. package/src/report/parameters.ts +230 -0
  90. package/src/report/recordSource.ts +123 -0
  91. package/src/report/render.test.ts +230 -0
  92. package/src/report/render.ts +269 -0
  93. package/src/report/report.test.ts +104 -0
  94. package/src/report/report.ts +51 -0
  95. package/src/report/types.ts +135 -0
@@ -0,0 +1,60 @@
1
+ import { getDynamicValueLabel, getOperatorArity, getOperatorLabel, normalizeOperator } from "./operators.js";
2
+ import { isExpression } from "./tree.js";
3
+ /**
4
+ * Builds a human-readable description of a filter, without contacting the
5
+ * server. Use this for read-only summaries; the authoritative filter string is
6
+ * produced by `api/filter/convert`, since only the server can generate the
7
+ * DevExpress criteria syntax.
8
+ */
9
+ export function describeFilter(filter, options = {}) {
10
+ if (!filter)
11
+ return "";
12
+ let fieldsByName = new Map((options.fields ?? []).map((field) => [field.name.toLowerCase(), field]));
13
+ return describe(filter, fieldsByName, 0);
14
+ }
15
+ function describe(item, fields, depth) {
16
+ if (isExpression(item))
17
+ return describeExpression(item, fields);
18
+ let parts = item.items.map((child) => describe(child, fields, depth + 1)).filter((part) => part !== "");
19
+ if (parts.length === 0)
20
+ return "";
21
+ if (parts.length === 1)
22
+ return parts[0];
23
+ let separator = item.mode === "or" ? " OR " : " AND ";
24
+ let joined = parts.join(separator);
25
+ return depth === 0 ? joined : `(${joined})`;
26
+ }
27
+ function describeExpression(expression, fields) {
28
+ if (!expression.column || !expression.operator)
29
+ return "";
30
+ let field = fields.get(expression.column.toLowerCase());
31
+ let caption = field?.caption ?? expression.column;
32
+ let operator = normalizeOperator(expression.operator);
33
+ let label = getOperatorLabel(operator, field?.type);
34
+ let arity = getOperatorArity(operator);
35
+ if (arity === "none")
36
+ return `[${caption}] ${label}`;
37
+ return `[${caption}] ${label} ${formatValue(expression, arity)}`;
38
+ }
39
+ function formatValue(expression, arity) {
40
+ let { value, valueType } = expression;
41
+ if (valueType === "special" && typeof value === "string") {
42
+ return getDynamicValueLabel(value);
43
+ }
44
+ if (Array.isArray(value)) {
45
+ let formatted = value.map((item) => formatScalar(item, valueType));
46
+ return arity === "pair" ? formatted.join(" - ") : `(${formatted.join(", ")})`;
47
+ }
48
+ return formatScalar(value, valueType);
49
+ }
50
+ function formatScalar(value, valueType) {
51
+ if (value === null || value === undefined)
52
+ return "";
53
+ if (value instanceof Date)
54
+ return value.toISOString().slice(0, 10);
55
+ if (typeof value === "number" || typeof value === "boolean")
56
+ return String(value);
57
+ if (valueType === "number" || valueType === "boolean")
58
+ return String(value);
59
+ return `'${String(value)}'`;
60
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,68 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { describeFilter } from "./describeFilter.js";
3
+ import { createEmptyFilter } from "./tree.js";
4
+ const fields = [
5
+ { name: "PersonID", caption: "Person ID", type: "string" },
6
+ { name: "Created", caption: "Created", type: "date" },
7
+ { name: "Amount", caption: "Amount", type: "number" },
8
+ ];
9
+ function expression(column, operator, value, valueType = "string") {
10
+ return { type: "expression", column, operator, value, valueType };
11
+ }
12
+ describe("describeFilter", () => {
13
+ test("empty filters describe as an empty string", () => {
14
+ expect(describeFilter(null)).toBe("");
15
+ expect(describeFilter(createEmptyFilter())).toBe("");
16
+ });
17
+ test("uses the field caption when available", () => {
18
+ let filter = expression("PersonID", "contains", "silv");
19
+ expect(describeFilter(filter, { fields })).toBe("[Person ID] Contains 'silv'");
20
+ });
21
+ test("falls back to the column name when the field is unknown", () => {
22
+ expect(describeFilter(expression("Unknown", "equals", "x"), { fields })).toBe("[Unknown] Equal to 'x'");
23
+ });
24
+ test("parameterless operators render without a value", () => {
25
+ expect(describeFilter(expression("PersonID", "isnull", null, "null"), { fields })).toBe("[Person ID] Is null");
26
+ });
27
+ test("numbers are not quoted", () => {
28
+ expect(describeFilter(expression("Amount", "greaterthan", 100, "number"), { fields })).toBe("[Amount] Greater than 100");
29
+ });
30
+ test("date fields use the relative operator wording", () => {
31
+ expect(describeFilter(expression("Created", "greaterthan", "2026-01-01", "datetime"), { fields })).toBe("[Created] After '2026-01-01'");
32
+ });
33
+ test("dynamic values render as their label", () => {
34
+ expect(describeFilter(expression("Created", "equals", "ThisMonth", "special"), { fields })).toBe("[Created] Equal to This month");
35
+ });
36
+ test("between renders a range", () => {
37
+ expect(describeFilter(expression("Amount", "between", ["1", "9"], "number"), { fields })).toBe("[Amount] Between 1 - 9");
38
+ });
39
+ test("in list renders a parenthesised list", () => {
40
+ expect(describeFilter(expression("PersonID", "inlist", ["a", "b"]), { fields })).toBe("[Person ID] In list ('a', 'b')");
41
+ });
42
+ test("groups are joined by their mode and nested groups are parenthesised", () => {
43
+ let filter = {
44
+ type: "group",
45
+ mode: "or",
46
+ items: [
47
+ {
48
+ type: "group",
49
+ mode: "and",
50
+ items: [expression("PersonID", "equals", "a"), expression("Amount", "greaterthan", 5, "number")],
51
+ },
52
+ { type: "group", mode: "and", items: [expression("PersonID", "equals", "b")] },
53
+ ],
54
+ };
55
+ expect(describeFilter(filter, { fields })).toBe("([Person ID] Equal to 'a' AND [Amount] Greater than 5) OR [Person ID] Equal to 'b'");
56
+ });
57
+ test("empty groups are omitted", () => {
58
+ let filter = {
59
+ type: "group",
60
+ mode: "or",
61
+ items: [
62
+ { type: "group", mode: "and", items: [expression("PersonID", "equals", "a")] },
63
+ { type: "group", mode: "and", items: [] },
64
+ ],
65
+ };
66
+ expect(describeFilter(filter, { fields })).toBe("[Person ID] Equal to 'a'");
67
+ });
68
+ });
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Field metadata for the filter builder.
3
+ *
4
+ * `sviw_WebSiteCMS_FilterBuilder_ViewFields` normalizes the SQL data types into
5
+ * the handful of types the builder groups fields by, so the mapping here is
6
+ * mostly a rename. The loading lives in `useFilterFields` in
7
+ * `@olenbetong/appframe-react`.
8
+ */
9
+ import type { FieldMetadata, FilterFieldType } from "./types.js";
10
+ /** A raw `sviw_WebSiteCMS_FilterBuilder_ViewFields` record. */
11
+ export type ViewFieldRecord = {
12
+ FieldName?: string | null;
13
+ /** Already normalized by the view; anything unexpected is treated as text. */
14
+ DataType?: string | null;
15
+ DBObjectID?: string | null;
16
+ Nullable?: boolean | number | null;
17
+ ExcludeFromFilter?: boolean | number | null;
18
+ ValueListRecordSource?: string | null;
19
+ };
20
+ /** The builder type for a normalized `DataType`. Unknown types are filtered as text. */
21
+ export declare function toFilterFieldType(dataType: string | null | undefined): FilterFieldType;
22
+ /** Maps a view field record onto the metadata the builder renders. */
23
+ export declare function toFieldMetadata(record: ViewFieldRecord): FieldMetadata;
24
+ /**
25
+ * Maps and sorts view field records. Fields the view marks as excluded are
26
+ * dropped rather than hidden, since nothing else can use them.
27
+ */
28
+ export declare function toFieldMetadataList(records: ViewFieldRecord[]): FieldMetadata[];
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Field metadata for the filter builder.
3
+ *
4
+ * `sviw_WebSiteCMS_FilterBuilder_ViewFields` normalizes the SQL data types into
5
+ * the handful of types the builder groups fields by, so the mapping here is
6
+ * mostly a rename. The loading lives in `useFilterFields` in
7
+ * `@olenbetong/appframe-react`.
8
+ */
9
+ const FIELD_TYPES = ["string", "number", "boolean", "date", "datetime", "uniqueidentifier"];
10
+ /** The builder type for a normalized `DataType`. Unknown types are filtered as text. */
11
+ export function toFilterFieldType(dataType) {
12
+ let normalized = dataType?.trim().toLowerCase();
13
+ return FIELD_TYPES.find((type) => type === normalized) ?? "string";
14
+ }
15
+ function toBoolean(value) {
16
+ return value === true || value === 1;
17
+ }
18
+ /** Maps a view field record onto the metadata the builder renders. */
19
+ export function toFieldMetadata(record) {
20
+ return {
21
+ name: record.FieldName ?? "",
22
+ type: toFilterFieldType(record.DataType),
23
+ nullable: toBoolean(record.Nullable),
24
+ excludeFromFilter: toBoolean(record.ExcludeFromFilter),
25
+ valueListRecordSource: record.ValueListRecordSource || undefined,
26
+ };
27
+ }
28
+ /**
29
+ * Maps and sorts view field records. Fields the view marks as excluded are
30
+ * dropped rather than hidden, since nothing else can use them.
31
+ */
32
+ export function toFieldMetadataList(records) {
33
+ return records
34
+ .map(toFieldMetadata)
35
+ .filter((field) => field.name !== "" && !field.excludeFromFilter)
36
+ .sort((a, b) => a.name.localeCompare(b.name));
37
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,49 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { toFieldMetadata, toFieldMetadataList, toFilterFieldType } from "./fields.js";
3
+ describe("toFilterFieldType", () => {
4
+ test("passes through the types the view normalizes to", () => {
5
+ expect(toFilterFieldType("boolean")).toBe("boolean");
6
+ expect(toFilterFieldType("datetime")).toBe("datetime");
7
+ expect(toFilterFieldType("uniqueidentifier")).toBe("uniqueidentifier");
8
+ });
9
+ test("an unknown or missing type is filtered as text", () => {
10
+ expect(toFilterFieldType("geography")).toBe("string");
11
+ expect(toFilterFieldType(null)).toBe("string");
12
+ });
13
+ });
14
+ describe("toFieldMetadata", () => {
15
+ test("maps a record", () => {
16
+ let field = toFieldMetadata({
17
+ FieldName: "PersonID",
18
+ DataType: "string",
19
+ Nullable: true,
20
+ ExcludeFromFilter: false,
21
+ ValueListRecordSource: "<afRecordSource />",
22
+ });
23
+ expect(field).toEqual({
24
+ name: "PersonID",
25
+ type: "string",
26
+ nullable: true,
27
+ excludeFromFilter: false,
28
+ valueListRecordSource: "<afRecordSource />",
29
+ });
30
+ });
31
+ test("bit columns arrive as numbers", () => {
32
+ expect(toFieldMetadata({ FieldName: "A", Nullable: 1 }).nullable).toBe(true);
33
+ expect(toFieldMetadata({ FieldName: "A", Nullable: 0 }).nullable).toBe(false);
34
+ });
35
+ test("an empty record source is dropped rather than kept as an empty string", () => {
36
+ expect(toFieldMetadata({ FieldName: "A", ValueListRecordSource: "" }).valueListRecordSource).toBeUndefined();
37
+ });
38
+ });
39
+ describe("toFieldMetadataList", () => {
40
+ test("sorts by name and drops excluded and unnamed fields", () => {
41
+ let fields = toFieldMetadataList([
42
+ { FieldName: "Name", DataType: "string" },
43
+ { FieldName: "Age", DataType: "number" },
44
+ { FieldName: "CDL", DataType: "boolean", ExcludeFromFilter: true },
45
+ { FieldName: "", DataType: "string" },
46
+ ]);
47
+ expect(fields.map((field) => field.name)).toEqual(["Age", "Name"]);
48
+ });
49
+ });
@@ -0,0 +1,8 @@
1
+ export { type ConvertRequest, convertFilter, type FilterConversionResult } from "./convertFilter.js";
2
+ export { describeFilter, type DescribeFilterOptions } from "./describeFilter.js";
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";
5
+ export { coerceToStandardLayout, isStandardLayout } from "./standardLayout.js";
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, updateNodeAtPath, } from "./tree.js";
8
+ export type { DynamicValue, FieldMetadata, FilterAccessLevel, FilterExpression, FilterFieldType, FilterGroup, FilterOperator, FilterTree, FilterValueType, } from "./types.js";
@@ -0,0 +1,7 @@
1
+ export { convertFilter } from "./convertFilter.js";
2
+ export { describeFilter } from "./describeFilter.js";
3
+ export { toFieldMetadata, toFieldMetadataList, toFilterFieldType } from "./fields.js";
4
+ export { getDefaultOperator, getDynamicValueLabel, getDynamicValuesForField, getOperatorArity, getOperatorLabel, getOperatorsForField, getValueType, normalizeOperator, RELATIVE_DATE_VALUES, } from "./operators.js";
5
+ export { coerceToStandardLayout, isStandardLayout } from "./standardLayout.js";
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, updateNodeAtPath, } from "./tree.js";
@@ -0,0 +1,37 @@
1
+ import type { DynamicValue, FieldMetadata, FilterFieldType, FilterOperator, FilterValueType } from "./types.js";
2
+ /** How many values an operator needs. */
3
+ export type OperatorArity = "none" | "single" | "pair" | "list";
4
+ export declare function getOperatorArity(operator: FilterOperator): OperatorArity;
5
+ /**
6
+ * Returns the localized label for an operator. Date fields get the
7
+ * "After"/"Before" wording instead of "Greater than"/"Less than".
8
+ */
9
+ export declare function getOperatorLabel(operator: FilterOperator, fieldType?: FilterFieldType): string;
10
+ /**
11
+ * Returns the operators available for a field. Nullable fields additionally get
12
+ * the null checks. Boolean fields never get null checks, since `istrue` and
13
+ * `isfalse` already cover the parameterless cases.
14
+ */
15
+ export declare function getOperatorsForField(field: Pick<FieldMetadata, "type" | "nullable">): FilterOperator[];
16
+ export declare function getDefaultOperator(field: Pick<FieldMetadata, "type">): FilterOperator;
17
+ /**
18
+ * The `valueType` to send for a field/operator combination.
19
+ *
20
+ * Parameterless operators send `"null"`, list and range operators send
21
+ * `"array"`, everything else sends the field's own value type.
22
+ */
23
+ export declare function getValueType(field: Pick<FieldMetadata, "type">, operator: FilterOperator): FilterValueType;
24
+ /** Relative date values offered in the date value editor, in menu order. */
25
+ export declare const RELATIVE_DATE_VALUES: DynamicValue[];
26
+ export declare function getDynamicValueLabel(value: DynamicValue): string;
27
+ /**
28
+ * Returns the dynamic ("special") values that can be used with a field. Date
29
+ * fields get the relative dates; string fields can be compared to the current
30
+ * user name.
31
+ */
32
+ export declare function getDynamicValuesForField(field: Pick<FieldMetadata, "type">): DynamicValue[];
33
+ /**
34
+ * Normalizes an operator string coming from the server or from another filter
35
+ * producer (`AfGrid` emits PascalCase names) to the canonical lower-case form.
36
+ */
37
+ export declare function normalizeOperator(operator: string): FilterOperator;
@@ -0,0 +1,235 @@
1
+ import { getLocalizedString } from "../getLocalizedString.js";
2
+ /**
3
+ * Operators that take no value at all.
4
+ *
5
+ * `eOperators.ParameterLessChecks` plus the blank checks.
6
+ */
7
+ const PARAMETERLESS_OPERATORS = ["isnull", "isnotnull", "istrue", "isfalse", "isblank", "isnotblank"];
8
+ /** Operators whose value is a two element `[from, to]` array. */
9
+ const PAIR_OPERATORS = ["between", "notbetween", "datebetween", "datenotbetween"];
10
+ /** Operators whose value is an array of values. */
11
+ const LIST_OPERATORS = ["inlist", "notinlist"];
12
+ export function getOperatorArity(operator) {
13
+ if (PARAMETERLESS_OPERATORS.includes(operator))
14
+ return "none";
15
+ if (PAIR_OPERATORS.includes(operator))
16
+ return "pair";
17
+ if (LIST_OPERATORS.includes(operator))
18
+ return "list";
19
+ return "single";
20
+ }
21
+ /**
22
+ * Operator labels, keyed by operator. Comparison operators have a different
23
+ * label for dates than for numbers, matching the Windows filter editor.
24
+ *
25
+ * @see `appframe/Appframe3.Win/Controls/afFilterEditor/Utils/FilterLineControlUtils.vb`
26
+ */
27
+ const OPERATOR_LABELS = {
28
+ equals: "Equal to",
29
+ notequals: "Not equal to",
30
+ greaterthan: "Greater than",
31
+ greaterthanorequal: "Greater than or equal to",
32
+ lessthan: "Less than",
33
+ lessthanorequal: "Less than or equal to",
34
+ beginswith: "Begins with",
35
+ notbeginswith: "Does not begin with",
36
+ endswith: "Ends with",
37
+ notendswith: "Does not end with",
38
+ contains: "Contains",
39
+ notcontains: "Does not contain",
40
+ like: "Like",
41
+ notlike: "Not like",
42
+ isnull: "Is null",
43
+ isnotnull: "Is not null",
44
+ isblank: "Is empty",
45
+ isnotblank: "Is not empty",
46
+ istrue: "Is true",
47
+ isfalse: "Is false",
48
+ inlist: "In list",
49
+ notinlist: "Not in list",
50
+ between: "Between",
51
+ notbetween: "Not between",
52
+ dateequals: "Equal to",
53
+ notdatequals: "Not equal to",
54
+ dategreaterthan: "After",
55
+ dategreaterthanorequal: "On or after",
56
+ datelessthan: "Before",
57
+ datelessthanorequal: "Up to",
58
+ datebetween: "Between",
59
+ datenotbetween: "Not between",
60
+ };
61
+ /** Comparison operator labels used for date and time fields. */
62
+ const DATE_OPERATOR_LABELS = {
63
+ greaterthan: "After",
64
+ greaterthanorequal: "On or after",
65
+ lessthan: "Before",
66
+ lessthanorequal: "Up to",
67
+ };
68
+ /**
69
+ * Returns the localized label for an operator. Date fields get the
70
+ * "After"/"Before" wording instead of "Greater than"/"Less than".
71
+ */
72
+ export function getOperatorLabel(operator, fieldType) {
73
+ let isDate = fieldType === "date" || fieldType === "datetime";
74
+ let label = (isDate ? DATE_OPERATOR_LABELS[operator] : undefined) ?? OPERATOR_LABELS[operator];
75
+ return getLocalizedString(label ?? operator);
76
+ }
77
+ const EQUALITY = ["equals", "notequals"];
78
+ const LIST_CHECKS = ["inlist", "notinlist"];
79
+ const RANGE = ["between", "notbetween"];
80
+ const COMPARISONS = ["greaterthan", "greaterthanorequal", "lessthan", "lessthanorequal"];
81
+ const STRING_CHECKS = [
82
+ "contains",
83
+ "notcontains",
84
+ "beginswith",
85
+ "notbeginswith",
86
+ "endswith",
87
+ "notendswith",
88
+ "isblank",
89
+ "isnotblank",
90
+ ];
91
+ /**
92
+ * Operators offered per field type, in the order the Windows filter editor
93
+ * presents them.
94
+ */
95
+ const OPERATORS_BY_TYPE = {
96
+ date: [...EQUALITY, ...LIST_CHECKS, ...RANGE, ...COMPARISONS],
97
+ datetime: [...EQUALITY, ...LIST_CHECKS, ...RANGE, ...COMPARISONS],
98
+ number: [...EQUALITY, ...LIST_CHECKS, ...RANGE, ...COMPARISONS],
99
+ boolean: ["istrue", "isfalse"],
100
+ string: [...EQUALITY, ...STRING_CHECKS, ...LIST_CHECKS, ...RANGE, ...COMPARISONS],
101
+ uniqueidentifier: [...EQUALITY, ...STRING_CHECKS, ...LIST_CHECKS, ...RANGE, ...COMPARISONS],
102
+ };
103
+ /**
104
+ * Default operator per field type.
105
+ *
106
+ * Text defaults to `contains` because a substring search is what people
107
+ * reach for when they type into a filter box without picking an operator —
108
+ * `beginswith` silently misses matches in the middle of the value.
109
+ */
110
+ const DEFAULT_OPERATOR_BY_TYPE = {
111
+ date: "equals",
112
+ datetime: "equals",
113
+ number: "equals",
114
+ boolean: "istrue",
115
+ string: "contains",
116
+ uniqueidentifier: "beginswith",
117
+ };
118
+ /**
119
+ * Returns the operators available for a field. Nullable fields additionally get
120
+ * the null checks. Boolean fields never get null checks, since `istrue` and
121
+ * `isfalse` already cover the parameterless cases.
122
+ */
123
+ export function getOperatorsForField(field) {
124
+ let operators = [...(OPERATORS_BY_TYPE[field.type] ?? OPERATORS_BY_TYPE.string)];
125
+ if (field.nullable && field.type !== "boolean") {
126
+ operators.push("isnull", "isnotnull");
127
+ }
128
+ return operators;
129
+ }
130
+ export function getDefaultOperator(field) {
131
+ return DEFAULT_OPERATOR_BY_TYPE[field.type] ?? "equals";
132
+ }
133
+ /**
134
+ * The `valueType` to send for a field/operator combination.
135
+ *
136
+ * Parameterless operators send `"null"`, list and range operators send
137
+ * `"array"`, everything else sends the field's own value type.
138
+ */
139
+ export function getValueType(field, operator) {
140
+ if (getOperatorArity(operator) === "none")
141
+ return "null";
142
+ switch (field.type) {
143
+ case "number":
144
+ return "number";
145
+ case "boolean":
146
+ return "boolean";
147
+ case "date":
148
+ case "datetime":
149
+ return "datetime";
150
+ case "uniqueidentifier":
151
+ return "uniqueidentifier";
152
+ default:
153
+ return "string";
154
+ }
155
+ }
156
+ /** Relative date values offered in the date value editor, in menu order. */
157
+ export const RELATIVE_DATE_VALUES = [
158
+ "Yesterday",
159
+ "Today",
160
+ "Tomorrow",
161
+ "ThisWeek",
162
+ "ThisMonth",
163
+ "ThisYear",
164
+ "LastWeek",
165
+ "LastMonth",
166
+ "LastYear",
167
+ "NextWeek",
168
+ "NextMonth",
169
+ "NextYear",
170
+ ];
171
+ const DYNAMIC_VALUE_LABELS = {
172
+ CurrentUser: "Current user",
173
+ Yesterday: "Yesterday",
174
+ Today: "Today",
175
+ Tomorrow: "Tomorrow",
176
+ ThisWeek: "This week",
177
+ ThisMonth: "This month",
178
+ ThisYear: "This year",
179
+ LastWeek: "Last week",
180
+ LastMonth: "Last month",
181
+ LastYear: "Last year",
182
+ NextWeek: "Next week",
183
+ NextMonth: "Next month",
184
+ NextYear: "Next year",
185
+ UserYesterday: "Yesterday",
186
+ UserToday: "Today",
187
+ UserTomorrow: "Tomorrow",
188
+ UserThisWeek: "This week",
189
+ UserThisMonth: "This month",
190
+ UserThisYear: "This year",
191
+ UserLastWeek: "Last week",
192
+ UserLastMonth: "Last month",
193
+ UserLastYear: "Last year",
194
+ UserNextWeek: "Next week",
195
+ UserNextMonth: "Next month",
196
+ UserNextYear: "Next year",
197
+ };
198
+ export function getDynamicValueLabel(value) {
199
+ return getLocalizedString(DYNAMIC_VALUE_LABELS[value] ?? value);
200
+ }
201
+ /**
202
+ * Returns the dynamic ("special") values that can be used with a field. Date
203
+ * fields get the relative dates; string fields can be compared to the current
204
+ * user name.
205
+ */
206
+ export function getDynamicValuesForField(field) {
207
+ if (field.type === "date" || field.type === "datetime")
208
+ return [...RELATIVE_DATE_VALUES];
209
+ if (field.type === "string")
210
+ return ["CurrentUser"];
211
+ return [];
212
+ }
213
+ /**
214
+ * Aliases for names `eOperators.ToString()` can produce that are not operators
215
+ * in their own right. `Not` is the bare negation flag (i.e. "not equal to"),
216
+ * `Tweens` shares its numeric value with `NotBetween`, and combined flags are
217
+ * rendered as a comma separated list.
218
+ *
219
+ * @see `FilterElementConverter.FilterExpressionToDictionary`
220
+ */
221
+ const OPERATOR_ALIASES = {
222
+ not: "notequals",
223
+ tweens: "notbetween",
224
+ notdate: "notdatequals",
225
+ // The enum member is `NotDateEquals`, but the wire format drops an "e".
226
+ notdateequals: "notdatequals",
227
+ };
228
+ /**
229
+ * Normalizes an operator string coming from the server or from another filter
230
+ * producer (`AfGrid` emits PascalCase names) to the canonical lower-case form.
231
+ */
232
+ export function normalizeOperator(operator) {
233
+ let normalized = operator.toLowerCase().replace(/[\s,]/g, "");
234
+ return OPERATOR_ALIASES[normalized] ?? normalized;
235
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,107 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { getDefaultOperator, getDynamicValuesForField, getOperatorArity, getOperatorLabel, getOperatorsForField, getValueType, normalizeOperator, } from "./operators.js";
3
+ describe("getOperatorsForField", () => {
4
+ test("boolean fields only get truth checks", () => {
5
+ expect(getOperatorsForField({ type: "boolean" })).toEqual(["istrue", "isfalse"]);
6
+ });
7
+ test("boolean fields never get null checks", () => {
8
+ expect(getOperatorsForField({ type: "boolean", nullable: true })).toEqual(["istrue", "isfalse"]);
9
+ });
10
+ test("nullable fields get null checks last", () => {
11
+ let operators = getOperatorsForField({ type: "number", nullable: true });
12
+ expect(operators.slice(-2)).toEqual(["isnull", "isnotnull"]);
13
+ });
14
+ test("string fields get the string comparison operators", () => {
15
+ let operators = getOperatorsForField({ type: "string" });
16
+ expect(operators).toContain("contains");
17
+ expect(operators).toContain("beginswith");
18
+ expect(operators).toContain("isblank");
19
+ });
20
+ test("number fields do not get string comparison operators", () => {
21
+ let operators = getOperatorsForField({ type: "number" });
22
+ expect(operators).not.toContain("contains");
23
+ expect(operators).not.toContain("beginswith");
24
+ });
25
+ test("unknown field types fall back to the string operators", () => {
26
+ let operators = getOperatorsForField({ type: "something" });
27
+ expect(operators).toEqual(getOperatorsForField({ type: "string" }));
28
+ });
29
+ });
30
+ describe("getDefaultOperator", () => {
31
+ test.each([
32
+ ["string", "contains"],
33
+ ["uniqueidentifier", "beginswith"],
34
+ ["number", "equals"],
35
+ ["date", "equals"],
36
+ ["datetime", "equals"],
37
+ ["boolean", "istrue"],
38
+ ])("%s defaults to %s", (type, expected) => {
39
+ expect(getDefaultOperator({ type })).toBe(expected);
40
+ });
41
+ });
42
+ describe("getOperatorArity", () => {
43
+ test.each([
44
+ ["isnull", "none"],
45
+ ["istrue", "none"],
46
+ ["isblank", "none"],
47
+ ["between", "pair"],
48
+ ["datenotbetween", "pair"],
49
+ ["inlist", "list"],
50
+ ["notinlist", "list"],
51
+ ["equals", "single"],
52
+ ["contains", "single"],
53
+ ])("%s has arity %s", (operator, expected) => {
54
+ expect(getOperatorArity(operator)).toBe(expected);
55
+ });
56
+ });
57
+ describe("getOperatorLabel", () => {
58
+ test("date fields get the relative wording for comparisons", () => {
59
+ expect(getOperatorLabel("greaterthan", "date")).toBe("After");
60
+ expect(getOperatorLabel("lessthanorequal", "datetime")).toBe("Up to");
61
+ });
62
+ test("number fields get the numeric wording", () => {
63
+ expect(getOperatorLabel("greaterthan", "number")).toBe("Greater than");
64
+ });
65
+ test("non-comparison operators are unaffected by the field type", () => {
66
+ expect(getOperatorLabel("inlist", "date")).toBe("In list");
67
+ expect(getOperatorLabel("inlist", "number")).toBe("In list");
68
+ });
69
+ });
70
+ describe("getValueType", () => {
71
+ test("parameterless operators send a null value type", () => {
72
+ expect(getValueType({ type: "string" }, "isnull")).toBe("null");
73
+ expect(getValueType({ type: "boolean" }, "istrue")).toBe("null");
74
+ });
75
+ test.each([
76
+ ["string", "string"],
77
+ ["number", "number"],
78
+ ["date", "datetime"],
79
+ ["datetime", "datetime"],
80
+ ["uniqueidentifier", "uniqueidentifier"],
81
+ ])("%s fields send %s", (type, expected) => {
82
+ expect(getValueType({ type }, "equals")).toBe(expected);
83
+ });
84
+ });
85
+ describe("normalizeOperator", () => {
86
+ test.each([
87
+ ["Equals", "equals"],
88
+ ["BeginsWith", "beginswith"],
89
+ ["NotDateEquals", "notdatequals"],
90
+ ["Not", "notequals"],
91
+ ["Tweens", "notbetween"],
92
+ ["Not, Date", "notdatequals"],
93
+ ])("%s normalizes to %s", (input, expected) => {
94
+ expect(normalizeOperator(input)).toBe(expected);
95
+ });
96
+ });
97
+ describe("getDynamicValuesForField", () => {
98
+ test("date fields get relative dates", () => {
99
+ expect(getDynamicValuesForField({ type: "date" })).toContain("ThisMonth");
100
+ });
101
+ test("string fields only get the current user", () => {
102
+ expect(getDynamicValuesForField({ type: "string" })).toEqual(["CurrentUser"]);
103
+ });
104
+ test("number fields get none", () => {
105
+ expect(getDynamicValuesForField({ type: "number" })).toEqual([]);
106
+ });
107
+ });