@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
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Report metadata — the shape of a report and its parameters, as the Windows
3
+ * report launcher understands them.
4
+ *
5
+ * The loading lives in `useReport` in `@olenbetong/appframe-react`; everything
6
+ * here is pure.
7
+ */
8
+ /**
9
+ * The parameter types `sviw_WinClient_ReportsParameters.DataType` uses. The
10
+ * Windows launcher creates a text edit, a numeric edit, a date edit or a check
11
+ * edit from this, unless the parameter has a lookup or a fixed item list.
12
+ */
13
+ export type ReportParameterType = "String" | "Integer" | "Double" | "Date" | "Boolean";
14
+ /** How a parameter should be edited. Derived from the metadata, not stored. */
15
+ export type ReportParameterEditor = "text" | "number" | "date" | "boolean" | "select" | "lookup";
16
+ /** A column selected by an `afRecordSource`. */
17
+ export type ReportRecordSourceColumn = {
18
+ name: string;
19
+ /** The field type the web data API expects, mapped from the CLR type. */
20
+ type: "string" | "number" | "boolean" | "date";
21
+ };
22
+ /** A lookup source parsed out of a parameter's `afRecordSource` XML. */
23
+ export type ReportRecordSource = {
24
+ viewName: string;
25
+ /** The columns the record source selects, in order. */
26
+ columns: ReportRecordSourceColumn[];
27
+ whereClause: string | null;
28
+ filterString: string | null;
29
+ maxRecords: number | null;
30
+ };
31
+ /** A report parameter, ready to be rendered. */
32
+ export type ReportParameter = {
33
+ name: string;
34
+ /** Label shown next to the editor. Falls back to the name. */
35
+ label: string;
36
+ /** Help text for the editor. */
37
+ description: string | null;
38
+ type: ReportParameterType;
39
+ editor: ReportParameterEditor;
40
+ /**
41
+ * The raw default. A value matching `^[a-z]stp_` names a stored procedure
42
+ * that produces the default; see `isProcedureDefault`.
43
+ */
44
+ defaultValue: string | null;
45
+ /** Fixed list of choices, one per line in `Items`. */
46
+ items: string[];
47
+ /** Lookup source, when the parameter is backed by a view. */
48
+ recordSource: ReportRecordSource | null;
49
+ /** The lookup column holding the value to send to the report. */
50
+ valueMember: string | null;
51
+ /** The lookup column to show. The first column that is not the value member. */
52
+ displayMember: string | null;
53
+ sortOrder: number;
54
+ /** Preferred size of the lookup popup, in pixels. */
55
+ lookupWidth: number | null;
56
+ lookupHeight: number | null;
57
+ };
58
+ /** A raw `sviw_WinClient_ReportsParameters` record. */
59
+ export type ReportParameterRecord = {
60
+ Name?: string | null;
61
+ Label?: string | null;
62
+ Description?: string | null;
63
+ DataType?: string | null;
64
+ DefaultValue?: string | null;
65
+ Items?: string | null;
66
+ RecordSource?: string | null;
67
+ ValueMember?: string | null;
68
+ ValueListViewName?: string | null;
69
+ SortOrder?: number | null;
70
+ LookupWidth?: number | null;
71
+ LookupHeight?: number | null;
72
+ };
73
+ /** Output formats a report can be rendered to. */
74
+ export type ReportFormat = "PDF" | "XLSX" | "XLS" | "CSV" | "HTML" | "RTF" | "MHT" | "TEXT" | "IMAGE";
75
+ /** A report, as the launcher needs it. */
76
+ export type Report = {
77
+ reportId: string;
78
+ /**
79
+ * The report's number, which is what users refer to a report by — it is
80
+ * printed in the footer of every report.
81
+ */
82
+ shortId: number | null;
83
+ title: string | null;
84
+ description: string | null;
85
+ /** The view the report reads, resolved from its record source. */
86
+ viewName: string | null;
87
+ /**
88
+ * The filter the report is designed with. Used unless the launcher is opened
89
+ * with an explicit filter.
90
+ */
91
+ initialFilter: string | null;
92
+ /** True when the report has a title band the user may override. */
93
+ hasTitle: boolean;
94
+ /** True when the report has a header band the user may fill in. */
95
+ hasHeader: boolean;
96
+ /** The formats the report may be rendered to, in the order they are offered. */
97
+ formats: ReportFormat[];
98
+ timeout: number | null;
99
+ };
100
+ /**
101
+ * A raw `sviw_WinClient_MyReports` record, optionally merged with the
102
+ * `HasTitle` / `HasHeader` columns from `sviw_WinClient_ReportsWeb`.
103
+ */
104
+ export type ReportRecord = {
105
+ ReportID?: string | null;
106
+ ShortID?: number | null;
107
+ Title?: string | null;
108
+ Description?: string | null;
109
+ RecordSource?: string | null;
110
+ FormatPDF?: boolean | number | null;
111
+ FormatCSV?: boolean | number | null;
112
+ FormatHTML?: boolean | number | null;
113
+ FormatRTF?: boolean | number | null;
114
+ FormatMHT?: boolean | number | null;
115
+ FormatTEXT?: boolean | number | null;
116
+ FormatIMAGE?: boolean | number | null;
117
+ HasTitle?: boolean | number | null;
118
+ HasHeader?: boolean | number | null;
119
+ Timeout?: number | null;
120
+ };
121
+ /** A parameter value, as sent to the render endpoint. */
122
+ export type ReportParameterValue = string | number | boolean | null;
123
+ /** Parameter values by parameter name. */
124
+ export type ReportParameterValues = Record<string, ReportParameterValue>;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Report metadata — the shape of a report and its parameters, as the Windows
3
+ * report launcher understands them.
4
+ *
5
+ * The loading lives in `useReport` in `@olenbetong/appframe-react`; everything
6
+ * here is pure.
7
+ */
8
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olenbetong/appframe-core",
3
- "version": "2.11.9",
3
+ "version": "2.12.0",
4
4
  "description": "Javascript libraries to utilize the Appframe framework",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -37,20 +37,20 @@
