@olenbetong/appframe-core 2.11.9 → 2.12.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 (97) hide show
  1. package/dist/esm/af.common.js +1979 -609
  2. package/dist/esm/af.common.legacy.js +1933 -609
  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 +2140 -694
  8. package/dist/iife/af.common.legacy.js +2094 -694
  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 +229 -0
  28. package/es/filter/operators.test.d.ts +1 -0
  29. package/es/filter/operators.test.js +106 -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.js +19 -2
  47. package/es/parseFunc.test.js +48 -4
  48. package/es/report/index.d.ts +6 -0
  49. package/es/report/index.js +5 -0
  50. package/es/report/parameterDefaults.d.ts +26 -0
  51. package/es/report/parameterDefaults.js +77 -0
  52. package/es/report/parameters.d.ts +46 -0
  53. package/es/report/parameters.js +188 -0
  54. package/es/report/parameters.test.d.ts +1 -0
  55. package/es/report/parameters.test.js +161 -0
  56. package/es/report/recordSource.d.ts +30 -0
  57. package/es/report/recordSource.js +105 -0
  58. package/es/report/render.d.ts +81 -0
  59. package/es/report/render.js +219 -0
  60. package/es/report/render.test.d.ts +1 -0
  61. package/es/report/render.test.js +144 -0
  62. package/es/report/report.d.ts +16 -0
  63. package/es/report/report.js +50 -0
  64. package/es/report/report.test.d.ts +1 -0
  65. package/es/report/report.test.js +86 -0
  66. package/es/report/types.d.ts +124 -0
  67. package/es/report/types.js +8 -0
  68. package/package.json +7 -7
  69. package/src/alert.ts +16 -1
  70. package/src/filter/convertFilter.ts +47 -0
  71. package/src/filter/describeFilter.test.ts +103 -0
  72. package/src/filter/describeFilter.ts +79 -0
  73. package/src/filter/fields.test.ts +58 -0
  74. package/src/filter/fields.ts +56 -0
  75. package/src/filter/index.ts +62 -0
  76. package/src/filter/operators.test.ts +136 -0
  77. package/src/filter/operators.ts +252 -0
  78. package/src/filter/savedFilters.test.ts +145 -0
  79. package/src/filter/savedFilters.ts +192 -0
  80. package/src/filter/standardLayout.test.ts +88 -0
  81. package/src/filter/standardLayout.ts +65 -0
  82. package/src/filter/tree.test.ts +258 -0
  83. package/src/filter/tree.ts +238 -0
  84. package/src/filter/types.ts +146 -0
  85. package/src/index.ts +3 -1
  86. package/src/parseFunc.test.ts +61 -4
  87. package/src/parseFunc.ts +29 -2
  88. package/src/report/index.ts +37 -0
  89. package/src/report/parameterDefaults.ts +101 -0
  90. package/src/report/parameters.test.ts +218 -0
  91. package/src/report/parameters.ts +230 -0
  92. package/src/report/recordSource.ts +123 -0
  93. package/src/report/render.test.ts +230 -0
  94. package/src/report/render.ts +269 -0
  95. package/src/report/report.test.ts +104 -0
  96. package/src/report/report.ts +51 -0
  97. package/src/report/types.ts +135 -0
package/es/alert.js CHANGED
@@ -9,12 +9,27 @@ const template = `
9
9
  </form>
10
10
  </dialog>
11
11
  `;
