@object-ui/data-objectstack 3.4.0 → 4.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # @object-ui/data-objectstack
2
2
 
3
+ ## 4.0.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 4be43e2: **Page-mode record forms (`editMode: 'page'`).** New per-object metadata flag that opts a record's create/edit form into a dedicated full-screen route (`/apps/:appName/:objectName/new`, `/apps/:appName/:objectName/record/:recordId/edit`). Two new declarative actions `navigate_create` and `navigate_edit` open these routes from JSON action buttons. Default modal behavior is preserved for objects that do not set `editMode`.
8
+
9
+ **`@object-ui/plugin-list` & `@object-ui/plugin-detail`: `ComponentRegistry` singleton fix.** Both plugins' Vite configs now mark all `@object-ui/*` packages as external so each plugin no longer bundles its own private copy of `@object-ui/core`. Cross-plugin component lookups now resolve correctly from the same singleton registry. `plugin-list` dist shrank from multi-MB to 67 kB (gzip 16 kB); `plugin-detail` to 124 kB (gzip 28 kB).
10
+
11
+ **`@object-ui/app-shell` `CreateViewDialog` churn fix.** `existingSet` is now memoised on the joined string key of `existingLabels` rather than the raw array reference, preventing the name-suggest `useEffect` from re-firing on every parent render.
12
+
13
+ **CI fixes.** `ReportViewer` conditional-formatting test now accepts both `rgb(...)` and hex color representations. `ObjectView` i18n mocks rewritten to mirror the real hook shapes (`useObjectTranslation`, `useObjectLabel`).
14
+
15
+ - Updated dependencies [4be43e2]
16
+ - @object-ui/types@4.0.3
17
+ - @object-ui/core@4.0.3
18
+
19
+ ## 4.0.1
20
+
21
+ ### Patch Changes
22
+
23
+ - @object-ui/types@4.0.1
24
+ - @object-ui/core@4.0.1
25
+
26
+ ## 4.0.0
27
+
28
+ ### Patch Changes
29
+
30
+ - Updated dependencies
31
+ - @object-ui/types@4.0.0
32
+ - @object-ui/core@4.0.0
33
+
3
34
  ## 3.4.0
4
35
 
5
36
  ### Patch Changes
package/dist/index.cjs CHANGED
@@ -829,6 +829,57 @@ function calculateAutoLayout(items, canvasWidth, padding = 40, gap = 40) {
829
829
  }
830
830
 
831
831
  // src/index.ts
832
+ var FILTER_OPERATOR_ALIASES = {
833
+ equals: "=",
834
+ eq: "=",
835
+ "==": "=",
836
+ not_equals: "!=",
837
+ notequals: "!=",
838
+ ne: "!=",
839
+ greater_than: ">",
840
+ greaterthan: ">",
841
+ gt: ">",
842
+ greater_than_or_equal: ">=",
843
+ greater_than_or_equals: ">=",
844
+ greaterthanorequal: ">=",
845
+ gte: ">=",
846
+ less_than: "<",
847
+ lessthan: "<",
848
+ lt: "<",
849
+ less_than_or_equal: "<=",
850
+ less_than_or_equals: "<=",
851
+ lessthanorequal: "<=",
852
+ lte: "<=",
853
+ in: "in",
854
+ not_in: "nin",
855
+ notin: "nin",
856
+ nin: "nin",
857
+ contains: "contains",
858
+ not_contains: "notcontains",
859
+ notcontains: "notcontains",
860
+ starts_with: "startswith",
861
+ startswith: "startswith",
862
+ ends_with: "endswith",
863
+ endswith: "endswith",
864
+ between: "between",
865
+ is_null: "isnull",
866
+ isnull: "isnull",
867
+ is_not_null: "isnotnull",
868
+ isnotnull: "isnotnull"
869
+ };
870
+ function normalizeFilterOperator(op) {
871
+ if (typeof op !== "string") return null;
872
+ const lower = op.toLowerCase();
873
+ return FILTER_OPERATOR_ALIASES[lower] ?? FILTER_OPERATOR_ALIASES[op] ?? op;
874
+ }
875
+ function objectFilterEntryToAST(entry) {
876
+ if (!entry || typeof entry !== "object") return null;
877
+ const field = entry.field ?? entry.name;
878
+ const rawOp = entry.operator ?? entry.op ?? "=";
879
+ const op = normalizeFilterOperator(rawOp);
880
+ if (!field || !op) return null;
881
+ return [String(field), op, entry.value];
882
+ }
832
883
  var discoveryCache = /* @__PURE__ */ new Map();
