@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 +31 -0
- package/dist/index.cjs +75 -1
- package/dist/index.js +75 -1
- package/package.json +3 -3
- package/src/index.ts +105 -2
- package/src/aggregate.test.ts +0 -277
- package/src/expand.test.ts +0 -267
- package/src/upload.test.ts +0 -112
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
|
-
|
|
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
|
-
|
|
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
|
+
"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": "
|
|
27
|
-
"@object-ui/types": "
|
|
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
|
-
//
|
|
753
|
-
|
|
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
|
package/src/aggregate.test.ts
DELETED
|
@@ -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
|
-
});
|
package/src/expand.test.ts
DELETED
|
@@ -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
|
-
});
|
package/src/upload.test.ts
DELETED
|
@@ -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
|
-
});
|