@object-ui/data-objectstack 11.0.0 → 11.3.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.
- package/CHANGELOG.md +24 -0
- package/dist/index.cjs +88 -33
- package/dist/index.d.cts +12 -1
- package/dist/index.d.ts +12 -1
- package/dist/index.js +88 -33
- package/package.json +4 -4
- package/src/exportDownload.test.ts +93 -0
- package/src/getObjectSchema.test.ts +36 -0
- package/src/index.ts +61 -1
- package/src/userState.test.ts +53 -0
- package/src/userState.ts +64 -37
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# @object-ui/data-objectstack
|
|
2
2
|
|
|
3
|
+
## 11.3.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [d23d6eb]
|
|
8
|
+
- @object-ui/core@11.3.0
|
|
9
|
+
- @object-ui/types@11.3.0
|
|
10
|
+
|
|
11
|
+
## 11.2.0
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- Updated dependencies [9e7a986]
|
|
16
|
+
- Updated dependencies [1311749]
|
|
17
|
+
- @object-ui/core@11.2.0
|
|
18
|
+
- @object-ui/types@11.2.0
|
|
19
|
+
|
|
20
|
+
## 11.1.0
|
|
21
|
+
|
|
22
|
+
### Patch Changes
|
|
23
|
+
|
|
24
|
+
- @object-ui/types@11.1.0
|
|
25
|
+
- @object-ui/core@11.1.0
|
|
26
|
+
|
|
3
27
|
## 7.3.0
|
|
4
28
|
|
|
5
29
|
### Patch Changes
|
package/dist/index.cjs
CHANGED
|
@@ -585,6 +585,7 @@ function createObjectStackUserStateAdapter(options) {
|
|
|
585
585
|
}
|
|
586
586
|
} = options;
|
|
587
587
|
let cachedRowId = null;
|
|
588
|
+
let saveChain = Promise.resolve();
|
|
588
589
|
const findExisting = async () => {
|
|
589
590
|
const params = {
|
|
590
591
|
$filter: {
|
|
@@ -615,6 +616,42 @@ function createObjectStackUserStateAdapter(options) {
|
|
|
615
616
|
}
|
|
616
617
|
return [];
|
|
617
618
|
};
|
|
619
|
+
const upsert = async (items) => {
|
|
620
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
621
|
+
if (cachedRowId !== null) {
|
|
622
|
+
try {
|
|
623
|
+
await dataSource.update(resource, cachedRowId, { value: items, updated_at: now });
|
|
624
|
+
return;
|
|
625
|
+
} catch (updateError) {
|
|
626
|
+
cachedRowId = null;
|
|
627
|
+
onError("save", updateError);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
const existing = await findExisting();
|
|
631
|
+
if (existing && existing.id !== void 0 && existing.id !== null) {
|
|
632
|
+
cachedRowId = existing.id;
|
|
633
|
+
await dataSource.update(resource, existing.id, { value: items, updated_at: now });
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
try {
|
|
637
|
+
const created = await dataSource.create(resource, {
|
|
638
|
+
user_id: userId,
|
|
639
|
+
key,
|
|
640
|
+
value: items,
|
|
641
|
+
updated_at: now
|
|
642
|
+
});
|
|
643
|
+
const newId = created?.id;
|
|
644
|
+
if (newId !== void 0 && newId !== null) cachedRowId = newId;
|
|
645
|
+
} catch (createError) {
|
|
646
|
+
const recovered = await findExisting();
|
|
647
|
+
if (recovered && recovered.id !== void 0 && recovered.id !== null) {
|
|
648
|
+
cachedRowId = recovered.id;
|
|
649
|
+
await dataSource.update(resource, recovered.id, { value: items, updated_at: now });
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
throw createError;
|
|
653
|
+
}
|
|
654
|
+
};
|
|
618
655
|
return {
|
|
619
656
|
async load() {
|
|
620
657
|
try {
|
|
@@ -631,40 +668,11 @@ function createObjectStackUserStateAdapter(options) {
|
|
|
631
668
|
}
|
|
632
669
|
},
|
|
633
670
|
async save(items) {
|
|
634
|
-
|
|
635
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
636
|
-
if (cachedRowId !== null) {
|
|
637
|
-
try {
|
|
638
|
-
await dataSource.update(resource, cachedRowId, {
|
|
639
|
-
value: items,
|
|
640
|
-
updated_at: now
|
|
641
|
-
});
|
|
642
|
-
return;
|
|
643
|
-
} catch (updateError) {
|
|
644
|
-
cachedRowId = null;
|
|
645
|
-
onError("save", updateError);
|
|
646
|
-
}
|
|
647
|
-
}
|
|
648
|
-
const existing = await findExisting();
|
|
649
|
-
if (existing && existing.id !== void 0 && existing.id !== null) {
|
|
650
|
-
cachedRowId = existing.id;
|
|
651
|
-
await dataSource.update(resource, existing.id, {
|
|
652
|
-
value: items,
|
|
653
|
-
updated_at: now
|
|
654
|
-
});
|
|
655
|
-
return;
|
|
656
|
-
}
|
|
657
|
-
const created = await dataSource.create(resource, {
|
|
658
|
-
user_id: userId,
|
|
659
|
-
key,
|
|
660
|
-
value: items,
|
|
661
|
-
updated_at: now
|
|
662
|
-
});
|
|
663
|
-
const newId = created?.id;
|
|
664
|
-
if (newId !== void 0 && newId !== null) cachedRowId = newId;
|
|
665
|
-
} catch (error) {
|
|
671
|
+
const run = saveChain.then(() => upsert(items)).catch((error) => {
|
|
666
672
|
onError("save", error);
|
|
667
|
-
}
|
|
673
|
+
});
|
|
674
|
+
saveChain = run;
|
|
675
|
+
return run;
|
|
668
676
|
}
|
|
669
677
|
};
|
|
670
678
|
}
|
|
@@ -2105,6 +2113,53 @@ var ObjectStackAdapter = class {
|
|
|
2105
2113
|
}
|
|
2106
2114
|
return body;
|
|
2107
2115
|
}
|
|
2116
|
+
/**
|
|
2117
|
+
* Synchronously download a server-streamed export (csv / json / xlsx).
|
|
2118
|
+
*
|
|
2119
|
+
* Hits `GET /api/v1/data/:object/export`, which streams matching rows in the
|
|
2120
|
+
* requested format, formats values for readability (lookup → name, select →
|
|
2121
|
+
* label, boolean → 是/否, dates formatted) and enforces permissions. The
|
|
2122
|
+
* filter / sort are translated the same way as `rawFindWithPopulate` so the
|
|
2123
|
+
* exported file mirrors the active list view. Returns the file as a Blob;
|
|
2124
|
+
* the caller triggers the browser download.
|
|
2125
|
+
*/
|
|
2126
|
+
async exportDownload(resource, request = {}) {
|
|
2127
|
+
const queryParams = new URLSearchParams();
|
|
2128
|
+
const format = request.format === "xlsx" ? "xlsx" : request.format === "json" ? "json" : "csv";
|
|
2129
|
+
queryParams.set("format", format);
|
|
2130
|
+
if (request.fields && request.fields.length > 0) {
|
|
2131
|
+
queryParams.set("fields", request.fields.join(","));
|
|
2132
|
+
}
|
|
2133
|
+
if (request.limit && request.limit > 0) {
|
|
2134
|
+
queryParams.set("limit", String(request.limit));
|
|
2135
|
+
}
|
|
2136
|
+
if (request.includeHeaders === false) {
|
|
2137
|
+
queryParams.set("header", "false");
|
|
2138
|
+
}
|
|
2139
|
+
if (request.sort && request.sort.length > 0) {
|
|
2140
|
+
const orderby = request.sort.filter((s) => s && s.field).map((s) => `${s.field}:${s.direction === "desc" ? "desc" : "asc"}`).join(",");
|
|
2141
|
+
if (orderby) queryParams.set("orderby", orderby);
|
|
2142
|
+
}
|
|
2143
|
+
if (request.filter !== void 0 && request.filter !== null) {
|
|
2144
|
+
const translated = translateFilterToAST(request.filter);
|
|
2145
|
+
if (translated !== void 0) {
|
|
2146
|
+
queryParams.set("filter", JSON.stringify(translated));
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
const baseUrl = this.baseUrl.replace(/\/$/, "");
|
|
2150
|
+
const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl);
|
|
2151
|
+
const dataPath = hasApiVersionSuffix ? "/data" : "/api/v1/data";
|
|
2152
|
+
const url = `${baseUrl}${dataPath}/${encodeURIComponent(resource)}/export?${queryParams.toString()}`;
|
|
2153
|
+
const headers = { ...this.getAuthHeaders() };
|
|
2154
|
+
const res = await this.fetchImpl(url, { method: "GET", headers, credentials: "include" });
|
|
2155
|
+
if (!res.ok) {
|
|
2156
|
+
const errorBody = await res.json().catch(() => ({ message: res.statusText }));
|
|
2157
|
+
const err = new Error(errorBody?.error?.message || errorBody?.message || res.statusText);
|
|
2158
|
+
err.status = res.status;
|
|
2159
|
+
throw err;
|
|
2160
|
+
}
|
|
2161
|
+
return await res.blob();
|
|
2162
|
+
}
|
|
2108
2163
|
/**
|
|
2109
2164
|
* Convert ObjectUI QueryParams to ObjectStack QueryOptions.
|
|
2110
2165
|
* Maps OData-style conventions to ObjectStack conventions.
|
package/dist/index.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ObjectStackClient } from '@objectstack/client';
|
|
2
|
-
import { DataSource, QueryParams, QueryResult, FileUploadResult } from '@object-ui/types';
|
|
2
|
+
import { DataSource, QueryParams, QueryResult, ExportDownloadRequest, FileUploadResult } from '@object-ui/types';
|
|
3
3
|
export { FileUploadResult } from '@object-ui/types';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -1443,6 +1443,17 @@ declare class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1443
1443
|
* to findData({ object, query: req.query }) which processes `populate`.
|
|
1444
1444
|
*/
|
|
1445
1445
|
private rawFindWithPopulate;
|
|
1446
|
+
/**
|
|
1447
|
+
* Synchronously download a server-streamed export (csv / json / xlsx).
|
|
1448
|
+
*
|
|
1449
|
+
* Hits `GET /api/v1/data/:object/export`, which streams matching rows in the
|
|
1450
|
+
* requested format, formats values for readability (lookup → name, select →
|
|
1451
|
+
* label, boolean → 是/否, dates formatted) and enforces permissions. The
|
|
1452
|
+
* filter / sort are translated the same way as `rawFindWithPopulate` so the
|
|
1453
|
+
* exported file mirrors the active list view. Returns the file as a Blob;
|
|
1454
|
+
* the caller triggers the browser download.
|
|
1455
|
+
*/
|
|
1456
|
+
exportDownload(resource: string, request?: ExportDownloadRequest): Promise<Blob>;
|
|
1446
1457
|
/**
|
|
1447
1458
|
* Convert ObjectUI QueryParams to ObjectStack QueryOptions.
|
|
1448
1459
|
* Maps OData-style conventions to ObjectStack conventions.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ObjectStackClient } from '@objectstack/client';
|
|
2
|
-
import { DataSource, QueryParams, QueryResult, FileUploadResult } from '@object-ui/types';
|
|
2
|
+
import { DataSource, QueryParams, QueryResult, ExportDownloadRequest, FileUploadResult } from '@object-ui/types';
|
|
3
3
|
export { FileUploadResult } from '@object-ui/types';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -1443,6 +1443,17 @@ declare class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1443
1443
|
* to findData({ object, query: req.query }) which processes `populate`.
|
|
1444
1444
|
*/
|
|
1445
1445
|
private rawFindWithPopulate;
|
|
1446
|
+
/**
|
|
1447
|
+
* Synchronously download a server-streamed export (csv / json / xlsx).
|
|
1448
|
+
*
|
|
1449
|
+
* Hits `GET /api/v1/data/:object/export`, which streams matching rows in the
|
|
1450
|
+
* requested format, formats values for readability (lookup → name, select →
|
|
1451
|
+
* label, boolean → 是/否, dates formatted) and enforces permissions. The
|
|
1452
|
+
* filter / sort are translated the same way as `rawFindWithPopulate` so the
|
|
1453
|
+
* exported file mirrors the active list view. Returns the file as a Blob;
|
|
1454
|
+
* the caller triggers the browser download.
|
|
1455
|
+
*/
|
|
1456
|
+
exportDownload(resource: string, request?: ExportDownloadRequest): Promise<Blob>;
|
|
1446
1457
|
/**
|
|
1447
1458
|
* Convert ObjectUI QueryParams to ObjectStack QueryOptions.
|
|
1448
1459
|
* Maps OData-style conventions to ObjectStack conventions.
|
package/dist/index.js
CHANGED
|
@@ -537,6 +537,7 @@ function createObjectStackUserStateAdapter(options) {
|
|
|
537
537
|
}
|
|
538
538
|
} = options;
|
|
539
539
|
let cachedRowId = null;
|
|
540
|
+
let saveChain = Promise.resolve();
|
|
540
541
|
const findExisting = async () => {
|
|
541
542
|
const params = {
|
|
542
543
|
$filter: {
|
|
@@ -567,6 +568,42 @@ function createObjectStackUserStateAdapter(options) {
|
|
|
567
568
|
}
|
|
568
569
|
return [];
|
|
569
570
|
};
|
|
571
|
+
const upsert = async (items) => {
|
|
572
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
573
|
+
if (cachedRowId !== null) {
|
|
574
|
+
try {
|
|
575
|
+
await dataSource.update(resource, cachedRowId, { value: items, updated_at: now });
|
|
576
|
+
return;
|
|
577
|
+
} catch (updateError) {
|
|
578
|
+
cachedRowId = null;
|
|
579
|
+
onError("save", updateError);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
const existing = await findExisting();
|
|
583
|
+
if (existing && existing.id !== void 0 && existing.id !== null) {
|
|
584
|
+
cachedRowId = existing.id;
|
|
585
|
+
await dataSource.update(resource, existing.id, { value: items, updated_at: now });
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
try {
|
|
589
|
+
const created = await dataSource.create(resource, {
|
|
590
|
+
user_id: userId,
|
|
591
|
+
key,
|
|
592
|
+
value: items,
|
|
593
|
+
updated_at: now
|
|
594
|
+
});
|
|
595
|
+
const newId = created?.id;
|
|
596
|
+
if (newId !== void 0 && newId !== null) cachedRowId = newId;
|
|
597
|
+
} catch (createError) {
|
|
598
|
+
const recovered = await findExisting();
|
|
599
|
+
if (recovered && recovered.id !== void 0 && recovered.id !== null) {
|
|
600
|
+
cachedRowId = recovered.id;
|
|
601
|
+
await dataSource.update(resource, recovered.id, { value: items, updated_at: now });
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
throw createError;
|
|
605
|
+
}
|
|
606
|
+
};
|
|
570
607
|
return {
|
|
571
608
|
async load() {
|
|
572
609
|
try {
|
|
@@ -583,40 +620,11 @@ function createObjectStackUserStateAdapter(options) {
|
|
|
583
620
|
}
|
|
584
621
|
},
|
|
585
622
|
async save(items) {
|
|
586
|
-
|
|
587
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
588
|
-
if (cachedRowId !== null) {
|
|
589
|
-
try {
|
|
590
|
-
await dataSource.update(resource, cachedRowId, {
|
|
591
|
-
value: items,
|
|
592
|
-
updated_at: now
|
|
593
|
-
});
|
|
594
|
-
return;
|
|
595
|
-
} catch (updateError) {
|
|
596
|
-
cachedRowId = null;
|
|
597
|
-
onError("save", updateError);
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
const existing = await findExisting();
|
|
601
|
-
if (existing && existing.id !== void 0 && existing.id !== null) {
|
|
602
|
-
cachedRowId = existing.id;
|
|
603
|
-
await dataSource.update(resource, existing.id, {
|
|
604
|
-
value: items,
|
|
605
|
-
updated_at: now
|
|
606
|
-
});
|
|
607
|
-
return;
|
|
608
|
-
}
|
|
609
|
-
const created = await dataSource.create(resource, {
|
|
610
|
-
user_id: userId,
|
|
611
|
-
key,
|
|
612
|
-
value: items,
|
|
613
|
-
updated_at: now
|
|
614
|
-
});
|
|
615
|
-
const newId = created?.id;
|
|
616
|
-
if (newId !== void 0 && newId !== null) cachedRowId = newId;
|
|
617
|
-
} catch (error) {
|
|
623
|
+
const run = saveChain.then(() => upsert(items)).catch((error) => {
|
|
618
624
|
onError("save", error);
|
|
619
|
-
}
|
|
625
|
+
});
|
|
626
|
+
saveChain = run;
|
|
627
|
+
return run;
|
|
620
628
|
}
|
|
621
629
|
};
|
|
622
630
|
}
|
|
@@ -2057,6 +2065,53 @@ var ObjectStackAdapter = class {
|
|
|
2057
2065
|
}
|
|
2058
2066
|
return body;
|
|
2059
2067
|
}
|
|
2068
|
+
/**
|
|
2069
|
+
* Synchronously download a server-streamed export (csv / json / xlsx).
|
|
2070
|
+
*
|
|
2071
|
+
* Hits `GET /api/v1/data/:object/export`, which streams matching rows in the
|
|
2072
|
+
* requested format, formats values for readability (lookup → name, select →
|
|
2073
|
+
* label, boolean → 是/否, dates formatted) and enforces permissions. The
|
|
2074
|
+
* filter / sort are translated the same way as `rawFindWithPopulate` so the
|
|
2075
|
+
* exported file mirrors the active list view. Returns the file as a Blob;
|
|
2076
|
+
* the caller triggers the browser download.
|
|
2077
|
+
*/
|
|
2078
|
+
async exportDownload(resource, request = {}) {
|
|
2079
|
+
const queryParams = new URLSearchParams();
|
|
2080
|
+
const format = request.format === "xlsx" ? "xlsx" : request.format === "json" ? "json" : "csv";
|
|
2081
|
+
queryParams.set("format", format);
|
|
2082
|
+
if (request.fields && request.fields.length > 0) {
|
|
2083
|
+
queryParams.set("fields", request.fields.join(","));
|
|
2084
|
+
}
|
|
2085
|
+
if (request.limit && request.limit > 0) {
|
|
2086
|
+
queryParams.set("limit", String(request.limit));
|
|
2087
|
+
}
|
|
2088
|
+
if (request.includeHeaders === false) {
|
|
2089
|
+
queryParams.set("header", "false");
|
|
2090
|
+
}
|
|
2091
|
+
if (request.sort && request.sort.length > 0) {
|
|
2092
|
+
const orderby = request.sort.filter((s) => s && s.field).map((s) => `${s.field}:${s.direction === "desc" ? "desc" : "asc"}`).join(",");
|
|
2093
|
+
if (orderby) queryParams.set("orderby", orderby);
|
|
2094
|
+
}
|
|
2095
|
+
if (request.filter !== void 0 && request.filter !== null) {
|
|
2096
|
+
const translated = translateFilterToAST(request.filter);
|
|
2097
|
+
if (translated !== void 0) {
|
|
2098
|
+
queryParams.set("filter", JSON.stringify(translated));
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
const baseUrl = this.baseUrl.replace(/\/$/, "");
|
|
2102
|
+
const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl);
|
|
2103
|
+
const dataPath = hasApiVersionSuffix ? "/data" : "/api/v1/data";
|
|
2104
|
+
const url = `${baseUrl}${dataPath}/${encodeURIComponent(resource)}/export?${queryParams.toString()}`;
|
|
2105
|
+
const headers = { ...this.getAuthHeaders() };
|
|
2106
|
+
const res = await this.fetchImpl(url, { method: "GET", headers, credentials: "include" });
|
|
2107
|
+
if (!res.ok) {
|
|
2108
|
+
const errorBody = await res.json().catch(() => ({ message: res.statusText }));
|
|
2109
|
+
const err = new Error(errorBody?.error?.message || errorBody?.message || res.statusText);
|
|
2110
|
+
err.status = res.status;
|
|
2111
|
+
throw err;
|
|
2112
|
+
}
|
|
2113
|
+
return await res.blob();
|
|
2114
|
+
}
|
|
2060
2115
|
/**
|
|
2061
2116
|
* Convert ObjectUI QueryParams to ObjectStack QueryOptions.
|
|
2062
2117
|
* Maps OData-style conventions to ObjectStack conventions.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/data-objectstack",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.3.0",
|
|
4
4
|
"description": "ObjectStack Data Adapter for Object UI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -22,9 +22,9 @@
|
|
|
22
22
|
"LICENSE"
|
|
23
23
|
],
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@objectstack/client": "^11.
|
|
26
|
-
"@object-ui/core": "11.
|
|
27
|
-
"@object-ui/types": "11.
|
|
25
|
+
"@objectstack/client": "^11.2.0",
|
|
26
|
+
"@object-ui/core": "11.3.0",
|
|
27
|
+
"@object-ui/types": "11.3.0"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"tsup": "^8.5.1",
|
|
@@ -0,0 +1,93 @@
|
|
|
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, vi } from 'vitest';
|
|
10
|
+
import { ObjectStackAdapter } from './index';
|
|
11
|
+
|
|
12
|
+
/** Build an adapter whose fetch is a spy returning the given Response. */
|
|
13
|
+
function makeDS(fetchImpl: any) {
|
|
14
|
+
const ds: any = new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: fetchImpl });
|
|
15
|
+
ds.connected = true;
|
|
16
|
+
ds.connectionState = 'connected';
|
|
17
|
+
return ds;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function csvResponse(body = 'ID,Name\n1,Acme') {
|
|
21
|
+
return new Response(body, { status: 200, headers: { 'Content-Type': 'text/csv' } });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('ObjectStackAdapter.exportDownload', () => {
|
|
25
|
+
it('GETs the /export route with format, fields, orderby, header and limit', async () => {
|
|
26
|
+
const fetchImpl = vi.fn(async () => csvResponse());
|
|
27
|
+
const ds = makeDS(fetchImpl);
|
|
28
|
+
|
|
29
|
+
const blob = await ds.exportDownload('task', {
|
|
30
|
+
format: 'xlsx',
|
|
31
|
+
fields: ['title', 'owner'],
|
|
32
|
+
sort: [{ field: 'title', direction: 'desc' }, { field: 'owner' }],
|
|
33
|
+
includeHeaders: false,
|
|
34
|
+
limit: 5000,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
expect(blob).toBeInstanceOf(Blob);
|
|
38
|
+
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
39
|
+
const [url, init] = fetchImpl.mock.calls[0];
|
|
40
|
+
const parsed = new URL(url);
|
|
41
|
+
expect(parsed.pathname).toBe('/api/v1/data/task/export');
|
|
42
|
+
expect(parsed.searchParams.get('format')).toBe('xlsx');
|
|
43
|
+
expect(parsed.searchParams.get('fields')).toBe('title,owner');
|
|
44
|
+
// direction defaults to asc when omitted.
|
|
45
|
+
expect(parsed.searchParams.get('orderby')).toBe('title:desc,owner:asc');
|
|
46
|
+
expect(parsed.searchParams.get('header')).toBe('false');
|
|
47
|
+
expect(parsed.searchParams.get('limit')).toBe('5000');
|
|
48
|
+
expect(init.method).toBe('GET');
|
|
49
|
+
expect(init.credentials).toBe('include');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('defaults to csv and omits optional params when not provided', async () => {
|
|
53
|
+
const fetchImpl = vi.fn(async () => csvResponse());
|
|
54
|
+
const ds = makeDS(fetchImpl);
|
|
55
|
+
|
|
56
|
+
await ds.exportDownload('task', {});
|
|
57
|
+
|
|
58
|
+
const [url] = fetchImpl.mock.calls[0];
|
|
59
|
+
const parsed = new URL(url);
|
|
60
|
+
expect(parsed.searchParams.get('format')).toBe('csv');
|
|
61
|
+
expect(parsed.searchParams.get('fields')).toBeNull();
|
|
62
|
+
expect(parsed.searchParams.get('orderby')).toBeNull();
|
|
63
|
+
expect(parsed.searchParams.get('header')).toBeNull();
|
|
64
|
+
expect(parsed.searchParams.get('limit')).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('serializes the filter to a JSON AST query param', async () => {
|
|
68
|
+
const fetchImpl = vi.fn(async () => csvResponse());
|
|
69
|
+
const ds = makeDS(fetchImpl);
|
|
70
|
+
|
|
71
|
+
await ds.exportDownload('task', { filter: [['status', '=', 'open']] });
|
|
72
|
+
|
|
73
|
+
const [url] = fetchImpl.mock.calls[0];
|
|
74
|
+
const raw = new URL(url).searchParams.get('filter');
|
|
75
|
+
expect(raw).toBeTruthy();
|
|
76
|
+
expect(JSON.parse(raw!)).toEqual([['status', '=', 'open']]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('throws an error carrying the server message and status on failure', async () => {
|
|
80
|
+
const fetchImpl = vi.fn(async () =>
|
|
81
|
+
new Response(JSON.stringify({ error: { message: 'Permission denied' } }), {
|
|
82
|
+
status: 403,
|
|
83
|
+
headers: { 'Content-Type': 'application/json' },
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
const ds = makeDS(fetchImpl);
|
|
87
|
+
|
|
88
|
+
await expect(ds.exportDownload('task', { format: 'csv' })).rejects.toMatchObject({
|
|
89
|
+
message: 'Permission denied',
|
|
90
|
+
status: 403,
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -74,4 +74,40 @@ describe('ObjectStackAdapter.getObjectSchema', () => {
|
|
|
74
74
|
expect(schema.name).toBe('account');
|
|
75
75
|
expect(Object.keys(schema.fields)).toEqual(['x']);
|
|
76
76
|
});
|
|
77
|
+
|
|
78
|
+
it('preserves `validations` (incl. state_machine transitions) — the seam the inline editor depends on', async () => {
|
|
79
|
+
// Inert-metadata regression guard. The inline select editor filters its
|
|
80
|
+
// options by the object's `state_machine` validation (objectui#2110). That
|
|
81
|
+
// feature only works because `getObjectSchema` passes the served
|
|
82
|
+
// `validations` through untouched — the server enforces the same rule. If a
|
|
83
|
+
// future change here started stripping or reshaping top-level metadata, the
|
|
84
|
+
// filter would silently no-op (valid-but-inert) with no error. Pin the
|
|
85
|
+
// pass-through so the data path can't go dark unnoticed.
|
|
86
|
+
const { fetchImpl } = makeFetch({
|
|
87
|
+
name: 'showcase_task',
|
|
88
|
+
fields: { status: { type: 'select', options: [{ value: 'done', label: 'Done' }] } },
|
|
89
|
+
validations: [
|
|
90
|
+
{
|
|
91
|
+
type: 'state_machine',
|
|
92
|
+
field: 'status',
|
|
93
|
+
transitions: { in_review: ['done', 'in_progress'], done: ['in_progress'] },
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
});
|
|
97
|
+
const adapter = new ObjectStackAdapter({
|
|
98
|
+
baseUrl: 'http://localhost:3000',
|
|
99
|
+
autoReconnect: false,
|
|
100
|
+
fetch: fetchImpl as any,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const schema: any = await adapter.getObjectSchema('showcase_task');
|
|
104
|
+
|
|
105
|
+
const sm = (schema.validations ?? []).find((v: any) => v?.type === 'state_machine');
|
|
106
|
+
expect(sm).toBeTruthy();
|
|
107
|
+
expect(sm.field).toBe('status');
|
|
108
|
+
// The exact map the consumer filters on — `done` only transitions to
|
|
109
|
+
// `in_progress` (so the editor must not offer `in_review`).
|
|
110
|
+
expect(sm.transitions.done).toEqual(['in_progress']);
|
|
111
|
+
expect(sm.transitions.in_review).toEqual(['done', 'in_progress']);
|
|
112
|
+
});
|
|
77
113
|
});
|
package/src/index.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { ObjectStackClient, type QueryOptions as ObjectStackQueryOptions } from '@objectstack/client';
|
|
10
|
-
import type { DataSource, QueryParams, QueryResult, FileUploadResult } from '@object-ui/types';
|
|
10
|
+
import type { DataSource, QueryParams, QueryResult, FileUploadResult, ExportDownloadRequest } from '@object-ui/types';
|
|
11
11
|
import { convertFiltersToAST } from '@object-ui/core';
|
|
12
12
|
import { MetadataCache } from './cache/MetadataCache';
|
|
13
13
|
import {
|
|
@@ -1182,6 +1182,66 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1182
1182
|
return body;
|
|
1183
1183
|
}
|
|
1184
1184
|
|
|
1185
|
+
/**
|
|
1186
|
+
* Synchronously download a server-streamed export (csv / json / xlsx).
|
|
1187
|
+
*
|
|
1188
|
+
* Hits `GET /api/v1/data/:object/export`, which streams matching rows in the
|
|
1189
|
+
* requested format, formats values for readability (lookup → name, select →
|
|
1190
|
+
* label, boolean → 是/否, dates formatted) and enforces permissions. The
|
|
1191
|
+
* filter / sort are translated the same way as `rawFindWithPopulate` so the
|
|
1192
|
+
* exported file mirrors the active list view. Returns the file as a Blob;
|
|
1193
|
+
* the caller triggers the browser download.
|
|
1194
|
+
*/
|
|
1195
|
+
async exportDownload(resource: string, request: ExportDownloadRequest = {}): Promise<Blob> {
|
|
1196
|
+
const queryParams = new URLSearchParams();
|
|
1197
|
+
|
|
1198
|
+
const format = request.format === 'xlsx' ? 'xlsx' : request.format === 'json' ? 'json' : 'csv';
|
|
1199
|
+
queryParams.set('format', format);
|
|
1200
|
+
|
|
1201
|
+
if (request.fields && request.fields.length > 0) {
|
|
1202
|
+
queryParams.set('fields', request.fields.join(','));
|
|
1203
|
+
}
|
|
1204
|
+
if (request.limit && request.limit > 0) {
|
|
1205
|
+
queryParams.set('limit', String(request.limit));
|
|
1206
|
+
}
|
|
1207
|
+
if (request.includeHeaders === false) {
|
|
1208
|
+
queryParams.set('header', 'false');
|
|
1209
|
+
}
|
|
1210
|
+
// Sort → server `orderby` shorthand: "field:dir,field2:dir".
|
|
1211
|
+
if (request.sort && request.sort.length > 0) {
|
|
1212
|
+
const orderby = request.sort
|
|
1213
|
+
.filter(s => s && s.field)
|
|
1214
|
+
.map(s => `${s.field}:${s.direction === 'desc' ? 'desc' : 'asc'}`)
|
|
1215
|
+
.join(',');
|
|
1216
|
+
if (orderby) queryParams.set('orderby', orderby);
|
|
1217
|
+
}
|
|
1218
|
+
// Filter → AST tuples, same translation the list GET path uses.
|
|
1219
|
+
if (request.filter !== undefined && request.filter !== null) {
|
|
1220
|
+
const translated = translateFilterToAST(request.filter);
|
|
1221
|
+
if (translated !== undefined) {
|
|
1222
|
+
queryParams.set('filter', JSON.stringify(translated));
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
const baseUrl = this.baseUrl.replace(/\/$/, '');
|
|
1227
|
+
// Avoid doubling /api/v1 if baseUrl already includes the version suffix.
|
|
1228
|
+
const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl);
|
|
1229
|
+
const dataPath = hasApiVersionSuffix ? '/data' : '/api/v1/data';
|
|
1230
|
+
const url = `${baseUrl}${dataPath}/${encodeURIComponent(resource)}/export?${queryParams.toString()}`;
|
|
1231
|
+
|
|
1232
|
+
const headers: Record<string, string> = { ...this.getAuthHeaders() };
|
|
1233
|
+
// `credentials: 'include'` carries the session cookie for the browser
|
|
1234
|
+
// console (which authenticates by cookie, not a bearer token).
|
|
1235
|
+
const res = await this.fetchImpl(url, { method: 'GET', headers, credentials: 'include' });
|
|
1236
|
+
if (!res.ok) {
|
|
1237
|
+
const errorBody = await res.json().catch(() => ({ message: res.statusText }));
|
|
1238
|
+
const err = new Error(errorBody?.error?.message || errorBody?.message || res.statusText) as any;
|
|
1239
|
+
err.status = res.status;
|
|
1240
|
+
throw err;
|
|
1241
|
+
}
|
|
1242
|
+
return await res.blob();
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1185
1245
|
/**
|
|
1186
1246
|
* Convert ObjectUI QueryParams to ObjectStack QueryOptions.
|
|
1187
1247
|
* Maps OData-style conventions to ObjectStack conventions.
|
package/src/userState.test.ts
CHANGED
|
@@ -221,6 +221,59 @@ describe('createObjectStackUserStateAdapter', () => {
|
|
|
221
221
|
}));
|
|
222
222
|
});
|
|
223
223
|
|
|
224
|
+
it('recovers from a UNIQUE(user_id, key) insert failure by updating in place', async () => {
|
|
225
|
+
// First findExisting() (no cached id) misses; create() loses the race and
|
|
226
|
+
// throws; the recovery findExisting() now sees the row → update.
|
|
227
|
+
const ds = mockDataSource({
|
|
228
|
+
find: vi.fn()
|
|
229
|
+
.mockResolvedValueOnce({ data: [] })
|
|
230
|
+
.mockResolvedValueOnce({ data: [{ id: 'row-7', user_id: 'u', key: 'k', value: [] }] }) as any,
|
|
231
|
+
create: vi.fn().mockRejectedValue(new Error('UNIQUE constraint failed')) as any,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const adapter = createObjectStackUserStateAdapter({
|
|
235
|
+
dataSource: ds,
|
|
236
|
+
userId: 'u',
|
|
237
|
+
key: 'k',
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
await adapter.save([{ id: 'q' } as any]);
|
|
241
|
+
|
|
242
|
+
expect(ds.create).toHaveBeenCalledTimes(1);
|
|
243
|
+
expect(ds.update).toHaveBeenCalledWith('sys_user_preference', 'row-7', {
|
|
244
|
+
value: [{ id: 'q' }],
|
|
245
|
+
updated_at: expect.any(String),
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('serializes concurrent saves so only one insert happens', async () => {
|
|
250
|
+
// Both saves start before either resolves. Without serialization both
|
|
251
|
+
// would findExisting()→[]→create() and the second would trip the
|
|
252
|
+
// UNIQUE constraint. With the save chain, the second waits, sees the
|
|
253
|
+
// cached row id from the first, and updates.
|
|
254
|
+
const ds = mockDataSource({
|
|
255
|
+
find: vi.fn().mockResolvedValue({ data: [] }) as any,
|
|
256
|
+
create: vi.fn().mockResolvedValue({ id: 'row-1' }) as any,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const adapter = createObjectStackUserStateAdapter({
|
|
260
|
+
dataSource: ds,
|
|
261
|
+
userId: 'u',
|
|
262
|
+
key: 'ui.recent',
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
await Promise.all([
|
|
266
|
+
adapter.save([{ id: 'a' } as any]),
|
|
267
|
+
adapter.save([{ id: 'a' }, { id: 'b' }] as any),
|
|
268
|
+
]);
|
|
269
|
+
|
|
270
|
+
expect(ds.create).toHaveBeenCalledTimes(1);
|
|
271
|
+
expect(ds.update).toHaveBeenCalledWith('sys_user_preference', 'row-1', {
|
|
272
|
+
value: [{ id: 'a' }, { id: 'b' }],
|
|
273
|
+
updated_at: expect.any(String),
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
224
277
|
it('swallows errors when save() throws', async () => {
|
|
225
278
|
const onError = vi.fn();
|
|
226
279
|
const ds = mockDataSource({
|
package/src/userState.ts
CHANGED
|
@@ -104,6 +104,14 @@ export function createObjectStackUserStateAdapter<T = unknown>(
|
|
|
104
104
|
// without re-querying. Reset on every successful load.
|
|
105
105
|
let cachedRowId: string | number | null = null;
|
|
106
106
|
|
|
107
|
+
// Serializes overlapping save() calls. A fresh adapter is created whenever
|
|
108
|
+
// the data source / user changes (see UserStateBridge), so its cachedRowId
|
|
109
|
+
// starts null; rapid navigations then fire debounced flushes that can race —
|
|
110
|
+
// two saves each findExisting()→null→create() and the second trips the
|
|
111
|
+
// UNIQUE(user_id, key) constraint. Chaining writes means the second save
|
|
112
|
+
// sees the cachedRowId the first one set and updates instead of inserting.
|
|
113
|
+
let saveChain: Promise<void> = Promise.resolve();
|
|
114
|
+
|
|
107
115
|
const findExisting = async (): Promise<UserPreferenceRecord | null> => {
|
|
108
116
|
// NOTE: `QueryParams` uses OData-style `$`-prefixed keys. Using the bare
|
|
109
117
|
// names (`filter`, `limit`) silently drops the predicate at the
|
|
@@ -146,6 +154,55 @@ export function createObjectStackUserStateAdapter<T = unknown>(
|
|
|
146
154
|
return [];
|
|
147
155
|
};
|
|
148
156
|
|
|
157
|
+
// Upsert by (user_id, key): update in place when the row is known/found,
|
|
158
|
+
// otherwise insert — and if the insert loses a UNIQUE(user_id, key) race
|
|
159
|
+
// (a concurrent writer created the row, or the backend dropped our find
|
|
160
|
+
// predicate), recover by re-finding and updating rather than surfacing the
|
|
161
|
+
// failed insert.
|
|
162
|
+
const upsert = async (items: T[]): Promise<void> => {
|
|
163
|
+
const now = new Date().toISOString();
|
|
164
|
+
|
|
165
|
+
// Fast path: we already know the row id from a previous load/save.
|
|
166
|
+
if (cachedRowId !== null) {
|
|
167
|
+
try {
|
|
168
|
+
await dataSource.update(resource, cachedRowId, { value: items, updated_at: now });
|
|
169
|
+
return;
|
|
170
|
+
} catch (updateError) {
|
|
171
|
+
// Row may have been deleted server-side — fall through to find/insert.
|
|
172
|
+
cachedRowId = null;
|
|
173
|
+
onError('save', updateError);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const existing = await findExisting();
|
|
178
|
+
if (existing && existing.id !== undefined && existing.id !== null) {
|
|
179
|
+
cachedRowId = existing.id;
|
|
180
|
+
await dataSource.update(resource, existing.id, { value: items, updated_at: now });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const created = await dataSource.create(resource, {
|
|
186
|
+
user_id: userId,
|
|
187
|
+
key,
|
|
188
|
+
value: items,
|
|
189
|
+
updated_at: now,
|
|
190
|
+
});
|
|
191
|
+
const newId = (created as UserPreferenceRecord | undefined)?.id;
|
|
192
|
+
if (newId !== undefined && newId !== null) cachedRowId = newId;
|
|
193
|
+
} catch (createError) {
|
|
194
|
+
// The row already exists (UNIQUE(user_id, key) violation) — re-find and
|
|
195
|
+
// update in place. This turns the insert into a real upsert.
|
|
196
|
+
const recovered = await findExisting();
|
|
197
|
+
if (recovered && recovered.id !== undefined && recovered.id !== null) {
|
|
198
|
+
cachedRowId = recovered.id;
|
|
199
|
+
await dataSource.update(resource, recovered.id, { value: items, updated_at: now });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
throw createError;
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
149
206
|
return {
|
|
150
207
|
async load(): Promise<T[]> {
|
|
151
208
|
try {
|
|
@@ -163,45 +220,15 @@ export function createObjectStackUserStateAdapter<T = unknown>(
|
|
|
163
220
|
},
|
|
164
221
|
|
|
165
222
|
async save(items: T[]): Promise<void> {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
try {
|
|
171
|
-
await dataSource.update(resource, cachedRowId, {
|
|
172
|
-
value: items,
|
|
173
|
-
updated_at: now,
|
|
174
|
-
});
|
|
175
|
-
return;
|
|
176
|
-
} catch (updateError) {
|
|
177
|
-
// Row may have been deleted server-side — fall through to insert.
|
|
178
|
-
cachedRowId = null;
|
|
179
|
-
onError('save', updateError);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
const existing = await findExisting();
|
|
184
|
-
if (existing && existing.id !== undefined && existing.id !== null) {
|
|
185
|
-
cachedRowId = existing.id;
|
|
186
|
-
await dataSource.update(resource, existing.id, {
|
|
187
|
-
value: items,
|
|
188
|
-
updated_at: now,
|
|
189
|
-
});
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
const created = await dataSource.create(resource, {
|
|
194
|
-
user_id: userId,
|
|
195
|
-
key,
|
|
196
|
-
value: items,
|
|
197
|
-
updated_at: now,
|
|
198
|
-
});
|
|
199
|
-
const newId = (created as UserPreferenceRecord | undefined)?.id;
|
|
200
|
-
if (newId !== undefined && newId !== null) cachedRowId = newId;
|
|
201
|
-
} catch (error) {
|
|
223
|
+
// Chain onto any in-flight save so concurrent flushes upsert serially
|
|
224
|
+
// rather than racing into two inserts (see `saveChain` above). Each link
|
|
225
|
+
// catches its own errors so one failure never poisons the chain.
|
|
226
|
+
const run = saveChain.then(() => upsert(items)).catch(error => {
|
|
202
227
|
onError('save', error);
|
|
203
228
|
// Swallow — provider falls back to localStorage as source of truth.
|
|
204
|
-
}
|
|
229
|
+
});
|
|
230
|
+
saveChain = run;
|
|
231
|
+
return run;
|
|
205
232
|
},
|
|
206
233
|
};
|
|
207
234
|
}
|