833
884
  async function getSharedDiscovery(baseUrl, fetcher) {
834
885
  const key = baseUrl || "<default>";
@@ -1322,7 +1373,18 @@ var ObjectStackAdapter = class {
1322
1373
  const isEmpty = Array.isArray(params.$filter) ? params.$filter.length === 0 : typeof params.$filter === "object" && Object.keys(params.$filter).length === 0;
1323
1374
  if (!isEmpty) {
1324
1375
  if (Array.isArray(params.$filter)) {
1325
- options.filters = params.$filter;
1376
+ const isObjectForm = params.$filter.length > 0 && typeof params.$filter[0] === "object" && !Array.isArray(params.$filter[0]) && params.$filter[0].field !== void 0;
1377
+ if (isObjectForm) {
1378
+ const tuples = params.$filter.map((entry) => objectFilterEntryToAST(entry)).filter((t) => t !== null);
1379
+ if (tuples.length === 0) {
1380
+ } else if (tuples.length === 1) {
1381
+ options.filters = tuples[0];
1382
+ } else {
1383
+ options.filters = ["and", ...tuples];
1384
+ }
1385
+ } else {
1386
+ options.filters = params.$filter;
1387
+ }
1326
1388
  } else {
1327
1389
  options.filters = convertFiltersToAST(params.$filter);
1328
1390
  }
@@ -1488,6 +1550,18 @@ var ObjectStackAdapter = class {
1488
1550
  }
1489
1551
  const data = await this.client.analytics.query(payload);
1490
1552
  const rawRows = Array.isArray(data) ? data : data?.rows && Array.isArray(data.rows) ? data.rows : data?.data && Array.isArray(data.data) ? data.data : data?.data?.rows && Array.isArray(data.data.rows) ? data.data.rows : data?.results && Array.isArray(data.results) ? data.results : [];
1553
+ const measureMissing = rawRows.length > 0 && rawRows.every((row) => {
1554
+ if (row == null) return true;
1555
+ if (measureName in row && row[measureName] != null) return false;
1556
+ if (params.field in row && row[params.field] != null) return false;
1557
+ return true;
1558
+ });
1559
+ if (measureMissing) {
1560
+ const result = await this.find(resource);
1561
+ const records = result.data || [];
1562
+ if (records.length === 0) return [];
1563
+ return this.aggregateClientSide(records, params);
1564
+ }
1491
1565
  return rawRows.map((row) => {
1492
1566
  const mapped = { ...row };
1493
1567
  if (measureName !== params.field && measureName in mapped) {
package/dist/index.js CHANGED
@@ -787,6 +787,57 @@ function calculateAutoLayout(items, canvasWidth, padding = 40, gap = 40) {
787
787
  }
788
788
 
789
789
  // src/index.ts
790
+ var FILTER_OPERATOR_ALIASES = {
791
+ equals: "=",
792
+ eq: "=",
793
+ "==": "=",
794
+ not_equals: "!=",
795
+ notequals: "!=",
796
+ ne: "!=",
797
+ greater_than: ">",
798
+ greaterthan: ">",
799
+ gt: ">",
800
+ greater_than_or_equal: ">=",
801
+ greater_than_or_equals: ">=",
802
+ greaterthanorequal: ">=",
803
+ gte: ">=",
804
+ less_than: "<",
805
+ lessthan: "<",
806
+ lt: "<",
807
+ less_than_or_equal: "<=",
808
+ less_than_or_equals: "<=",
809
+ lessthanorequal: "<=",
810
+ lte: "<=",
811
+ in: "in",
812
+ not_in: "nin",
813
+ notin: "nin",
814
+ nin: "nin",
815
+ contains: "contains",
816
+ not_contains: "notcontains",
817
+ notcontains: "notcontains",
818
+ starts_with: "startswith",
819
+ startswith: "startswith",
820
+ ends_with: "endswith",
821
+ endswith: "endswith",
822
+ between: "between",
823
+ is_null: "isnull",
824
+ isnull: "isnull",
825
+ is_not_null: "isnotnull",
826
+ isnotnull: "isnotnull"
827
+ };
828
+ function normalizeFilterOperator(op) {
829
+ if (typeof op !== "string") return null;
830
+ const lower = op.toLowerCase();
831
+ return FILTER_OPERATOR_ALIASES[lower] ?? FILTER_OPERATOR_ALIASES[op] ?? op;
832
+ }
833
+ function objectFilterEntryToAST(entry) {
834
+ if (!entry || typeof entry !== "object") return null;
835
+ const field = entry.field ?? entry.name;
836
+ const rawOp = entry.operator ?? entry.op ?? "=";
837
+ const op = normalizeFilterOperator(rawOp);
838
+ if (!field || !op) return null;
839
+ return [String(field), op, entry.value];
840
+ }
790
841
  var discoveryCache = /* @__PURE__ */ new Map();
791
842
  async function getSharedDiscovery(baseUrl, fetcher) {
792
843
  const key = baseUrl || "<default>";
@@ -1280,7 +1331,18 @@ var ObjectStackAdapter = class {
1280
1331
  const isEmpty = Array.isArray(params.$filter) ? params.$filter.length === 0 : typeof params.$filter === "object" && Object.keys(params.$filter).length === 0;
1281
1332
  if (!isEmpty) {
1282
1333
  if (Array.isArray(params.$filter)) {
1283
- options.filters = params.$filter;
1334
+ const isObjectForm = params.$filter.length > 0 && typeof params.$filter[0] === "object" && !Array.isArray(params.$filter[0]) && params.$filter[0].field !== void 0;
1335
+ if (isObjectForm) {
1336
+ const tuples = params.$filter.map((entry) => objectFilterEntryToAST(entry)).filter((t) => t !== null);
1337
+ if (tuples.length === 0) {
1338
+ } else if (tuples.length === 1) {
1339
+ options.filters = tuples[0];
1340
+ } else {
1341
+ options.filters = ["and", ...tuples];
1342
+ }
1343
+ } else {
1344
+ options.filters = params.$filter;
1345
+ }
1284
1346
  } else {
1285
1347
  options.filters = convertFiltersToAST(params.$filter);
1286
1348
  }
@@ -1446,6 +1508,18 @@ var ObjectStackAdapter = class {
1446
1508
  }
1447
1509
  const data = await this.client.analytics.query(payload);
1448
1510
  const rawRows = Array.isArray(data) ? data : data?.rows && Array.isArray(data.rows) ? data.rows : data?.data && Array.isArray(data.data) ? data.data : data?.data?.rows && Array.isArray(data.data.rows) ? data.data.rows : data?.results && Array.isArray(data.results) ? data.results : [];
1511
+ const measureMissing = rawRows.length > 0 && rawRows.every((row) => {
1512
+ if (row == null) return true;
1513
+ if (measureName in row && row[measureName] != null) return false;
1514
+ if (params.field in row && row[params.field] != null) return false;
1515
+ return true;
1516
+ });
1517
+ if (measureMissing) {
1518
+ const result = await this.find(resource);
1519
+ const records = result.data || [];
1520
+ if (records.length === 0) return [];
1521
+ return this.aggregateClientSide(records, params);
1522
+ }
1449
1523
  return rawRows.map((row) => {
1450
1524
  const mapped = { ...row };
1451
1525
  if (measureName !== params.field && measureName in mapped) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/data-objectstack",
3
- "version": "3.4.0",
3
+ "version": "4.0.3",
4
4
  "description": "ObjectStack Data Adapter for Object UI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,8 +23,8 @@
23
23
  ],
24
24
  "dependencies": {
25
25
  "@objectstack/client": "^4.0.4",
26
- "@object-ui/core": "3.4.0",
27
- "@object-ui/types": "3.4.0"
26
+ "@object-ui/core": "4.0.3",
27
+ "@object-ui/types": "4.0.3"
28
28
  },
29
29
  "devDependencies": {
30
30
  "tsup": "^8.5.1",
package/src/index.ts CHANGED
@@ -18,6 +18,66 @@ import {
18
18
  createErrorFromResponse,
19
19
  } from './errors';
20
20
 
21
+ /**
22
+ * Map human-readable filter operator names produced by SDUI view configs
23
+ * (e.g. `lead.view.ts`) to the canonical operator symbols expected by the
24
+ * ObjectStack server's filter AST. Unknown operators fall through unchanged
25
+ * so existing AST-style entries keep working.
26
+ */
27
+ const FILTER_OPERATOR_ALIASES: Record<string, string> = {
28
+ equals: '=',
29
+ eq: '=',
30
+ '==': '=',
31
+ not_equals: '!=',
32
+ notequals: '!=',
33
+ ne: '!=',
34
+ greater_than: '>',
35
+ greaterthan: '>',
36
+ gt: '>',
37
+ greater_than_or_equal: '>=',
38
+ greater_than_or_equals: '>=',
39
+ greaterthanorequal: '>=',
40
+ gte: '>=',
41
+ less_than: '<',
42
+ lessthan: '<',
43
+ lt: '<',
44
+ less_than_or_equal: '<=',
45
+ less_than_or_equals: '<=',
46
+ lessthanorequal: '<=',
47
+ lte: '<=',
48
+ in: 'in',
49
+ not_in: 'nin',
50
+ notin: 'nin',
51
+ nin: 'nin',
52
+ contains: 'contains',
53
+ not_contains: 'notcontains',
54
+ notcontains: 'notcontains',
55
+ starts_with: 'startswith',
56
+ startswith: 'startswith',
57
+ ends_with: 'endswith',
58
+ endswith: 'endswith',
59
+ between: 'between',
60
+ is_null: 'isnull',
61
+ isnull: 'isnull',
62
+ is_not_null: 'isnotnull',
63
+ isnotnull: 'isnotnull',
64
+ };
65
+
66
+ function normalizeFilterOperator(op: unknown): string | null {
67
+ if (typeof op !== 'string') return null;
68
+ const lower = op.toLowerCase();
69
+ return FILTER_OPERATOR_ALIASES[lower] ?? FILTER_OPERATOR_ALIASES[op] ?? op;
70
+ }
71
+
72
+ function objectFilterEntryToAST(entry: any): [string, string, any] | null {
73
+ if (!entry || typeof entry !== 'object') return null;
74
+ const field = entry.field ?? entry.name;
75
+ const rawOp = entry.operator ?? entry.op ?? '=';
76
+ const op = normalizeFilterOperator(rawOp);
77
+ if (!field || !op) return null;
78
+ return [String(field), op, entry.value];
79
+ }
80
+
21
81
  // Module-level discovery cache. Multiple ObjectStackAdapter instances pointed
22
82
  // at the same baseUrl (e.g. ConditionalAuthWrapper's throwaway adapter +
23
83
  // AdapterProvider's main adapter) would otherwise each fire `/discovery`. By
@@ -749,8 +809,33 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
749
809
  : typeof params.$filter === 'object' && Object.keys(params.$filter).length === 0;
750
810
  if (!isEmpty) {
751
811
  if (Array.isArray(params.$filter)) {
752
- // Assume active AST format if it's already an array
753
- options.filters = params.$filter;
812
+ // Two array shapes are accepted from upstream:
813
+ // 1. AST tuples: [field, op, value] — pass through.
814
+ // 2. Object form: [{ field, operator, value }, ...] — server-driven
815
+ // view configs (lead.view.ts etc.) use this. Translate each
816
+ // entry into the AST tuple shape and map human-readable
817
+ // operator names (`greater_than_or_equal`, `in`, `contains`,
818
+ // …) to the canonical symbols the server understands.
819
+ const isObjectForm = params.$filter.length > 0
820
+ && typeof params.$filter[0] === 'object'
821
+ && !Array.isArray(params.$filter[0])
822
+ && (params.$filter[0] as any).field !== undefined;
823
+ if (isObjectForm) {
824
+ const tuples = (params.$filter as any[])
825
+ .map(entry => objectFilterEntryToAST(entry))
826
+ .filter((t): t is [string, string, any] => t !== null);
827
+ if (tuples.length === 0) {
828
+ // All entries were unrecognized — drop the filter rather than
829
+ // sending a malformed array.
830
+ } else if (tuples.length === 1) {
831
+ options.filters = tuples[0];
832
+ } else {
833
+ options.filters = ['and', ...tuples];
834
+ }
835
+ } else {
836
+ // Already in AST format
837
+ options.filters = params.$filter;
838
+ }
754
839
  } else {
755
840
  options.filters = convertFiltersToAST(params.$filter);
756
841
  }
@@ -970,6 +1055,24 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
970
1055
  : data?.results && Array.isArray(data.results) ? data.results
971
1056
  : [];
972
1057
 
1058
+ // Defensive guard: if the backend silently dropped the requested measure
1059
+ // (e.g. it doesn't recognise the `${field}_${function}` alias and the
1060
+ // canonical measure is named differently), the rows come back without
1061
+ // any measure value. Detect this and fall back to client-side
1062
+ // aggregation so charts still render.
1063
+ const measureMissing = rawRows.length > 0 && rawRows.every((row: any) => {
1064
+ if (row == null) return true;
1065
+ if (measureName in row && row[measureName] != null) return false;
1066
+ if (params.field in row && row[params.field] != null) return false;
1067
+ return true;
1068
+ });
1069
+ if (measureMissing) {
1070
+ const result = await this.find(resource as any);
1071
+ const records = result.data || [];
1072
+ if (records.length === 0) return [];
1073
+ return this.aggregateClientSide(records, params);
1074
+ }
1075
+
973
1076
  // Map measure keys back to the original field name so that consumers
974
1077
  // (ObjectChart, DashboardRenderer, etc.) can access values by field name.
975
1078
  // This includes count → field (e.g. 'count' → 'amount') to match the
@@ -1,277 +0,0 @@
1
- /**
2
- * ObjectUI
3
- * Copyright (c) 2024-present ObjectStack Inc.
4
- *
5
- * This source code is licensed under the MIT license found in the
6
- * LICENSE file in the root directory of this source tree.
7
- */
8
-
9
- import { describe, it, expect, beforeEach, vi } from 'vitest';
10
- import { ObjectStackAdapter } from './index';
11
-
12
- /**
13
- * Tests for ObjectStackAdapter.aggregate() — verifies that the analytics
14
- * query payload uses the correct string-based measure/dimension format
15
- * expected by the backend analytics service (MemoryAnalyticsService).
16
- *
17
- * See: https://github.com/objectstack-ai/objectui/issues (measures format bug)
18
- */
19
- describe('ObjectStackAdapter aggregate()', () => {
20
- let adapter: ObjectStackAdapter;
21
- let mockAnalyticsQuery: ReturnType<typeof vi.fn>;
22
-
23
- beforeEach(() => {
24
- mockAnalyticsQuery = vi.fn().mockResolvedValue({ data: [] });
25
-
26
- adapter = new ObjectStackAdapter({
27
- baseUrl: 'http://localhost:3000',
28
- autoReconnect: false,
29
- });
30
-
31
- // Inject mock client and mark as connected to bypass connect()
32
- (adapter as any).client = {
33
- data: {
34
- find: vi.fn().mockResolvedValue({ records: [], total: 0 }),
35
- },
36
- analytics: {
37
- query: mockAnalyticsQuery,
38
- },
39
- connect: vi.fn().mockResolvedValue(undefined),
40
- discover: vi.fn().mockResolvedValue({ status: 'ok' }),
41
- };
42
- (adapter as any).connected = true;
43
- });
44
-
45
- it('should send measures as string array with field_function format for sum', async () => {
46
- mockAnalyticsQuery.mockResolvedValue({ data: [] });
47
-
48
- await adapter.aggregate('opportunity', {
49
- field: 'amount',
50
- function: 'sum',
51
- groupBy: 'stage',
52
- });
53
-
54
- expect(mockAnalyticsQuery).toHaveBeenCalledWith({
55
- cube: 'opportunity',
56
- measures: ['amount_sum'],
57
- dimensions: ['stage'],
58
- });
59
- });
60
-
61
- it('should send measures as ["count"] for count aggregation', async () => {
62
- mockAnalyticsQuery.mockResolvedValue({ data: [] });
63
-
64
- await adapter.aggregate('opportunity', {
65
- field: 'amount',
66
- function: 'count',
67
- groupBy: 'stage',
68
- });
69
-
70
- expect(mockAnalyticsQuery).toHaveBeenCalledWith({
71
- cube: 'opportunity',
72
- measures: ['count'],
73
- dimensions: ['stage'],
74
- });
75
- });
76
-
77
- it('should send measures as string for avg aggregation', async () => {
78
- mockAnalyticsQuery.mockResolvedValue({ data: [] });
79
-
80
- await adapter.aggregate('opportunity', {
81
- field: 'amount',
82
- function: 'avg',
83
- groupBy: 'stage',
84
- });
85
-
86
- expect(mockAnalyticsQuery).toHaveBeenCalledWith({
87
- cube: 'opportunity',
88
- measures: ['amount_avg'],
89
- dimensions: ['stage'],
90
- });
91
- });
92
-
93
- it('should send empty dimensions when groupBy is _all', async () => {
94
- mockAnalyticsQuery.mockResolvedValue({ data: [] });
95
-
96
- await adapter.aggregate('opportunity', {
97
- field: 'amount',
98
- function: 'sum',
99
- groupBy: '_all',
100
- });
101
-
102
- expect(mockAnalyticsQuery).toHaveBeenCalledWith({
103
- cube: 'opportunity',
104
- measures: ['amount_sum'],
105
- dimensions: [],
106
- });
107
- });
108
-
109
- it('should include filters in payload when provided', async () => {
110
- const filter = [{ member: 'stage', operator: 'equals', values: ['Closed Won'] }];
111
- mockAnalyticsQuery.mockResolvedValue({ data: [] });
112
-
113
- await adapter.aggregate('opportunity', {
114
- field: 'amount',
115
- function: 'sum',
116
- groupBy: 'stage',
117
- filter,
118
- });
119
-
120
- expect(mockAnalyticsQuery).toHaveBeenCalledWith({
121
- cube: 'opportunity',
122
- measures: ['amount_sum'],
123
- dimensions: ['stage'],
124
- filters: filter,
125
- });
126
- });
127
-
128
- it('should map measure key back to field name in response', async () => {
129
- mockAnalyticsQuery.mockResolvedValue({
130
- data: [
131
- { stage: 'Prospect', amount_sum: 300 },
132
- { stage: 'Closed Won', amount_sum: 500 },
133
- ],
134
- });
135
-
136
- const result = await adapter.aggregate('opportunity', {
137
- field: 'amount',
138
- function: 'sum',
139
- groupBy: 'stage',
140
- });
141
-
142
- expect(result).toEqual([
143
- { stage: 'Prospect', amount: 300 },
144
- { stage: 'Closed Won', amount: 500 },
145
- ]);
146
- });
147
-
148
- it('should map count measure back to field name in response', async () => {
149
- mockAnalyticsQuery.mockResolvedValue({
150
- data: [
151
- { stage: 'Prospect', count: 5 },
152
- { stage: 'Closed Won', count: 3 },
153
- ],
154
- });
155
-
156
- const result = await adapter.aggregate('opportunity', {
157
- field: 'amount',
158
- function: 'count',
159
- groupBy: 'stage',
160
- });
161
-
162
- expect(result).toEqual([
163
- { stage: 'Prospect', amount: 5 },
164
- { stage: 'Closed Won', amount: 3 },
165
- ]);
166
- });
167
-
168
- it('should handle direct array response from analytics', async () => {
169
- mockAnalyticsQuery.mockResolvedValue([
170
- { stage: 'Prospect', amount_sum: 300 },
171
- ]);
172
-
173
- const result = await adapter.aggregate('opportunity', {
174
- field: 'amount',
175
- function: 'sum',
176
- groupBy: 'stage',
177
- });
178
-
179
- expect(result).toEqual([
180
- { stage: 'Prospect', amount: 300 },
181
- ]);
182
- });
183
-
184
- it('should handle results wrapper in response', async () => {
185
- mockAnalyticsQuery.mockResolvedValue({
186
- results: [
187
- { stage: 'Prospect', amount_avg: 150 },
188
- ],
189
- });
190
-
191
- const result = await adapter.aggregate('opportunity', {
192
- field: 'amount',
193
- function: 'avg',
194
- groupBy: 'stage',
195
- });
196
-
197
- expect(result).toEqual([
198
- { stage: 'Prospect', amount: 150 },
199
- ]);
200
- });
201
-
202
- it('should extract rows from { rows: [...] } envelope (SDK unwraps outer success/data)', async () => {
203
- mockAnalyticsQuery.mockResolvedValue({
204
- rows: [
205
- { stage: 'closed_won', expected_revenue_sum: 225000 },
206
- { stage: 'negotiation', expected_revenue_sum: 36000 },
207
- ],
208
- fields: [
209
- { name: 'stage', type: 'string' },
210
- { name: 'expected_revenue_sum', type: 'number' },
211
- ],
212
- });
213
-
214
- const result = await adapter.aggregate('opportunity', {
215
- field: 'expected_revenue',
216
- function: 'sum',
217
- groupBy: 'stage',
218
- });
219
-
220
- expect(result).toEqual([
221
- { stage: 'closed_won', expected_revenue: 225000 },
222
- { stage: 'negotiation', expected_revenue: 36000 },
223
- ]);
224
- });
225
-
226
- it('should extract rows from { data: { rows: [...] } } envelope (SDK does not unwrap)', async () => {
227
- mockAnalyticsQuery.mockResolvedValue({
228
- success: true,
229
- data: {
230
- rows: [
231
- { stage: 'closed_won', expected_revenue_sum: 225000 },
232
- { stage: 'negotiation', expected_revenue_sum: 36000 },
233
- ],
234
- fields: [
235
- { name: 'stage', type: 'string' },
236
- { name: 'expected_revenue_sum', type: 'number' },
237
- ],
238
- sql: '-- MongoDB Aggregation Pipeline',
239
- },
240
- });
241
-
242
- const result = await adapter.aggregate('opportunity', {
243
- field: 'expected_revenue',
244
- function: 'sum',
245
- groupBy: 'stage',
246
- });
247
-
248
- expect(result).toEqual([
249
- { stage: 'closed_won', expected_revenue: 225000 },
250
- { stage: 'negotiation', expected_revenue: 36000 },
251
- ]);
252
- });
253
-
254
- it('should fall back to client-side aggregation when analytics endpoint fails', async () => {
255
- mockAnalyticsQuery.mockRejectedValue(new Error('Analytics not available'));
256
-
257
- // Mock find() to return records for client-side aggregation
258
- (adapter as any).client.data.find = vi.fn().mockResolvedValue({
259
- records: [
260
- { stage: 'Prospect', amount: 100 },
261
- { stage: 'Prospect', amount: 200 },
262
- { stage: 'Closed Won', amount: 500 },
263
- ],
264
- total: 3,
265
- });
266
-
267
- const result = await adapter.aggregate('opportunity', {
268
- field: 'amount',
269
- function: 'sum',
270
- groupBy: 'stage',
271
- });
272
-
273
- expect(result).toHaveLength(2);
274
- expect(result.find((r: any) => r.stage === 'Prospect')?.amount).toBe(300);
275
- expect(result.find((r: any) => r.stage === 'Closed Won')?.amount).toBe(500);
276
- });
277
- });
@@ -1,267 +0,0 @@
1
- /**
2
- * ObjectUI
3
- * Copyright (c) 2024-present ObjectStack Inc.
4
- *
5
- * This source code is licensed under the MIT license found in the
6
- * LICENSE file in the root directory of this source tree.
7
- */
8
-
9
- import { describe, it, expect, beforeEach, vi } from 'vitest';
10
- import { ObjectStackAdapter } from './index';
11
-
12
- // We test the adapter's $expand handling.
13
- // When $expand is present, the adapter makes a raw GET request to the REST API
14
- // with `populate` as a URL query param (since the client SDK's data.find()
15
- // QueryOptions does not support populate/expand).
16
- // The key scenarios:
17
- // 1. find() with $expand → raw GET /api/v1/data/:object?populate=...
18
- // 2. find() without $expand → client.data.find() (GET) as before
19
- // 3. findOne() with $expand → raw GET /api/v1/data/:object?filter={id:...}&populate=...
20
- // 4. findOne() without $expand → client.data.get() as before
21
-
22
- describe('ObjectStackAdapter $expand support', () => {
23
- let adapter: ObjectStackAdapter;
24
- let mockClient: any;
25
- let mockFetch: ReturnType<typeof vi.fn>;
26
-
27
- beforeEach(() => {
28
- // Create a mock fetch that returns a successful response
29
- mockFetch = vi.fn().mockResolvedValue({
30
- ok: true,
31
- json: () => Promise.resolve({ records: [], total: 0 }),
32
- });
33
-
34
- adapter = new ObjectStackAdapter({
35
- baseUrl: 'http://localhost:3000',
36
- autoReconnect: false,
37
- fetch: mockFetch,
38
- });
39
-
40
- // Mock the internal client after construction
41
- mockClient = {
42
- data: {
43
- find: vi.fn().mockResolvedValue({ records: [], total: 0 }),
44
- query: vi.fn().mockResolvedValue({ records: [], total: 0 }),
45
- get: vi.fn().mockResolvedValue({ record: { id: '1', name: 'Test' } }),
46
- },
47
- connect: vi.fn().mockResolvedValue(undefined),
48
- discover: vi.fn().mockResolvedValue({ status: 'ok' }),
49
- };
50
-
51
- // Inject mock client and mark as connected to bypass connect()
52
- (adapter as any).client = mockClient;
53
- (adapter as any).connected = true;
54
- });
55
-
56
- describe('find() with $expand', () => {
57
- it('should make a raw GET request with populate query param when $expand is present', async () => {
58
- mockFetch.mockResolvedValue({
59
- ok: true,
60
- json: () => Promise.resolve({
61
- records: [{ id: '1', name: 'Order 1', customer: { id: '2', name: 'Alice' } }],
62
- total: 1,
63
- }),
64
- });
65
-
66
- const result = await adapter.find('order', {
67
- $top: 10,
68
- $expand: ['customer', 'account'],
69
- });
70
-
71
- // Should use raw fetch, not client.data.query or client.data.find
72
- expect(mockFetch).toHaveBeenCalled();
73
- const fetchUrl = mockFetch.mock.calls[0][0] as string;
74
- expect(fetchUrl).toContain('/api/v1/data/order');
75
- expect(fetchUrl).toContain('populate=customer%2Caccount');
76
- expect(fetchUrl).toContain('top=10');
77
- expect(mockClient.data.find).not.toHaveBeenCalled();
78
- expect(result.data).toHaveLength(1);
79
- expect(result.data[0].customer).toEqual({ id: '2', name: 'Alice' });
80
- });
81
-
82
- it('should pass filters and sort as query params', async () => {
83
- mockFetch.mockResolvedValue({
84
- ok: true,
85
- json: () => Promise.resolve({ records: [], total: 0 }),
86
- });
87
-
88
- await adapter.find('order', {
89
- $filter: { status: 'active' },
90
- $orderby: [{ field: 'name', order: 'asc' }],
91
- $top: 50,
92
- $skip: 10,
93
- $expand: ['customer'],
94
- });
95
-
96
- expect(mockFetch).toHaveBeenCalled();
97
- const fetchUrl = mockFetch.mock.calls[0][0] as string;
98
- expect(fetchUrl).toContain('populate=customer');
99
- expect(fetchUrl).toContain('top=50');
100
- expect(fetchUrl).toContain('skip=10');
101
- expect(fetchUrl).toContain('sort=name');
102
- expect(fetchUrl).toContain('filter=');
103
- });
104
-
105
- it('should use data.find() when $expand is not present', async () => {
106
- mockClient.data.find.mockResolvedValue({ records: [{ id: '1', name: 'Test' }], total: 1 });
107
-
108
- const result = await adapter.find('order', { $top: 10 });
109
-
110
- expect(mockClient.data.find).toHaveBeenCalled();
111
- expect(result.data).toHaveLength(1);
112
- });
113
-
114
- it('should use data.find() when $expand is an empty array', async () => {
115
- mockClient.data.find.mockResolvedValue({ records: [], total: 0 });
116
-
117
- await adapter.find('order', { $top: 10, $expand: [] });
118
-
119
- expect(mockClient.data.find).toHaveBeenCalled();
120
- });
121
- });
122
-
123
- describe('findOne() with $expand', () => {
124
- it('should make a raw GET request with id filter and populate when $expand is present', async () => {
125
- mockFetch.mockResolvedValue({
126
- ok: true,
127
- json: () => Promise.resolve({
128
- records: [{ id: 'order-1', name: 'Order 1', customer: { id: '2', name: 'Alice' } }],
129
- }),
130
- });
131
-
132
- const result = await adapter.findOne('order', 'order-1', {
133
- $expand: ['customer', 'account'],
134
- });
135
-
136
- expect(mockFetch).toHaveBeenCalled();
137
- const fetchUrl = mockFetch.mock.calls[0][0] as string;
138
- expect(fetchUrl).toContain('/api/v1/data/order');
139
- expect(fetchUrl).toContain('populate=customer%2Caccount');
140
- expect(fetchUrl).toContain('top=1');
141
- expect(fetchUrl).toContain('filter=');
142
- // Verify the filter contains id
143
- const filterParam = new URL(fetchUrl).searchParams.get('filter');
144
- expect(filterParam).toBeTruthy();
145
- const parsedFilter = JSON.parse(filterParam!);
146
- expect(parsedFilter).toEqual({ id: 'order-1' });
147
- expect(mockClient.data.get).not.toHaveBeenCalled();
148
- expect(result).toEqual({ id: 'order-1', name: 'Order 1', customer: { id: '2', name: 'Alice' } });
149
- });
150
-
151
- it('should return null when raw request returns no records', async () => {
152
- mockFetch.mockResolvedValue({
153
- ok: true,
154
- json: () => Promise.resolve({ records: [] }),
155
- });
156
-
157
- const result = await adapter.findOne('order', 'nonexistent', {
158
- $expand: ['customer'],
159
- });
160
-
161
- expect(result).toBeNull();
162
- });
163
-
164
- it('should use data.get() when $expand is not present', async () => {
165
- mockClient.data.get.mockResolvedValue({ record: { id: '1', name: 'Test' } });
166
-
167
- const result = await adapter.findOne('order', '1');
168
-
169
- expect(mockClient.data.get).toHaveBeenCalledWith('order', '1');
170
- expect(result).toEqual({ id: '1', name: 'Test' });
171
- });
172
-
173
- it('should return null for 404 errors without $expand', async () => {
174
- mockClient.data.get.mockRejectedValue({ status: 404 });
175
-
176
- const result = await adapter.findOne('order', 'nonexistent');
177
-
178
- expect(result).toBeNull();
179
- });
180
-
181
- it('should fall through to data.get() when $expand raw request fails with non-404 error', async () => {
182
- // The raw populate request fails (e.g., server doesn't support the filter+populate API)
183
- mockFetch.mockResolvedValue({
184
- ok: false,
185
- status: 500,
186
- statusText: 'Internal Server Error',
187
- json: () => Promise.resolve({ message: 'unsupported' }),
188
- });
189
- // But the direct data.get() call succeeds
190
- mockClient.data.get.mockResolvedValue({ record: { id: 'order-1', name: 'Order 1' } });
191
-
192
- const result = await adapter.findOne('order', 'order-1', {
193
- $expand: ['customer'],
194
- });
195
-
196
- // Should have tried raw request first
197
- expect(mockFetch).toHaveBeenCalled();
198
- // Then fell through to data.get()
199
- expect(mockClient.data.get).toHaveBeenCalledWith('order', 'order-1');
200
- expect(result).toEqual({ id: 'order-1', name: 'Order 1' });
201
- });
202
- });
203
-
204
- describe('raw request format', () => {
205
- it('should include Authorization header when token is set', async () => {
206
- mockFetch.mockResolvedValue({
207
- ok: true,
208
- json: () => Promise.resolve({ records: [], total: 0 }),
209
- });
210
-
211
- (adapter as any).token = 'test-token';
212
-
213
- await adapter.find('order', {
214
- $expand: ['customer'],
215
- });
216
-
217
- expect(mockFetch).toHaveBeenCalled();
218
- const fetchInit = mockFetch.mock.calls[0][1];
219
- expect(fetchInit.headers.Authorization).toBe('Bearer test-token');
220
- });
221
-
222
- it('should unwrap response envelope with success/data wrapper', async () => {
223
- mockFetch.mockResolvedValue({
224
- ok: true,
225
- json: () => Promise.resolve({
226
- success: true,
227
- data: {
228
- records: [{ id: '1', name: 'Order' }],
229
- total: 1,
230
- },
231
- }),
232
- });
233
-
234
- const result = await adapter.find('order', {
235
- $expand: ['customer'],
236
- });
237
-
238
- expect(result.data).toHaveLength(1);
239
- });
240
-
241
- it('should not double /api/v1 when baseUrl already includes it', async () => {
242
- mockFetch.mockResolvedValue({
243
- ok: true,
244
- json: () => Promise.resolve({ records: [], total: 0 }),
245
- });
246
-
247
- // Create adapter with /api/v1 in baseUrl
248
- const apiAdapter = new ObjectStackAdapter({
249
- baseUrl: 'http://localhost:3000/api/v1',
250
- autoReconnect: false,
251
- fetch: mockFetch,
252
- });
253
- (apiAdapter as any).client = mockClient;
254
- (apiAdapter as any).connected = true;
255
-
256
- await apiAdapter.find('order', {
257
- $expand: ['customer'],
258
- });
259
-
260
- expect(mockFetch).toHaveBeenCalled();
261
- const fetchUrl = mockFetch.mock.calls[0][0] as string;
262
- // Should be /api/v1/data/order not /api/v1/api/v1/data/order
263
- expect(fetchUrl).toBe('http://localhost:3000/api/v1/data/order?populate=customer');
264
- expect(fetchUrl).not.toContain('/api/v1/api/v1');
265
- });
266
- });
267
- });
@@ -1,112 +0,0 @@
1
- /**
2
- * Tests for ObjectStackAdapter file upload integration
3
- */
4
- import { describe, it, expect, vi, beforeEach } from 'vitest';
5
- import { ObjectStackAdapter } from './index';
6
-
7
- describe('ObjectStackAdapter File Upload', () => {
8
- let adapter: ObjectStackAdapter;
9
-
10
- beforeEach(() => {
11
- adapter = new ObjectStackAdapter({
12
- baseUrl: 'http://localhost:3000',
13
- autoReconnect: false,
14
- });
15
- vi.clearAllMocks();
16
- });
17
-
18
- describe('uploadFile', () => {
19
- it('should be a method on the adapter', () => {
20
- expect(typeof adapter.uploadFile).toBe('function');
21
- });
22
-
23
- it('should call fetch with multipart form data when connected', async () => {
24
- const mockResponse = {
25
- ok: true,
26
- json: vi.fn().mockResolvedValue({
27
- id: 'file-1',
28
- filename: 'test.pdf',
29
- mimeType: 'application/pdf',
30
- size: 1024,
31
- url: 'http://localhost:3000/files/file-1',
32
- }),
33
- };
34
-
35
- global.fetch = vi.fn().mockResolvedValue(mockResponse);
36
-
37
- // Manually set connected state by accessing private field
38
- (adapter as any).connected = true;
39
- (adapter as any).connectionState = 'connected';
40
-
41
- const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' });
42
-
43
- const result = await adapter.uploadFile('documents', file, {
44
- recordId: 'rec-123',
45
- fieldName: 'attachment',
46
- });
47
-
48
- expect(global.fetch).toHaveBeenCalledWith(
49
- expect.stringContaining('/api/data/documents/upload'),
50
- expect.objectContaining({
51
- method: 'POST',
52
- body: expect.any(FormData),
53
- }),
54
- );
55
-
56
- expect(result.id).toBe('file-1');
57
- expect(result.filename).toBe('test.pdf');
58
- });
59
-
60
- it('should throw on upload failure', async () => {
61
- const mockResponse = {
62
- ok: false,
63
- status: 413,
64
- statusText: 'Payload Too Large',
65
- json: vi.fn().mockResolvedValue({ message: 'File too large' }),
66
- };
67
-
68
- global.fetch = vi.fn().mockResolvedValue(mockResponse);
69
-
70
- // Manually set connected state
71
- (adapter as any).connected = true;
72
- (adapter as any).connectionState = 'connected';
73
-
74
- const file = new File(['test'], 'large.bin', { type: 'application/octet-stream' });
75
-
76
- await expect(adapter.uploadFile('documents', file)).rejects.toThrow('File too large');
77
- });
78
- });
79
-
80
- describe('uploadFiles', () => {
81
- it('should be a method on the adapter', () => {
82
- expect(typeof adapter.uploadFiles).toBe('function');
83
- });
84
-
85
- it('should upload multiple files', async () => {
86
- const mockResponse = {
87
- ok: true,
88
- json: vi.fn().mockResolvedValue([
89
- { id: 'file-1', filename: 'a.pdf', mimeType: 'application/pdf', size: 100, url: '/files/1' },
90
- { id: 'file-2', filename: 'b.pdf', mimeType: 'application/pdf', size: 200, url: '/files/2' },
91
- ]),
92
- };
93
-
94
- global.fetch = vi.fn().mockResolvedValue(mockResponse);
95
-
96
- // Manually set connected state
97
- (adapter as any).connected = true;
98
- (adapter as any).connectionState = 'connected';
99
-
100
- const files = [
101
- new File(['content1'], 'a.pdf', { type: 'application/pdf' }),
102
- new File(['content2'], 'b.pdf', { type: 'application/pdf' }),
103
- ];
104
-
105
- const results = await adapter.uploadFiles('documents', files);
106
-
107
- expect(results).toHaveLength(2);
108
- expect(results[0].id).toBe('file-1');
109
- expect(results[1].id).toBe('file-2');
110
- });
111
- });
112
- });