37
37
  ]
38
38
  },
39
39
  "dependencies": {
40
- "@happy-dom/global-registrator": "^15.7.3",
41
40
  "mitt": "^3.0.1"
42
41
  },
43
42
  "devDependencies": {
44
- "@types/bun": "^1.1.8",
45
- "chalk": "^5.3.0"
43
+ "@happy-dom/global-registrator": "^20.11.2",
44
+ "@types/bun": "^1.3.14",
45
+ "chalk": "^5.6.2"
46
46
  },
47
47
  "peerDependencies": {},
48
48
  "optionalDependencies": {},
49
49
  "scripts": {
50
- "build": "pnpm run build:tsc && pnpm test && pnpm run build:esbuild && pnpm run build:replace-version",
50
+ "build": "pnpm run build:tsc && pnpm test && pnpm run build:bundle && pnpm run build:replace-version",
51
51
  "test": "vitest run --config vitest.node.config.ts",
52
- "build:esbuild": "node --no-warnings ../../scripts/packages-esbuild.ts",
52
+ "build:bundle": "node --no-warnings ../../scripts/packages-bundle.ts",
53
53
  "build:replace-version": "node ./scripts/replace-version.js",
54
- "build:tsc": "rm -rf ./es tsconfig.build.tsbuildinfo && tsc -p tsconfig.build.json"
54
+ "build:tsc": "node --no-warnings ../../scripts/clean.ts ./es tsconfig.build.tsbuildinfo && tsc -p tsconfig.build.json"
55
55
  }
56
56
  }
package/src/alert.ts CHANGED
@@ -11,13 +11,28 @@ const template = `
11
11
  </dialog>
12
12
  `;
13
13
 
