@object-ui/data-objectstack 13.2.0 → 14.1.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 +55 -0
- package/dist/index.cjs +67 -2
- package/dist/index.d.cts +11 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +67 -2
- package/package.json +4 -4
- package/src/getObjectSchema.test.ts +32 -0
- package/src/index.ts +87 -4
- package/src/onMutation.test.ts +172 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,60 @@
|
|
|
1
1
|
# @object-ui/data-objectstack
|
|
2
2
|
|
|
3
|
+
## 14.1.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [0890fa7]
|
|
8
|
+
- Updated dependencies [2ded18c]
|
|
9
|
+
- Updated dependencies [e628d1f]
|
|
10
|
+
- Updated dependencies [5523fc4]
|
|
11
|
+
- Updated dependencies [887062c]
|
|
12
|
+
- Updated dependencies [9e2d58f]
|
|
13
|
+
- Updated dependencies [dea65f7]
|
|
14
|
+
- Updated dependencies [d5b1bc0]
|
|
15
|
+
- Updated dependencies [f0f10f5]
|
|
16
|
+
- @object-ui/core@14.1.0
|
|
17
|
+
- @object-ui/types@14.1.0
|
|
18
|
+
|
|
19
|
+
## 14.0.0
|
|
20
|
+
|
|
21
|
+
### Minor Changes
|
|
22
|
+
|
|
23
|
+
- 6a74160: Sharing-rule form: pick, don't type. Three new widget-hint field components make
|
|
24
|
+
the generic object form render pickers where an admin previously had to type
|
|
25
|
+
machine data (driven by the framework `widget` hints on `sys_sharing_rule`;
|
|
26
|
+
generalizes the `capability-multiselect` pattern). All degrade to the underlying
|
|
27
|
+
`type` renderer when a widget is unregistered.
|
|
28
|
+
|
|
29
|
+
- **`object-ref`** — choose a registered object by name (searchable `Combobox`),
|
|
30
|
+
backed by the new `DataSource.getObjects()` (`ObjectStackAdapter` lists code-
|
|
31
|
+
and DB-defined objects via `/api/v1/meta/object`), falling back to a
|
|
32
|
+
`sys_metadata` query. Stores the object's `name`.
|
|
33
|
+
- **`filter-condition`** — a visual criteria builder (`FilterBuilder`) scoped to
|
|
34
|
+
the fields of the object chosen in a sibling field (via `getObjectSchema`),
|
|
35
|
+
round-tripping the stored **MongoDB-style** FilterCondition JSON. Criteria the
|
|
36
|
+
builder can't represent (or invalid JSON) fall back to a raw-JSON editor, with
|
|
37
|
+
an always-available "Edit as JSON" toggle — nothing is hidden or lost.
|
|
38
|
+
- **`recipient-picker`** — a record picker whose target object follows a sibling
|
|
39
|
+
`recipient_type` (`user`→sys_user, `team`→sys_team, `business_unit`/
|
|
40
|
+
`unit_and_subordinates`→sys_business_unit, `position`→sys_position), storing the
|
|
41
|
+
value the evaluator matches on (a record id, or the position **name**). Resets
|
|
42
|
+
the stored id when the type changes.
|
|
43
|
+
|
|
44
|
+
Wiring: the three keys join `DATA_SOURCE_FIELD_TYPES` (form.tsx) so the form
|
|
45
|
+
threads `dataSource` + `dependentValues` to them, and `INLINE_EXCLUDED_FIELD_TYPES`
|
|
46
|
+
(they're authored in the record form, not a grid cell). `DataSource.getObjects()`
|
|
47
|
+
is optional on the interface; the ObjectStack adapter implements it.
|
|
48
|
+
|
|
49
|
+
### Patch Changes
|
|
50
|
+
|
|
51
|
+
- Updated dependencies [443360a]
|
|
52
|
+
- Updated dependencies [86c69c3]
|
|
53
|
+
- Updated dependencies [05e56ca]
|
|
54
|
+
- Updated dependencies [6a74160]
|
|
55
|
+
- @object-ui/core@14.0.0
|
|
56
|
+
- @object-ui/types@14.0.0
|
|
57
|
+
|
|
3
58
|
## 13.2.0
|
|
4
59
|
|
|
5
60
|
### Patch Changes
|
package/dist/index.cjs
CHANGED
|
@@ -119,6 +119,24 @@ function convertFiltersToAST(filter) {
|
|
|
119
119
|
return ["and", ...conditions];
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
// ../core/src/utils/reference-keys.ts
|
|
123
|
+
function normalizeFieldReferenceKeys(fieldDef) {
|
|
124
|
+
if (!fieldDef || typeof fieldDef !== "object") return fieldDef;
|
|
125
|
+
const f = fieldDef;
|
|
126
|
+
const target = f.reference_to ?? f.reference ?? f.referenceTo;
|
|
127
|
+
if (target == null || target === "") return fieldDef;
|
|
128
|
+
if (f.reference_to === void 0) f.reference_to = target;
|
|
129
|
+
if (f.reference === void 0) f.reference = target;
|
|
130
|
+
return fieldDef;
|
|
131
|
+
}
|
|
132
|
+
function normalizeSchemaReferenceKeys(schema) {
|
|
133
|
+
const fields = schema && typeof schema === "object" ? schema.fields : null;
|
|
134
|
+
if (!fields || typeof fields !== "object") return schema;
|
|
135
|
+
const defs = Array.isArray(fields) ? fields : Object.values(fields);
|
|
136
|
+
for (const def of defs) normalizeFieldReferenceKeys(def);
|
|
137
|
+
return schema;
|
|
138
|
+
}
|
|
139
|
+
|
|
122
140
|
// src/cache/MetadataCache.ts
|
|
123
141
|
var MetadataCache = class {
|
|
124
142
|
/**
|
|
@@ -1922,7 +1940,7 @@ var ObjectStackAdapter = class {
|
|
|
1922
1940
|
*/
|
|
1923
1941
|
async batchTransaction(operations) {
|
|
1924
1942
|
const url = `${this.baseUrl}/api/v1/batch`;
|
|
1925
|
-
const response = await
|
|
1943
|
+
const response = await this.fetchImpl(url, {
|
|
1926
1944
|
method: "POST",
|
|
1927
1945
|
headers: { "Content-Type": "application/json", ...this.getAuthHeaders() },
|
|
1928
1946
|
// Send the session cookie too: in the browser console auth is cookie-based
|
|
@@ -1940,7 +1958,20 @@ var ObjectStackAdapter = class {
|
|
|
1940
1958
|
response.status
|
|
1941
1959
|
);
|
|
1942
1960
|
}
|
|
1943
|
-
|
|
1961
|
+
const payload = await response.json();
|
|
1962
|
+
const results = Array.isArray(payload?.results) ? payload.results : [];
|
|
1963
|
+
operations.forEach((op, i) => {
|
|
1964
|
+
const action = op.action ?? "create";
|
|
1965
|
+
const echo = results[i];
|
|
1966
|
+
if (action === "create") {
|
|
1967
|
+
this.emitMutation({ type: "create", resource: op.object, record: echo });
|
|
1968
|
+
} else if (action === "update") {
|
|
1969
|
+
this.emitMutation({ type: "update", resource: op.object, id: op.id ?? echo?.id ?? echo?._id, record: echo });
|
|
1970
|
+
} else if (action === "delete") {
|
|
1971
|
+
this.emitMutation({ type: "delete", resource: op.object, id: op.id });
|
|
1972
|
+
}
|
|
1973
|
+
});
|
|
1974
|
+
return payload;
|
|
1944
1975
|
}
|
|
1945
1976
|
async bulk(resource, operation, data) {
|
|
1946
1977
|
await this.connect();
|
|
@@ -1967,6 +1998,7 @@ var ObjectStackAdapter = class {
|
|
|
1967
1998
|
completed = created.length;
|
|
1968
1999
|
failed = total - completed;
|
|
1969
2000
|
emitProgress();
|
|
2001
|
+
if (completed > 0) this.emitMutation({ type: "create", resource });
|
|
1970
2002
|
return created;
|
|
1971
2003
|
}
|
|
1972
2004
|
case "delete": {
|
|
@@ -1985,6 +2017,7 @@ var ObjectStackAdapter = class {
|
|
|
1985
2017
|
completed = ids.length;
|
|
1986
2018
|
failed = total - completed;
|
|
1987
2019
|
emitProgress();
|
|
2020
|
+
if (completed > 0) this.emitMutation({ type: "delete", resource });
|
|
1988
2021
|
return [];
|
|
1989
2022
|
}
|
|
1990
2023
|
case "update": {
|
|
@@ -1996,6 +2029,7 @@ var ObjectStackAdapter = class {
|
|
|
1996
2029
|
completed = updated.length;
|
|
1997
2030
|
failed = total - completed;
|
|
1998
2031
|
emitProgress();
|
|
2032
|
+
if (completed > 0) this.emitMutation({ type: "update", resource });
|
|
1999
2033
|
return updated;
|
|
2000
2034
|
} catch {
|
|
2001
2035
|
}
|
|
@@ -2023,6 +2057,7 @@ var ObjectStackAdapter = class {
|
|
|
2023
2057
|
emitProgress();
|
|
2024
2058
|
}
|
|
2025
2059
|
}
|
|
2060
|
+
if (completed > 0) this.emitMutation({ type: "update", resource });
|
|
2026
2061
|
if (errors.length > 0) {
|
|
2027
2062
|
throw new BulkOperationError(
|
|
2028
2063
|
"update",
|
|
@@ -2432,6 +2467,7 @@ var ObjectStackAdapter = class {
|
|
|
2432
2467
|
objectName,
|
|
2433
2468
|
() => this.fetchObjectSchemaFresh(objectName)
|
|
2434
2469
|
);
|
|
2470
|
+
normalizeSchemaReferenceKeys(schema);
|
|
2435
2471
|
this.applyFieldWidgetOverrides(objectName, schema);
|
|
2436
2472
|
return schema;
|
|
2437
2473
|
} catch (error) {
|
|
@@ -2525,6 +2561,35 @@ var ObjectStackAdapter = class {
|
|
|
2525
2561
|
const data = body && typeof body === "object" && "success" in body && "data" in body ? body.data : body;
|
|
2526
2562
|
return data && typeof data === "object" && "item" in data ? data.item : data;
|
|
2527
2563
|
}
|
|
2564
|
+
/**
|
|
2565
|
+
* List every registered object (code- and DB-defined) from the metadata
|
|
2566
|
+
* registry — `GET /api/v1/meta/object`. Returns lightweight `{ name, label }`
|
|
2567
|
+
* headers for object-picker widgets (e.g. the sharing-rule `object-ref`
|
|
2568
|
+
* field). The list endpoint is uncached server-side, so no cache-busting
|
|
2569
|
+
* dance is needed. Returns `[]` on any failure so callers degrade gracefully.
|
|
2570
|
+
*/
|
|
2571
|
+
async getObjects() {
|
|
2572
|
+
try {
|
|
2573
|
+
await this.connect();
|
|
2574
|
+
const baseUrl = (this.baseUrl || "").replace(/\/$/, "");
|
|
2575
|
+
const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl);
|
|
2576
|
+
const metaPath = hasApiVersionSuffix ? "/meta" : "/api/v1/meta";
|
|
2577
|
+
const url = `${baseUrl}${metaPath}/object`;
|
|
2578
|
+
const headers = { "Content-Type": "application/json" };
|
|
2579
|
+
if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
|
|
2580
|
+
const res = await this.fetchImpl(url, { method: "GET", headers });
|
|
2581
|
+
if (!res.ok) return [];
|
|
2582
|
+
const body = await res.json();
|
|
2583
|
+
const data = body && typeof body === "object" && "success" in body && "data" in body ? body.data : body;
|
|
2584
|
+
const items = Array.isArray(data) ? data : Array.isArray(data?.items) ? data.items : [];
|
|
2585
|
+
return items.map((it) => ({
|
|
2586
|
+
name: String(it?.name ?? ""),
|
|
2587
|
+
label: it?.label != null ? String(it.label) : void 0
|
|
2588
|
+
})).filter((it) => it.name);
|
|
2589
|
+
} catch {
|
|
2590
|
+
return [];
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2528
2593
|
/**
|
|
2529
2594
|
* Get access to the underlying ObjectStack client for advanced operations.
|
|
2530
2595
|
*/
|
package/dist/index.d.cts
CHANGED
|
@@ -1557,6 +1557,17 @@ declare class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1557
1557
|
* not expose the request cache mode.
|
|
1558
1558
|
*/
|
|
1559
1559
|
private fetchObjectSchemaFresh;
|
|
1560
|
+
/**
|
|
1561
|
+
* List every registered object (code- and DB-defined) from the metadata
|
|
1562
|
+
* registry — `GET /api/v1/meta/object`. Returns lightweight `{ name, label }`
|
|
1563
|
+
* headers for object-picker widgets (e.g. the sharing-rule `object-ref`
|
|
1564
|
+
* field). The list endpoint is uncached server-side, so no cache-busting
|
|
1565
|
+
* dance is needed. Returns `[]` on any failure so callers degrade gracefully.
|
|
1566
|
+
*/
|
|
1567
|
+
getObjects(): Promise<Array<{
|
|
1568
|
+
name: string;
|
|
1569
|
+
label?: string;
|
|
1570
|
+
}>>;
|
|
1560
1571
|
/**
|
|
1561
1572
|
* Get access to the underlying ObjectStack client for advanced operations.
|
|
1562
1573
|
*/
|
package/dist/index.d.ts
CHANGED
|
@@ -1557,6 +1557,17 @@ declare class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1557
1557
|
* not expose the request cache mode.
|
|
1558
1558
|
*/
|
|
1559
1559
|
private fetchObjectSchemaFresh;
|
|
1560
|
+
/**
|
|
1561
|
+
* List every registered object (code- and DB-defined) from the metadata
|
|
1562
|
+
* registry — `GET /api/v1/meta/object`. Returns lightweight `{ name, label }`
|
|
1563
|
+
* headers for object-picker widgets (e.g. the sharing-rule `object-ref`
|
|
1564
|
+
* field). The list endpoint is uncached server-side, so no cache-busting
|
|
1565
|
+
* dance is needed. Returns `[]` on any failure so callers degrade gracefully.
|
|
1566
|
+
*/
|
|
1567
|
+
getObjects(): Promise<Array<{
|
|
1568
|
+
name: string;
|
|
1569
|
+
label?: string;
|
|
1570
|
+
}>>;
|
|
1560
1571
|
/**
|
|
1561
1572
|
* Get access to the underlying ObjectStack client for advanced operations.
|
|
1562
1573
|
*/
|
package/dist/index.js
CHANGED
|
@@ -71,6 +71,24 @@ function convertFiltersToAST(filter) {
|
|
|
71
71
|
return ["and", ...conditions];
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
// ../core/src/utils/reference-keys.ts
|
|
75
|
+
function normalizeFieldReferenceKeys(fieldDef) {
|
|
76
|
+
if (!fieldDef || typeof fieldDef !== "object") return fieldDef;
|
|
77
|
+
const f = fieldDef;
|
|
78
|
+
const target = f.reference_to ?? f.reference ?? f.referenceTo;
|
|
79
|
+
if (target == null || target === "") return fieldDef;
|
|
80
|
+
if (f.reference_to === void 0) f.reference_to = target;
|
|
81
|
+
if (f.reference === void 0) f.reference = target;
|
|
82
|
+
return fieldDef;
|
|
83
|
+
}
|
|
84
|
+
function normalizeSchemaReferenceKeys(schema) {
|
|
85
|
+
const fields = schema && typeof schema === "object" ? schema.fields : null;
|
|
86
|
+
if (!fields || typeof fields !== "object") return schema;
|
|
87
|
+
const defs = Array.isArray(fields) ? fields : Object.values(fields);
|
|
88
|
+
for (const def of defs) normalizeFieldReferenceKeys(def);
|
|
89
|
+
return schema;
|
|
90
|
+
}
|
|
91
|
+
|
|
74
92
|
// src/cache/MetadataCache.ts
|
|
75
93
|
var MetadataCache = class {
|
|
76
94
|
/**
|
|
@@ -1874,7 +1892,7 @@ var ObjectStackAdapter = class {
|
|
|
1874
1892
|
*/
|
|
1875
1893
|
async batchTransaction(operations) {
|
|
1876
1894
|
const url = `${this.baseUrl}/api/v1/batch`;
|
|
1877
|
-
const response = await
|
|
1895
|
+
const response = await this.fetchImpl(url, {
|
|
1878
1896
|
method: "POST",
|
|
1879
1897
|
headers: { "Content-Type": "application/json", ...this.getAuthHeaders() },
|
|
1880
1898
|
// Send the session cookie too: in the browser console auth is cookie-based
|
|
@@ -1892,7 +1910,20 @@ var ObjectStackAdapter = class {
|
|
|
1892
1910
|
response.status
|
|
1893
1911
|
);
|
|
1894
1912
|
}
|
|
1895
|
-
|
|
1913
|
+
const payload = await response.json();
|
|
1914
|
+
const results = Array.isArray(payload?.results) ? payload.results : [];
|
|
1915
|
+
operations.forEach((op, i) => {
|
|
1916
|
+
const action = op.action ?? "create";
|
|
1917
|
+
const echo = results[i];
|
|
1918
|
+
if (action === "create") {
|
|
1919
|
+
this.emitMutation({ type: "create", resource: op.object, record: echo });
|
|
1920
|
+
} else if (action === "update") {
|
|
1921
|
+
this.emitMutation({ type: "update", resource: op.object, id: op.id ?? echo?.id ?? echo?._id, record: echo });
|
|
1922
|
+
} else if (action === "delete") {
|
|
1923
|
+
this.emitMutation({ type: "delete", resource: op.object, id: op.id });
|
|
1924
|
+
}
|
|
1925
|
+
});
|
|
1926
|
+
return payload;
|
|
1896
1927
|
}
|
|
1897
1928
|
async bulk(resource, operation, data) {
|
|
1898
1929
|
await this.connect();
|
|
@@ -1919,6 +1950,7 @@ var ObjectStackAdapter = class {
|
|
|
1919
1950
|
completed = created.length;
|
|
1920
1951
|
failed = total - completed;
|
|
1921
1952
|
emitProgress();
|
|
1953
|
+
if (completed > 0) this.emitMutation({ type: "create", resource });
|
|
1922
1954
|
return created;
|
|
1923
1955
|
}
|
|
1924
1956
|
case "delete": {
|
|
@@ -1937,6 +1969,7 @@ var ObjectStackAdapter = class {
|
|
|
1937
1969
|
completed = ids.length;
|
|
1938
1970
|
failed = total - completed;
|
|
1939
1971
|
emitProgress();
|
|
1972
|
+
if (completed > 0) this.emitMutation({ type: "delete", resource });
|
|
1940
1973
|
return [];
|
|
1941
1974
|
}
|
|
1942
1975
|
case "update": {
|
|
@@ -1948,6 +1981,7 @@ var ObjectStackAdapter = class {
|
|
|
1948
1981
|
completed = updated.length;
|
|
1949
1982
|
failed = total - completed;
|
|
1950
1983
|
emitProgress();
|
|
1984
|
+
if (completed > 0) this.emitMutation({ type: "update", resource });
|
|
1951
1985
|
return updated;
|
|
1952
1986
|
} catch {
|
|
1953
1987
|
}
|
|
@@ -1975,6 +2009,7 @@ var ObjectStackAdapter = class {
|
|
|
1975
2009
|
emitProgress();
|
|
1976
2010
|
}
|
|
1977
2011
|
}
|
|
2012
|
+
if (completed > 0) this.emitMutation({ type: "update", resource });
|
|
1978
2013
|
if (errors.length > 0) {
|
|
1979
2014
|
throw new BulkOperationError(
|
|
1980
2015
|
"update",
|
|
@@ -2384,6 +2419,7 @@ var ObjectStackAdapter = class {
|
|
|
2384
2419
|
objectName,
|
|
2385
2420
|
() => this.fetchObjectSchemaFresh(objectName)
|
|
2386
2421
|
);
|
|
2422
|
+
normalizeSchemaReferenceKeys(schema);
|
|
2387
2423
|
this.applyFieldWidgetOverrides(objectName, schema);
|
|
2388
2424
|
return schema;
|
|
2389
2425
|
} catch (error) {
|
|
@@ -2477,6 +2513,35 @@ var ObjectStackAdapter = class {
|
|
|
2477
2513
|
const data = body && typeof body === "object" && "success" in body && "data" in body ? body.data : body;
|
|
2478
2514
|
return data && typeof data === "object" && "item" in data ? data.item : data;
|
|
2479
2515
|
}
|
|
2516
|
+
/**
|
|
2517
|
+
* List every registered object (code- and DB-defined) from the metadata
|
|
2518
|
+
* registry — `GET /api/v1/meta/object`. Returns lightweight `{ name, label }`
|
|
2519
|
+
* headers for object-picker widgets (e.g. the sharing-rule `object-ref`
|
|
2520
|
+
* field). The list endpoint is uncached server-side, so no cache-busting
|
|
2521
|
+
* dance is needed. Returns `[]` on any failure so callers degrade gracefully.
|
|
2522
|
+
*/
|
|
2523
|
+
async getObjects() {
|
|
2524
|
+
try {
|
|
2525
|
+
await this.connect();
|
|
2526
|
+
const baseUrl = (this.baseUrl || "").replace(/\/$/, "");
|
|
2527
|
+
const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl);
|
|
2528
|
+
const metaPath = hasApiVersionSuffix ? "/meta" : "/api/v1/meta";
|
|
2529
|
+
const url = `${baseUrl}${metaPath}/object`;
|
|
2530
|
+
const headers = { "Content-Type": "application/json" };
|
|
2531
|
+
if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
|
|
2532
|
+
const res = await this.fetchImpl(url, { method: "GET", headers });
|
|
2533
|
+
if (!res.ok) return [];
|
|
2534
|
+
const body = await res.json();
|
|
2535
|
+
const data = body && typeof body === "object" && "success" in body && "data" in body ? body.data : body;
|
|
2536
|
+
const items = Array.isArray(data) ? data : Array.isArray(data?.items) ? data.items : [];
|
|
2537
|
+
return items.map((it) => ({
|
|
2538
|
+
name: String(it?.name ?? ""),
|
|
2539
|
+
label: it?.label != null ? String(it.label) : void 0
|
|
2540
|
+
})).filter((it) => it.name);
|
|
2541
|
+
} catch {
|
|
2542
|
+
return [];
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2480
2545
|
/**
|
|
2481
2546
|
* Get access to the underlying ObjectStack client for advanced operations.
|
|
2482
2547
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/data-objectstack",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "14.1.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": "^14.
|
|
26
|
-
"@object-ui/core": "
|
|
27
|
-
"@object-ui/types": "
|
|
25
|
+
"@objectstack/client": "^14.6.0",
|
|
26
|
+
"@object-ui/core": "14.1.0",
|
|
27
|
+
"@object-ui/types": "14.1.0"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"tsup": "^8.5.1",
|
|
@@ -75,6 +75,38 @@ describe('ObjectStackAdapter.getObjectSchema', () => {
|
|
|
75
75
|
expect(Object.keys(schema.fields)).toEqual(['x']);
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
+
it('canonicalizes the ObjectStack-convention `reference` key onto `reference_to` (and back)', async () => {
|
|
79
|
+
// The server names a relational field's target `reference`
|
|
80
|
+
// (showcase_project.account → { type: 'lookup', reference: 'showcase_account' }),
|
|
81
|
+
// while most consumers read `reference_to` (#2407 / PR #2587). getObjectSchema
|
|
82
|
+
// is the choke point every schema read goes through, so both keys must come
|
|
83
|
+
// back stamped — regardless of which convention the served schema used.
|
|
84
|
+
const { fetchImpl } = makeFetch({
|
|
85
|
+
name: 'showcase_project',
|
|
86
|
+
fields: {
|
|
87
|
+
name: { type: 'text' },
|
|
88
|
+
account: { type: 'lookup', reference: 'showcase_account' },
|
|
89
|
+
team_members: { type: 'user', reference: 'sys_user', multiple: true },
|
|
90
|
+
legacy: { type: 'master_detail', reference_to: 'showcase_order' },
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
const adapter = new ObjectStackAdapter({
|
|
94
|
+
baseUrl: 'http://localhost:3000',
|
|
95
|
+
autoReconnect: false,
|
|
96
|
+
fetch: fetchImpl as any,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const schema: any = await adapter.getObjectSchema('showcase_project');
|
|
100
|
+
|
|
101
|
+
expect(schema.fields.account.reference_to).toBe('showcase_account');
|
|
102
|
+
expect(schema.fields.team_members.reference_to).toBe('sys_user');
|
|
103
|
+
// Mirror direction: `.reference`-only readers (e.g. attachInlineSubforms)
|
|
104
|
+
// must also see the target on a reference_to-authored schema.
|
|
105
|
+
expect(schema.fields.legacy.reference).toBe('showcase_order');
|
|
106
|
+
// Non-relational fields stay untouched.
|
|
107
|
+
expect('reference_to' in schema.fields.name).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
78
110
|
it('preserves `validations` (incl. state_machine transitions) — the seam the inline editor depends on', async () => {
|
|
79
111
|
// Inert-metadata regression guard. The inline select editor filters its
|
|
80
112
|
// options by the object's `state_machine` validation (objectui#2110). That
|
package/src/index.ts
CHANGED
|
@@ -23,7 +23,7 @@ import type {
|
|
|
23
23
|
ImportJobUndoResult,
|
|
24
24
|
ListImportJobsOptions,
|
|
25
25
|
} from '@object-ui/types';
|
|
26
|
-
import { convertFiltersToAST } from '@object-ui/core';
|
|
26
|
+
import { convertFiltersToAST, normalizeSchemaReferenceKeys } from '@object-ui/core';
|
|
27
27
|
import { MetadataCache } from './cache/MetadataCache';
|
|
28
28
|
import {
|
|
29
29
|
ObjectStackError,
|
|
@@ -936,7 +936,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
936
936
|
operations: Array<{ object: string; action?: 'create' | 'update' | 'delete'; data?: any; id?: string }>,
|
|
937
937
|
): Promise<{ results: any[] }> {
|
|
938
938
|
const url = `${this.baseUrl}/api/v1/batch`;
|
|
939
|
-
const response = await
|
|
939
|
+
const response = await this.fetchImpl(url, {
|
|
940
940
|
method: 'POST',
|
|
941
941
|
headers: { 'Content-Type': 'application/json', ...this.getAuthHeaders() },
|
|
942
942
|
// Send the session cookie too: in the browser console auth is cookie-based
|
|
@@ -954,7 +954,27 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
954
954
|
response.status,
|
|
955
955
|
);
|
|
956
956
|
}
|
|
957
|
-
|
|
957
|
+
const payload: { results: any[] } = await response.json();
|
|
958
|
+
// The whole transaction committed — emit one MutationEvent per operation so
|
|
959
|
+
// the invalidation bus (#2269) sees writes that went through /batch exactly
|
|
960
|
+
// like single create/update/delete calls (master-detail ModalForm saves
|
|
961
|
+
// otherwise leave related lists and count badges stale, #2582). `results`
|
|
962
|
+
// is index-aligned with `operations`; creates take id/record from the
|
|
963
|
+
// server echo. A failed batch rolled back entirely and throws above, so
|
|
964
|
+
// nothing is emitted for it.
|
|
965
|
+
const results = Array.isArray(payload?.results) ? payload.results : [];
|
|
966
|
+
operations.forEach((op, i) => {
|
|
967
|
+
const action = op.action ?? 'create';
|
|
968
|
+
const echo = results[i];
|
|
969
|
+
if (action === 'create') {
|
|
970
|
+
this.emitMutation({ type: 'create', resource: op.object, record: echo });
|
|
971
|
+
} else if (action === 'update') {
|
|
972
|
+
this.emitMutation({ type: 'update', resource: op.object, id: op.id ?? echo?.id ?? echo?._id, record: echo });
|
|
973
|
+
} else if (action === 'delete') {
|
|
974
|
+
this.emitMutation({ type: 'delete', resource: op.object, id: op.id });
|
|
975
|
+
}
|
|
976
|
+
});
|
|
977
|
+
return payload;
|
|
958
978
|
}
|
|
959
979
|
|
|
960
980
|
async bulk(resource: string, operation: 'create' | 'update' | 'delete', data: Partial<T>[]): Promise<T[]> {
|
|
@@ -986,6 +1006,10 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
986
1006
|
completed = created.length;
|
|
987
1007
|
failed = total - completed;
|
|
988
1008
|
emitProgress();
|
|
1009
|
+
// One resource-level event for the whole batch (same contract as
|
|
1010
|
+
// bulkUpdate/bulkDelete) so bound views refresh after subform child
|
|
1011
|
+
// rows are created through this path (#2582).
|
|
1012
|
+
if (completed > 0) this.emitMutation({ type: 'create', resource });
|
|
989
1013
|
return created;
|
|
990
1014
|
}
|
|
991
1015
|
|
|
@@ -1010,6 +1034,8 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1010
1034
|
completed = ids.length;
|
|
1011
1035
|
failed = total - completed;
|
|
1012
1036
|
emitProgress();
|
|
1037
|
+
// One resource-level event per batch — see the create branch.
|
|
1038
|
+
if (completed > 0) this.emitMutation({ type: 'delete', resource });
|
|
1013
1039
|
return [] as T[];
|
|
1014
1040
|
}
|
|
1015
1041
|
|
|
@@ -1025,6 +1051,8 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1025
1051
|
completed = updated.length;
|
|
1026
1052
|
failed = total - completed;
|
|
1027
1053
|
emitProgress();
|
|
1054
|
+
// One resource-level event per batch — see the create branch.
|
|
1055
|
+
if (completed > 0) this.emitMutation({ type: 'update', resource });
|
|
1028
1056
|
return updated;
|
|
1029
1057
|
} catch {
|
|
1030
1058
|
// If updateMany is not supported, fall back to individual updates
|
|
@@ -1060,6 +1088,11 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1060
1088
|
}
|
|
1061
1089
|
}
|
|
1062
1090
|
|
|
1091
|
+
// Rows that DID persist must still reach subscribers, even when the
|
|
1092
|
+
// batch as a whole reports failure below (continueOnError semantics
|
|
1093
|
+
// — the successful writes are not rolled back).
|
|
1094
|
+
if (completed > 0) this.emitMutation({ type: 'update', resource });
|
|
1095
|
+
|
|
1063
1096
|
// If there were any errors, throw BulkOperationError
|
|
1064
1097
|
if (errors.length > 0) {
|
|
1065
1098
|
throw new BulkOperationError(
|
|
@@ -1070,7 +1103,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1070
1103
|
{ resource, totalRecords: data.length }
|
|
1071
1104
|
);
|
|
1072
1105
|
}
|
|
1073
|
-
|
|
1106
|
+
|
|
1074
1107
|
return results;
|
|
1075
1108
|
}
|
|
1076
1109
|
|
|
@@ -1609,6 +1642,13 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1609
1642
|
this.fetchObjectSchemaFresh(objectName),
|
|
1610
1643
|
);
|
|
1611
1644
|
|
|
1645
|
+
// Canonicalize the relational-target key: the server names it
|
|
1646
|
+
// `reference` (ObjectStack convention) while most consumers read
|
|
1647
|
+
// `reference_to` (#2407 / PR #2587). Stamping both here — the choke
|
|
1648
|
+
// point every schema read goes through — means no per-consumer
|
|
1649
|
+
// dual-key fallback can drift. Idempotent on the cached object.
|
|
1650
|
+
normalizeSchemaReferenceKeys(schema);
|
|
1651
|
+
|
|
1612
1652
|
// ADR-0056 P2 (epic #2398): stamp structured-widget hints onto specific
|
|
1613
1653
|
// platform fields. This is the single choke point both the record form
|
|
1614
1654
|
// (ObjectForm) and the detail view (DetailView/DetailSection) read the
|
|
@@ -1725,6 +1765,49 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1725
1765
|
return data && typeof data === 'object' && 'item' in data ? data.item : data;
|
|
1726
1766
|
}
|
|
1727
1767
|
|
|
1768
|
+
/**
|
|
1769
|
+
* List every registered object (code- and DB-defined) from the metadata
|
|
1770
|
+
* registry — `GET /api/v1/meta/object`. Returns lightweight `{ name, label }`
|
|
1771
|
+
* headers for object-picker widgets (e.g. the sharing-rule `object-ref`
|
|
1772
|
+
* field). The list endpoint is uncached server-side, so no cache-busting
|
|
1773
|
+
* dance is needed. Returns `[]` on any failure so callers degrade gracefully.
|
|
1774
|
+
*/
|
|
1775
|
+
async getObjects(): Promise<Array<{ name: string; label?: string }>> {
|
|
1776
|
+
try {
|
|
1777
|
+
await this.connect();
|
|
1778
|
+
const baseUrl = (this.baseUrl || '').replace(/\/$/, '');
|
|
1779
|
+
// Avoid doubling /api/v1 when baseUrl already carries the version suffix
|
|
1780
|
+
// (mirrors fetchObjectSchemaFresh).
|
|
1781
|
+
const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl);
|
|
1782
|
+
const metaPath = hasApiVersionSuffix ? '/meta' : '/api/v1/meta';
|
|
1783
|
+
const url = `${baseUrl}${metaPath}/object`;
|
|
1784
|
+
|
|
1785
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
1786
|
+
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
|
|
1787
|
+
|
|
1788
|
+
const res = await this.fetchImpl(url, { method: 'GET', headers });
|
|
1789
|
+
if (!res.ok) return [];
|
|
1790
|
+
const body: any = await res.json();
|
|
1791
|
+
// Unwrap the `{ success, data }` envelope, the `{ type, items }` list
|
|
1792
|
+
// shape, or a bare array.
|
|
1793
|
+
const data =
|
|
1794
|
+
body && typeof body === 'object' && 'success' in body && 'data' in body ? body.data : body;
|
|
1795
|
+
const items: any[] = Array.isArray(data)
|
|
1796
|
+
? data
|
|
1797
|
+
: Array.isArray(data?.items)
|
|
1798
|
+
? data.items
|
|
1799
|
+
: [];
|
|
1800
|
+
return items
|
|
1801
|
+
.map((it: any) => ({
|
|
1802
|
+
name: String(it?.name ?? ''),
|
|
1803
|
+
label: it?.label != null ? String(it.label) : undefined,
|
|
1804
|
+
}))
|
|
1805
|
+
.filter((it) => it.name);
|
|
1806
|
+
} catch {
|
|
1807
|
+
return [];
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1728
1811
|
/**
|
|
1729
1812
|
* Get access to the underlying ObjectStack client for advanced operations.
|
|
1730
1813
|
*/
|
package/src/onMutation.test.ts
CHANGED
|
@@ -123,3 +123,175 @@ describe('ObjectStackAdapter.onMutation', () => {
|
|
|
123
123
|
expect(good).toHaveBeenCalledTimes(1);
|
|
124
124
|
});
|
|
125
125
|
});
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Regression for #2582: a ModalForm over an object WITH subforms delegates to
|
|
129
|
+
* MasterDetailForm, which persists parent + child rows through ONE
|
|
130
|
+
* `batchTransaction` (`POST /api/v1/batch`). That path never emitted
|
|
131
|
+
* MutationEvents, so the related list and the "相关" tab count badge stayed
|
|
132
|
+
* stale after a successful create/edit until a full page reload. The adapter
|
|
133
|
+
* must emit one event per committed operation — and nothing when the
|
|
134
|
+
* transaction failed (it rolled back entirely).
|
|
135
|
+
*/
|
|
136
|
+
describe('ObjectStackAdapter.batchTransaction mutation events', () => {
|
|
137
|
+
function makeBatchDS(responder: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>) {
|
|
138
|
+
const ds: any = new ObjectStackAdapter({
|
|
139
|
+
baseUrl: 'http://test.local',
|
|
140
|
+
fetch: vi.fn(responder),
|
|
141
|
+
});
|
|
142
|
+
ds.connected = true;
|
|
143
|
+
ds.connectionState = 'connected';
|
|
144
|
+
return ds;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
it('emits one event per op after a committed master-detail CREATE batch', async () => {
|
|
148
|
+
const ds = makeBatchDS(async () =>
|
|
149
|
+
new Response(
|
|
150
|
+
JSON.stringify({
|
|
151
|
+
results: [
|
|
152
|
+
{ id: 'p1', name: 'Task version A' },
|
|
153
|
+
{ id: 'c1', task: 'p1', label: 'Check item 1' },
|
|
154
|
+
],
|
|
155
|
+
}),
|
|
156
|
+
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
157
|
+
),
|
|
158
|
+
);
|
|
159
|
+
const events: MutationEvent[] = [];
|
|
160
|
+
ds.onMutation((e: MutationEvent) => events.push(e));
|
|
161
|
+
|
|
162
|
+
await ds.batchTransaction([
|
|
163
|
+
{ object: 'ehr_task_version', action: 'create', data: { name: 'Task version A' } },
|
|
164
|
+
{ object: 'ehr_task_check_item', action: 'create', data: { task: { $ref: 0 }, label: 'Check item 1' } },
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
expect(events).toEqual([
|
|
168
|
+
{ type: 'create', resource: 'ehr_task_version', record: { id: 'p1', name: 'Task version A' } },
|
|
169
|
+
{ type: 'create', resource: 'ehr_task_check_item', record: { id: 'c1', task: 'p1', label: 'Check item 1' } },
|
|
170
|
+
]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('emits update/create/delete events after a committed master-detail EDIT batch', async () => {
|
|
174
|
+
const ds = makeBatchDS(async () =>
|
|
175
|
+
new Response(
|
|
176
|
+
JSON.stringify({
|
|
177
|
+
results: [{ id: 'p1' }, { id: 'c-new' }, { id: 'c1' }, { deleted: true }],
|
|
178
|
+
}),
|
|
179
|
+
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
180
|
+
),
|
|
181
|
+
);
|
|
182
|
+
const events: MutationEvent[] = [];
|
|
183
|
+
ds.onMutation((e: MutationEvent) => events.push(e));
|
|
184
|
+
|
|
185
|
+
await ds.batchTransaction([
|
|
186
|
+
{ object: 'ehr_task_version', action: 'update', id: 'p1', data: { name: 'Renamed' } },
|
|
187
|
+
{ object: 'ehr_task_check_item', action: 'create', data: { task: 'p1' } },
|
|
188
|
+
{ object: 'ehr_task_check_item', action: 'update', id: 'c1', data: { label: 'Edited' } },
|
|
189
|
+
{ object: 'ehr_task_check_item', action: 'delete', id: 'c2' },
|
|
190
|
+
]);
|
|
191
|
+
|
|
192
|
+
expect(events).toEqual([
|
|
193
|
+
{ type: 'update', resource: 'ehr_task_version', id: 'p1', record: { id: 'p1' } },
|
|
194
|
+
{ type: 'create', resource: 'ehr_task_check_item', record: { id: 'c-new' } },
|
|
195
|
+
{ type: 'update', resource: 'ehr_task_check_item', id: 'c1', record: { id: 'c1' } },
|
|
196
|
+
{ type: 'delete', resource: 'ehr_task_check_item', id: 'c2' },
|
|
197
|
+
]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('treats an op without an explicit action as a create (the server default)', async () => {
|
|
201
|
+
const ds = makeBatchDS(async () =>
|
|
202
|
+
new Response(JSON.stringify({ results: [{ id: 'p1' }] }), {
|
|
203
|
+
status: 200,
|
|
204
|
+
headers: { 'Content-Type': 'application/json' },
|
|
205
|
+
}),
|
|
206
|
+
);
|
|
207
|
+
const events: MutationEvent[] = [];
|
|
208
|
+
ds.onMutation((e: MutationEvent) => events.push(e));
|
|
209
|
+
|
|
210
|
+
await ds.batchTransaction([{ object: 'ehr_task_version', data: { name: 'X' } }]);
|
|
211
|
+
|
|
212
|
+
expect(events).toEqual([
|
|
213
|
+
{ type: 'create', resource: 'ehr_task_version', record: { id: 'p1' } },
|
|
214
|
+
]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('emits NOTHING when the batch failed — the transaction rolled back', async () => {
|
|
218
|
+
const ds = makeBatchDS(async () =>
|
|
219
|
+
new Response(JSON.stringify({ error: 'validation failed' }), {
|
|
220
|
+
status: 422,
|
|
221
|
+
headers: { 'Content-Type': 'application/json' },
|
|
222
|
+
}),
|
|
223
|
+
);
|
|
224
|
+
const events: MutationEvent[] = [];
|
|
225
|
+
ds.onMutation((e: MutationEvent) => events.push(e));
|
|
226
|
+
|
|
227
|
+
await expect(
|
|
228
|
+
ds.batchTransaction([{ object: 'ehr_task_version', action: 'create', data: {} }]),
|
|
229
|
+
).rejects.toThrow('validation failed');
|
|
230
|
+
|
|
231
|
+
expect(events).toEqual([]);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Regression for #2582 (fallback path): when the server lacks the transactional
|
|
237
|
+
* batch endpoint, MasterDetailForm persists child rows via `bulk(child,
|
|
238
|
+
* 'create', rows)` (applyDetail → createMany). `bulk` only emitted progress
|
|
239
|
+
* events, so those writes were equally invisible to the invalidation bus. Each
|
|
240
|
+
* branch now emits ONE resource-level event, matching bulkUpdate/bulkDelete.
|
|
241
|
+
*/
|
|
242
|
+
describe('ObjectStackAdapter.bulk mutation events', () => {
|
|
243
|
+
it('emits a single create event per bulk create call', async () => {
|
|
244
|
+
const createMany = vi.fn().mockResolvedValue([{ id: 'c1' }, { id: 'c2' }]);
|
|
245
|
+
const ds = makeDS({ createMany });
|
|
246
|
+
const events: MutationEvent[] = [];
|
|
247
|
+
ds.onMutation((e: MutationEvent) => events.push(e));
|
|
248
|
+
|
|
249
|
+
await ds.bulk('ehr_task_check_item', 'create', [{ label: 'a' }, { label: 'b' }]);
|
|
250
|
+
|
|
251
|
+
expect(events).toEqual([{ type: 'create', resource: 'ehr_task_check_item' }]);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('does not emit when a bulk create returned zero records', async () => {
|
|
255
|
+
const createMany = vi.fn().mockResolvedValue([]);
|
|
256
|
+
const ds = makeDS({ createMany });
|
|
257
|
+
const events: MutationEvent[] = [];
|
|
258
|
+
ds.onMutation((e: MutationEvent) => events.push(e));
|
|
259
|
+
|
|
260
|
+
await ds.bulk('ehr_task_check_item', 'create', [{ label: 'a' }]);
|
|
261
|
+
|
|
262
|
+
expect(events).toEqual([]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('emits a single delete event per bulk delete call', async () => {
|
|
266
|
+
const deleteMany = vi.fn().mockResolvedValue(undefined);
|
|
267
|
+
const ds = makeDS({ deleteMany });
|
|
268
|
+
const events: MutationEvent[] = [];
|
|
269
|
+
ds.onMutation((e: MutationEvent) => events.push(e));
|
|
270
|
+
|
|
271
|
+
await ds.bulk('ehr_task_check_item', 'delete', [{ id: 'c1' }, { id: 'c2' }] as any);
|
|
272
|
+
|
|
273
|
+
expect(events).toEqual([{ type: 'delete', resource: 'ehr_task_check_item' }]);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('emits a single update event per bulk update call', async () => {
|
|
277
|
+
const updateMany = vi.fn().mockResolvedValue([{ id: 'c1' }]);
|
|
278
|
+
const ds = makeDS({ updateMany });
|
|
279
|
+
const events: MutationEvent[] = [];
|
|
280
|
+
ds.onMutation((e: MutationEvent) => events.push(e));
|
|
281
|
+
|
|
282
|
+
await ds.bulk('ehr_task_check_item', 'update', [{ id: 'c1', label: 'x' }] as any);
|
|
283
|
+
|
|
284
|
+
expect(events).toEqual([{ type: 'update', resource: 'ehr_task_check_item' }]);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('bulk create failure emits nothing', async () => {
|
|
288
|
+
const createMany = vi.fn().mockRejectedValue(new Error('boom'));
|
|
289
|
+
const ds = makeDS({ createMany });
|
|
290
|
+
const events: MutationEvent[] = [];
|
|
291
|
+
ds.onMutation((e: MutationEvent) => events.push(e));
|
|
292
|
+
|
|
293
|
+
await expect(ds.bulk('ehr_task_check_item', 'create', [{ label: 'a' }])).rejects.toThrow();
|
|
294
|
+
|
|
295
|
+
expect(events).toEqual([]);
|
|
296
|
+
});
|
|
297
|
+
});
|