12
- if (globalThis.document && !document.querySelector("#AlertDialogTemplate")) {
12
+ /*
13
+ * The module can be loaded from `<head>`, where there is no body to append to
14
+ * yet. Waiting for the DOM in that case keeps the whole bundle from throwing on
15
+ * load — everything else it exports would be lost with it.
16
+ */
17
+ function addDialogTemplate() {
18
+ if (document.querySelector("#AlertDialogTemplate"))
19
+ return;
13
20
  let $template = document.createElement("template");
14
21
  $template.id = "AlertDialogTemplate";
15
22
  $template.innerHTML = template;
16
23
  document.body.append($template);
17
24
  }
25
+ if (globalThis.document) {
26
+ if (document.body) {
27
+ addDialogTemplate();
28
+ }
29
+ else {
30
+ document.addEventListener("DOMContentLoaded", addDialogTemplate, { once: true });
31
+ }
32
+ }
18
33
  export function waitForDialog(dialog, cancelValue = "No") {
19
34
  return new Promise((resolve) => {
20
35
  let controller = new AbortController();
@@ -0,0 +1,23 @@
1
+ import type { FilterGroup } from "./types.js";
2
+ export type FilterConversionResult = {
3
+ filterObject: FilterGroup | null;
4
+ filterString: string;
5
+ whereClause: string;
6
+ /** Set when the filter string could not be parsed into a structured filter. */
7
+ warning?: string;
8
+ };
9
+ export type ConvertRequest = {
10
+ filterObject?: FilterGroup | null;
11
+ filterString?: string;
12
+ /** Optional view name, used server-side to validate field names. */
13
+ viewName?: string;
14
+ };
15
+ /**
16
+ * Converts between a structured filter and a DevExpress filter string.
17
+ *
18
+ * The conversion is done server-side by `api/filter/convert`, because the
19
+ * criteria string grammar is implemented by the licensed DevExpress parser that
20
+ * the Windows client and the report engine use. Doing it in the browser would
21
+ * risk producing strings the server cannot parse back.
22
+ */
23
+ export declare function convertFilter(request: ConvertRequest, signal?: AbortSignal): Promise<FilterConversionResult>;
@@ -0,0 +1,26 @@
1
+ import { toFilterTree } from "./tree.js";
2
+ const CONVERT_URL = "/api/filter/convert";
3
+ /**
4
+ * Converts between a structured filter and a DevExpress filter string.
5
+ *
6
+ * The conversion is done server-side by `api/filter/convert`, because the
7
+ * criteria string grammar is implemented by the licensed DevExpress parser that
8
+ * the Windows client and the report engine use. Doing it in the browser would
9
+ * risk producing strings the server cannot parse back.
10
+ */
11
+ export async function convertFilter(request, signal) {
12
+ let response = await fetch(CONVERT_URL, {
13
+ method: "POST",
14
+ headers: { "Content-Type": "application/json" },
15
+ body: JSON.stringify(request),
16
+ signal,
17
+ });
18
+ if (!response.ok) {
19
+ throw new Error(`Failed to convert filter: ${response.status} ${response.statusText}`);
20
+ }
21
+ let result = (await response.json());
22
+ return {
23
+ ...result,
24
+ filterObject: result.filterObject ? toFilterTree(result.filterObject) : null,
25
+ };
26
+ }
@@ -0,0 +1,13 @@
1
+ import type { FilterExpression, FilterGroup } from "./types.js";
2
+ import type { FieldMetadata } from "./types.js";
3
+ export type DescribeFilterOptions = {
4
+ /** Field metadata, used to resolve captions and date operator wording. */
5
+ fields?: FieldMetadata[];
6
+ };
7
+ /**
8
+ * Builds a human-readable description of a filter, without contacting the
9
+ * server. Use this for read-only summaries; the authoritative filter string is
10
+ * produced by `api/filter/convert`, since only the server can generate the
11
+ * DevExpress criteria syntax.
12
+ */
13
+ export declare function describeFilter(filter: FilterGroup | FilterExpression | null | undefined, options?: DescribeFilterOptions): string;
@@ -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,229 @@
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
+ /** Default operator per field type. */
104
+ const DEFAULT_OPERATOR_BY_TYPE = {
105
+ date: "equals",
106
+ datetime: "equals",
107
+ number: "equals",
108
+ boolean: "istrue",
109
+ string: "beginswith",
110
+ uniqueidentifier: "beginswith",
111
+ };
112
+ /**
113
+ * Returns the operators available for a field. Nullable fields additionally get
114
+ * the null checks. Boolean fields never get null checks, since `istrue` and
115
+ * `isfalse` already cover the parameterless cases.
116
+ */
117
+ export function getOperatorsForField(field) {
118
+ let operators = [...(OPERATORS_BY_TYPE[field.type] ?? OPERATORS_BY_TYPE.string)];
119
+ if (field.nullable && field.type !== "boolean") {
120
+ operators.push("isnull", "isnotnull");
121
+ }
122
+ return operators;
123
+ }
124
+ export function getDefaultOperator(field) {
125
+ return DEFAULT_OPERATOR_BY_TYPE[field.type] ?? "equals";
126
+ }
127
+ /**
128
+ * The `valueType` to send for a field/operator combination.
129
+ *
130
+ * Parameterless operators send `"null"`, list and range operators send
131
+ * `"array"`, everything else sends the field's own value type.
132
+ */
133
+ export function getValueType(field, operator) {
134
+ if (getOperatorArity(operator) === "none")
135
+ return "null";
136
+ switch (field.type) {
137
+ case "number":
138
+ return "number";
139
+ case "boolean":
140
+ return "boolean";
141
+ case "date":
142
+ case "datetime":
143
+ return "datetime";
144
+ case "uniqueidentifier":
145
+ return "uniqueidentifier";
146
+ default:
147
+ return "string";
148
+ }
149
+ }
150
+ /** Relative date values offered in the date value editor, in menu order. */
151
+ export const RELATIVE_DATE_VALUES = [
152
+ "Yesterday",
153
+ "Today",
154
+ "Tomorrow",
155
+ "ThisWeek",
156
+ "ThisMonth",
157
+ "ThisYear",
158
+ "LastWeek",
159
+ "LastMonth",
160
+ "LastYear",
161
+ "NextWeek",
162
+ "NextMonth",
163
+ "NextYear",
164
+ ];
165
+ const DYNAMIC_VALUE_LABELS = {
166
+ CurrentUser: "Current user",
167
+ Yesterday: "Yesterday",
168
+ Today: "Today",
169
+ Tomorrow: "Tomorrow",
170
+ ThisWeek: "This week",
171
+ ThisMonth: "This month",
172
+ ThisYear: "This year",
173
+ LastWeek: "Last week",
174
+ LastMonth: "Last month",
175
+ LastYear: "Last year",
176
+ NextWeek: "Next week",
177
+ NextMonth: "Next month",
178
+ NextYear: "Next year",
179
+ UserYesterday: "Yesterday",
180
+ UserToday: "Today",
181
+ UserTomorrow: "Tomorrow",
182
+ UserThisWeek: "This week",
183
+ UserThisMonth: "This month",
184
+ UserThisYear: "This year",
185
+ UserLastWeek: "Last week",
186
+ UserLastMonth: "Last month",
187
+ UserLastYear: "Last year",
188
+ UserNextWeek: "Next week",
189
+ UserNextMonth: "Next month",
190
+ UserNextYear: "Next year",
191
+ };
192
+ export function getDynamicValueLabel(value) {
193
+ return getLocalizedString(DYNAMIC_VALUE_LABELS[value] ?? value);
194
+ }
195
+ /**
196
+ * Returns the dynamic ("special") values that can be used with a field. Date
197
+ * fields get the relative dates; string fields can be compared to the current
198
+ * user name.
199
+ */
200
+ export function getDynamicValuesForField(field) {
201
+ if (field.type === "date" || field.type === "datetime")
202
+ return [...RELATIVE_DATE_VALUES];
203
+ if (field.type === "string")
204
+ return ["CurrentUser"];
205
+ return [];
206
+ }
207
+ /**
208
+ * Aliases for names `eOperators.ToString()` can produce that are not operators
209
+ * in their own right. `Not` is the bare negation flag (i.e. "not equal to"),
210
+ * `Tweens` shares its numeric value with `NotBetween`, and combined flags are
211
+ * rendered as a comma separated list.
212
+ *
213
+ * @see `FilterElementConverter.FilterExpressionToDictionary`
214
+ */
215
+ const OPERATOR_ALIASES = {
216
+ not: "notequals",
217
+ tweens: "notbetween",
218
+ notdate: "notdatequals",
219
+ // The enum member is `NotDateEquals`, but the wire format drops an "e".
220
+ notdateequals: "notdatequals",
221
+ };
222
+ /**
223
+ * Normalizes an operator string coming from the server or from another filter
224
+ * producer (`AfGrid` emits PascalCase names) to the canonical lower-case form.
225
+ */
226
+ export function normalizeOperator(operator) {
227
+ let normalized = operator.toLowerCase().replace(/[\s,]/g, "");
228
+ return OPERATOR_ALIASES[normalized] ?? normalized;
229
+ }
@@ -0,0 +1 @@
1
+ export {};