14
- if (globalThis.document && !document.querySelector("#AlertDialogTemplate")) {
14
+ /*
15
+ * The module can be loaded from `<head>`, where there is no body to append to
16
+ * yet. Waiting for the DOM in that case keeps the whole bundle from throwing on
17
+ * load — everything else it exports would be lost with it.
18
+ */
19
+ function addDialogTemplate() {
20
+ if (document.querySelector("#AlertDialogTemplate")) return;
21
+
15
22
  let $template = document.createElement("template");
16
23
  $template.id = "AlertDialogTemplate";
17
24
  $template.innerHTML = template;
18
25
  document.body.append($template);
19
26
  }
20
27
 
28
+ if (globalThis.document) {
29
+ if (document.body) {
30
+ addDialogTemplate();
31
+ } else {
32
+ document.addEventListener("DOMContentLoaded", addDialogTemplate, { once: true });
33
+ }
34
+ }
35
+
21
36
  export function waitForDialog(dialog: HTMLDialogElement, cancelValue = "No"): Promise<string> {
22
37
  return new Promise((resolve) => {
23
38
  let controller = new AbortController();
@@ -0,0 +1,47 @@
1
+ import { toFilterTree } from "./tree.js";
2
+ import type { FilterGroup } from "./types.js";
3
+
4
+ const CONVERT_URL = "/api/filter/convert";
5
+
6
+ export type FilterConversionResult = {
7
+ filterObject: FilterGroup | null;
8
+ filterString: string;
9
+ whereClause: string;
10
+ /** Set when the filter string could not be parsed into a structured filter. */
11
+ warning?: string;
12
+ };
13
+
14
+ export type ConvertRequest = {
15
+ filterObject?: FilterGroup | null;
16
+ filterString?: string;
17
+ /** Optional view name, used server-side to validate field names. */
18
+ viewName?: string;
19
+ };
20
+
21
+ /**
22
+ * Converts between a structured filter and a DevExpress filter string.
23
+ *
24
+ * The conversion is done server-side by `api/filter/convert`, because the
25
+ * criteria string grammar is implemented by the licensed DevExpress parser that
26
+ * the Windows client and the report engine use. Doing it in the browser would
27
+ * risk producing strings the server cannot parse back.
28
+ */
29
+ export async function convertFilter(request: ConvertRequest, signal?: AbortSignal): Promise<FilterConversionResult> {
30
+ let response = await fetch(CONVERT_URL, {
31
+ method: "POST",
32
+ headers: { "Content-Type": "application/json" },
33
+ body: JSON.stringify(request),
34
+ signal,
35
+ });
36
+
37
+ if (!response.ok) {
38
+ throw new Error(`Failed to convert filter: ${response.status} ${response.statusText}`);
39
+ }
40
+
41
+ let result = (await response.json()) as FilterConversionResult;
42
+
43
+ return {
44
+ ...result,
45
+ filterObject: result.filterObject ? toFilterTree(result.filterObject) : null,
46
+ };
47
+ }
@@ -0,0 +1,103 @@
1
+ import type { FilterExpression } from "./types.js";
2
+ import { describe, expect, test } from "vitest";
3
+
4
+ import { describeFilter } from "./describeFilter.js";
5
+ import { createEmptyFilter } from "./tree.js";
6
+
7
+ const fields = [
8
+ { name: "PersonID", caption: "Person ID", type: "string" as const },
9
+ { name: "Created", caption: "Created", type: "date" as const },
10
+ { name: "Amount", caption: "Amount", type: "number" as const },
11
+ ];
12
+
13
+ function expression(
14
+ column: string,
15
+ operator: string,
16
+ value: FilterExpression["value"],
17
+ valueType = "string",
18
+ ): FilterExpression {
19
+ return { type: "expression", column, operator, value, valueType };
20
+ }
21
+
22
+ describe("describeFilter", () => {
23
+ test("empty filters describe as an empty string", () => {
24
+ expect(describeFilter(null)).toBe("");
25
+ expect(describeFilter(createEmptyFilter())).toBe("");
26
+ });
27
+
28
+ test("uses the field caption when available", () => {
29
+ let filter = expression("PersonID", "contains", "silv");
30
+
31
+ expect(describeFilter(filter, { fields })).toBe("[Person ID] Contains 'silv'");
32
+ });
33
+
34
+ test("falls back to the column name when the field is unknown", () => {
35
+ expect(describeFilter(expression("Unknown", "equals", "x"), { fields })).toBe("[Unknown] Equal to 'x'");
36
+ });
37
+
38
+ test("parameterless operators render without a value", () => {
39
+ expect(describeFilter(expression("PersonID", "isnull", null, "null"), { fields })).toBe("[Person ID] Is null");
40
+ });
41
+
42
+ test("numbers are not quoted", () => {
43
+ expect(describeFilter(expression("Amount", "greaterthan", 100, "number"), { fields })).toBe(
44
+ "[Amount] Greater than 100",
45
+ );
46
+ });
47
+
48
+ test("date fields use the relative operator wording", () => {
49
+ expect(describeFilter(expression("Created", "greaterthan", "2026-01-01", "datetime"), { fields })).toBe(
50
+ "[Created] After '2026-01-01'",
51
+ );
52
+ });
53
+
54
+ test("dynamic values render as their label", () => {
55
+ expect(describeFilter(expression("Created", "equals", "ThisMonth", "special"), { fields })).toBe(
56
+ "[Created] Equal to This month",
57
+ );
58
+ });
59
+
60
+ test("between renders a range", () => {
61
+ expect(describeFilter(expression("Amount", "between", ["1", "9"], "number"), { fields })).toBe(
62
+ "[Amount] Between 1 - 9",
63
+ );
64
+ });
65
+
66
+ test("in list renders a parenthesised list", () => {
67
+ expect(describeFilter(expression("PersonID", "inlist", ["a", "b"]), { fields })).toBe(
68
+ "[Person ID] In list ('a', 'b')",
69
+ );
70
+ });
71
+
72
+ test("groups are joined by their mode and nested groups are parenthesised", () => {
73
+ let filter = {
74
+ type: "group" as const,
75
+ mode: "or" as const,
76
+ items: [
77
+ {
78
+ type: "group" as const,
79
+ mode: "and" as const,
80
+ items: [expression("PersonID", "equals", "a"), expression("Amount", "greaterthan", 5, "number")],
81
+ },
82
+ { type: "group" as const, mode: "and" as const, items: [expression("PersonID", "equals", "b")] },
83
+ ],
84
+ };
85
+
86
+ expect(describeFilter(filter, { fields })).toBe(
87
+ "([Person ID] Equal to 'a' AND [Amount] Greater than 5) OR [Person ID] Equal to 'b'",
88
+ );
89
+ });
90
+
91
+ test("empty groups are omitted", () => {
92
+ let filter = {
93
+ type: "group" as const,
94
+ mode: "or" as const,
95
+ items: [
96
+ { type: "group" as const, mode: "and" as const, items: [expression("PersonID", "equals", "a")] },
97
+ { type: "group" as const, mode: "and" as const, items: [] },
98
+ ],
99
+ };
100
+
101
+ expect(describeFilter(filter, { fields })).toBe("[Person ID] Equal to 'a'");
102
+ });
103
+ });
@@ -0,0 +1,79 @@
1
+ import type { FilterExpression, FilterGroup } from "./types.js";
2
+ import { getDynamicValueLabel, getOperatorArity, getOperatorLabel, normalizeOperator } from "./operators.js";
3
+ import { isExpression } from "./tree.js";
4
+ import type { DynamicValue, FieldMetadata } from "./types.js";
5
+
6
+ export type DescribeFilterOptions = {
7
+ /** Field metadata, used to resolve captions and date operator wording. */
8
+ fields?: FieldMetadata[];
9
+ };
10
+
11
+ /**
12
+ * Builds a human-readable description of a filter, without contacting the
13
+ * server. Use this for read-only summaries; the authoritative filter string is
14
+ * produced by `api/filter/convert`, since only the server can generate the
15
+ * DevExpress criteria syntax.
16
+ */
17
+ export function describeFilter(
18
+ filter: FilterGroup | FilterExpression | null | undefined,
19
+ options: DescribeFilterOptions = {},
20
+ ): string {
21
+ if (!filter) return "";
22
+
23
+ let fieldsByName = new Map((options.fields ?? []).map((field) => [field.name.toLowerCase(), field]));
24
+
25
+ return describe(filter, fieldsByName, 0);
26
+ }
27
+
28
+ function describe(item: FilterGroup | FilterExpression, fields: Map<string, FieldMetadata>, depth: number): string {
29
+ if (isExpression(item)) return describeExpression(item, fields);
30
+
31
+ let parts = item.items.map((child) => describe(child, fields, depth + 1)).filter((part) => part !== "");
32
+
33
+ if (parts.length === 0) return "";
34
+ if (parts.length === 1) return parts[0] as string;
35
+
36
+ let separator = item.mode === "or" ? " OR " : " AND ";
37
+ let joined = parts.join(separator);
38
+
39
+ return depth === 0 ? joined : `(${joined})`;
40
+ }
41
+
42
+ function describeExpression(expression: FilterExpression, fields: Map<string, FieldMetadata>): string {
43
+ if (!expression.column || !expression.operator) return "";
44
+
45
+ let field = fields.get(expression.column.toLowerCase());
46
+ let caption = field?.caption ?? expression.column;
47
+ let operator = normalizeOperator(expression.operator);
48
+ let label = getOperatorLabel(operator, field?.type);
49
+ let arity = getOperatorArity(operator);
50
+
51
+ if (arity === "none") return `[${caption}] ${label}`;
52
+
53
+ return `[${caption}] ${label} ${formatValue(expression, arity)}`;
54
+ }
55
+
56
+ function formatValue(expression: FilterExpression, arity: ReturnType<typeof getOperatorArity>): string {
57
+ let { value, valueType } = expression;
58
+
59
+ if (valueType === "special" && typeof value === "string") {
60
+ return getDynamicValueLabel(value as DynamicValue);
61
+ }
62
+
63
+ if (Array.isArray(value)) {
64
+ let formatted = value.map((item) => formatScalar(item, valueType));
65
+
66
+ return arity === "pair" ? formatted.join(" - ") : `(${formatted.join(", ")})`;
67
+ }
68
+
69
+ return formatScalar(value, valueType);
70
+ }
71
+
72
+ function formatScalar(value: unknown, valueType: string): string {
73
+ if (value === null || value === undefined) return "";
74
+ if (value instanceof Date) return value.toISOString().slice(0, 10);
75
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
76
+ if (valueType === "number" || valueType === "boolean") return String(value);
77
+
78
+ return `'${String(value)}'`;
79
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, expect, test } from "vitest";
2
+
3
+ import { toFieldMetadata, toFieldMetadataList, toFilterFieldType } from "./fields.js";
4
+
5
+ describe("toFilterFieldType", () => {
6
+ test("passes through the types the view normalizes to", () => {
7
+ expect(toFilterFieldType("boolean")).toBe("boolean");
8
+ expect(toFilterFieldType("datetime")).toBe("datetime");
9
+ expect(toFilterFieldType("uniqueidentifier")).toBe("uniqueidentifier");
10
+ });
11
+
12
+ test("an unknown or missing type is filtered as text", () => {
13
+ expect(toFilterFieldType("geography")).toBe("string");
14
+ expect(toFilterFieldType(null)).toBe("string");
15
+ });
16
+ });
17
+
18
+ describe("toFieldMetadata", () => {
19
+ test("maps a record", () => {
20
+ let field = toFieldMetadata({
21
+ FieldName: "PersonID",
22
+ DataType: "string",
23
+ Nullable: true,
24
+ ExcludeFromFilter: false,
25
+ ValueListRecordSource: "<afRecordSource />",
26
+ });
27
+
28
+ expect(field).toEqual({
29
+ name: "PersonID",
30
+ type: "string",
31
+ nullable: true,
32
+ excludeFromFilter: false,
33
+ valueListRecordSource: "<afRecordSource />",
34
+ });
35
+ });
36
+
37
+ test("bit columns arrive as numbers", () => {
38
+ expect(toFieldMetadata({ FieldName: "A", Nullable: 1 }).nullable).toBe(true);
39
+ expect(toFieldMetadata({ FieldName: "A", Nullable: 0 }).nullable).toBe(false);
40
+ });
41
+
42
+ test("an empty record source is dropped rather than kept as an empty string", () => {
43
+ expect(toFieldMetadata({ FieldName: "A", ValueListRecordSource: "" }).valueListRecordSource).toBeUndefined();
44
+ });
45
+ });
46
+
47
+ describe("toFieldMetadataList", () => {
48
+ test("sorts by name and drops excluded and unnamed fields", () => {
49
+ let fields = toFieldMetadataList([
50
+ { FieldName: "Name", DataType: "string" },
51
+ { FieldName: "Age", DataType: "number" },
52
+ { FieldName: "CDL", DataType: "boolean", ExcludeFromFilter: true },
53
+ { FieldName: "", DataType: "string" },
54
+ ]);
55
+
56
+ expect(fields.map((field) => field.name)).toEqual(["Age", "Name"]);
57
+ });
58
+ });
@@ -0,0 +1,56 @@
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
+
10
+ import type { FieldMetadata, FilterFieldType } from "./types.js";
11
+
12
+ /** A raw `sviw_WebSiteCMS_FilterBuilder_ViewFields` record. */
13
+ export type ViewFieldRecord = {
14
+ FieldName?: string | null;
15
+ /** Already normalized by the view; anything unexpected is treated as text. */
16
+ DataType?: string | null;
17
+ DBObjectID?: string | null;
18
+ Nullable?: boolean | number | null;
19
+ ExcludeFromFilter?: boolean | number | null;
20
+ ValueListRecordSource?: string | null;
21
+ };
22
+
23
+ const FIELD_TYPES: FilterFieldType[] = ["string", "number", "boolean", "date", "datetime", "uniqueidentifier"];
24
+
25
+ /** The builder type for a normalized `DataType`. Unknown types are filtered as text. */
26
+ export function toFilterFieldType(dataType: string | null | undefined): FilterFieldType {
27
+ let normalized = dataType?.trim().toLowerCase();
28
+
29
+ return FIELD_TYPES.find((type) => type === normalized) ?? "string";
30
+ }
31
+
32
+ function toBoolean(value: boolean | number | null | undefined) {
33
+ return value === true || value === 1;
34
+ }
35
+
36
+ /** Maps a view field record onto the metadata the builder renders. */
37
+ export function toFieldMetadata(record: ViewFieldRecord): FieldMetadata {
38
+ return {
39
+ name: record.FieldName ?? "",
40
+ type: toFilterFieldType(record.DataType),
41
+ nullable: toBoolean(record.Nullable),
42
+ excludeFromFilter: toBoolean(record.ExcludeFromFilter),
43
+ valueListRecordSource: record.ValueListRecordSource || undefined,
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Maps and sorts view field records. Fields the view marks as excluded are
49
+ * dropped rather than hidden, since nothing else can use them.
50
+ */
51
+ export function toFieldMetadataList(records: ViewFieldRecord[]): FieldMetadata[] {
52
+ return records
53
+ .map(toFieldMetadata)
54
+ .filter((field) => field.name !== "" && !field.excludeFromFilter)
55
+ .sort((a, b) => a.name.localeCompare(b.name));
56
+ }
@@ -0,0 +1,62 @@
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 {
5
+ getDefaultOperator,
6
+ getDynamicValueLabel,
7
+ getDynamicValuesForField,
8
+ getOperatorArity,
9
+ getOperatorLabel,
10
+ getOperatorsForField,
11
+ getValueType,
12
+ normalizeOperator,
13
+ type OperatorArity,
14
+ RELATIVE_DATE_VALUES,
15
+ } from "./operators.js";
16
+ export { coerceToStandardLayout, isStandardLayout } from "./standardLayout.js";
17
+ export {
18
+ canDeleteSavedFilter,
19
+ canEditSavedFilter,
20
+ canRenameSavedFilter,
21
+ canSetCrossDomain,
22
+ canShareSavedFilter,
23
+ findSavedFilter,
24
+ NO_FILTER_ID,
25
+ partitionSavedFilters,
26
+ type SavedFilter,
27
+ type SavedFilterRecord,
28
+ toSavedFilter,
29
+ toSavedFilters,
30
+ } from "./savedFilters.js";
31
+ export {
32
+ addGroupAtPath,
33
+ changeOperator,
34
+ countExpressions,
35
+ createEmptyFilter,
36
+ createEmptyGroup,
37
+ createExpression,
38
+ type FilterNode,
39
+ type FilterPath,
40
+ getNodeAtPath,
41
+ insertNodeAtPath,
42
+ isCompleteExpression,
43
+ isEmptyFilter,
44
+ isExpression,
45
+ isGroup,
46
+ pruneFilter,
47
+ removeNodeAtPath,
48
+ setGroupMode,
49
+ toFilterTree,
50
+ updateNodeAtPath,
51
+ } from "./tree.js";
52
+ export type {
53
+ DynamicValue,
54
+ FieldMetadata,
55
+ FilterAccessLevel,
56
+ FilterExpression,
57
+ FilterFieldType,
58
+ FilterGroup,
59
+ FilterOperator,
60
+ FilterTree,
61
+ FilterValueType,
62
+ } from "./types.js";