@object-ui/data-objectstack 6.2.3 → 7.0.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 +133 -0
- package/dist/index.cjs +344 -15
- package/dist/index.d.cts +368 -5
- package/dist/index.d.ts +368 -5
- package/dist/index.js +344 -15
- package/package.json +5 -5
- package/src/index.ts +216 -8
- package/src/listViews.test.ts +78 -0
- package/src/metadata-client.test.ts +141 -0
- package/src/metadata-client.ts +469 -11
- package/src/queryDataset.test.ts +83 -0
package/src/index.ts
CHANGED
|
@@ -603,7 +603,11 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
603
603
|
// which parses `populate` (comma-separated) into an array for lookup expansion.
|
|
604
604
|
// We use a raw request because the client SDK's data.find() QueryOptions
|
|
605
605
|
// interface does not include populate/expand fields.
|
|
606
|
-
if (params?.$expand && params.$expand.length > 0)
|
|
606
|
+
if ((params?.$expand && params.$expand.length > 0)
|
|
607
|
+
|| (params?.$search != null && String(params.$search).trim() !== '')) {
|
|
608
|
+
// The client SDK's data.find() QueryOptions drops `$search`; route through
|
|
609
|
+
// the raw GET so the term reaches protocol.findData → the metadata-driven
|
|
610
|
+
// search executor (ADR-0061).
|
|
607
611
|
const result = await this.rawFindWithPopulate(resource, params);
|
|
608
612
|
return this.normalizeQueryResult(result, params);
|
|
609
613
|
}
|
|
@@ -857,6 +861,37 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
857
861
|
* @param data - Array of records to process
|
|
858
862
|
* @returns Promise resolving to array of results
|
|
859
863
|
*/
|
|
864
|
+
/**
|
|
865
|
+
* Cross-object transactional batch (ObjectStack #1604). Runs the operations
|
|
866
|
+
* in ONE server transaction — commit all or roll back all. A field value of
|
|
867
|
+
* `{ $ref: <earlier op index> }` resolves to that op's created id, so a child
|
|
868
|
+
* can reference its parent created earlier in the same batch (master-detail).
|
|
869
|
+
*/
|
|
870
|
+
async batchTransaction(
|
|
871
|
+
operations: Array<{ object: string; action?: 'create' | 'update' | 'delete'; data?: any; id?: string }>,
|
|
872
|
+
): Promise<{ results: any[] }> {
|
|
873
|
+
const url = `${this.baseUrl}/api/v1/batch`;
|
|
874
|
+
const response = await fetch(url, {
|
|
875
|
+
method: 'POST',
|
|
876
|
+
headers: { 'Content-Type': 'application/json', ...this.getAuthHeaders() },
|
|
877
|
+
// Send the session cookie too: in the browser console auth is cookie-based
|
|
878
|
+
// (no bearer `token`), so without `credentials: 'include'` this raw fetch
|
|
879
|
+
// is unauthenticated — every master-detail batch save would 401. Bearer
|
|
880
|
+
// (server-to-server) and cookie (console) auth now both work.
|
|
881
|
+
credentials: 'include',
|
|
882
|
+
body: JSON.stringify({ operations }),
|
|
883
|
+
});
|
|
884
|
+
if (!response.ok) {
|
|
885
|
+
const error = await response.json().catch(() => ({ message: response.statusText }));
|
|
886
|
+
throw new ObjectStackError(
|
|
887
|
+
error.error || error.message || `Batch failed with status ${response.status}`,
|
|
888
|
+
'BATCH_ERROR',
|
|
889
|
+
response.status,
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
return response.json();
|
|
893
|
+
}
|
|
894
|
+
|
|
860
895
|
async bulk(resource: string, operation: 'create' | 'update' | 'delete', data: Partial<T>[]): Promise<T[]> {
|
|
861
896
|
await this.connect();
|
|
862
897
|
|
|
@@ -1027,16 +1062,22 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1027
1062
|
};
|
|
1028
1063
|
}
|
|
1029
1064
|
|
|
1030
|
-
const resultObj = result as { records?: T[]; total?: number; value?: T[]; count?: number };
|
|
1065
|
+
const resultObj = result as { records?: T[]; total?: number; value?: T[]; count?: number; hasMore?: boolean };
|
|
1031
1066
|
const records = resultObj.records || resultObj.value || [];
|
|
1032
1067
|
const total = resultObj.total ?? resultObj.count ?? records.length;
|
|
1068
|
+
// Prefer the server's `hasMore` (real server-side pagination, framework
|
|
1069
|
+
// issue #2212). Fall back to the page-local estimate (a full page implies
|
|
1070
|
+
// there may be more) only when the server doesn't report it.
|
|
1071
|
+
const hasMore = typeof resultObj.hasMore === 'boolean'
|
|
1072
|
+
? resultObj.hasMore
|
|
1073
|
+
: (params?.$top ? records.length === params.$top : false);
|
|
1033
1074
|
return {
|
|
1034
1075
|
data: records,
|
|
1035
1076
|
total,
|
|
1036
1077
|
// Calculate page number safely
|
|
1037
1078
|
page: params?.$skip && params.$top ? Math.floor(params.$skip / params.$top) + 1 : 1,
|
|
1038
1079
|
pageSize: params?.$top,
|
|
1039
|
-
hasMore
|
|
1080
|
+
hasMore,
|
|
1040
1081
|
};
|
|
1041
1082
|
}
|
|
1042
1083
|
|
|
@@ -1062,6 +1103,15 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1062
1103
|
queryParams.set('skip', String(params.$skip));
|
|
1063
1104
|
}
|
|
1064
1105
|
|
|
1106
|
+
// Full-text search (ADR-0061). The server resolves which fields to match
|
|
1107
|
+
// from object metadata; the client only sends the term (+ optional override).
|
|
1108
|
+
if (params.$search != null && String(params.$search).trim() !== '') {
|
|
1109
|
+
queryParams.set('search', String(params.$search).trim());
|
|
1110
|
+
}
|
|
1111
|
+
if (params.$searchFields && params.$searchFields.length > 0) {
|
|
1112
|
+
queryParams.set('searchFields', params.$searchFields.join(','));
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1065
1115
|
// Selection — always include `id` to ensure records can be identified
|
|
1066
1116
|
// for navigation/selection even when callers omit it from $select.
|
|
1067
1117
|
if (params.$select && params.$select.length > 0) {
|
|
@@ -1219,6 +1269,13 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1219
1269
|
options.top = params.$top;
|
|
1220
1270
|
}
|
|
1221
1271
|
|
|
1272
|
+
if (params.$search != null && String(params.$search).trim() !== '') {
|
|
1273
|
+
(options as Record<string, unknown>).search = String(params.$search).trim();
|
|
1274
|
+
}
|
|
1275
|
+
if (params.$searchFields && params.$searchFields.length > 0) {
|
|
1276
|
+
(options as Record<string, unknown>).searchFields = params.$searchFields;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1222
1279
|
return options;
|
|
1223
1280
|
}
|
|
1224
1281
|
|
|
@@ -1233,7 +1290,12 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1233
1290
|
await this.connect();
|
|
1234
1291
|
|
|
1235
1292
|
try {
|
|
1236
|
-
// Use cache with automatic fetching
|
|
1293
|
+
// Use cache with automatic fetching. The cache is keyed by object name
|
|
1294
|
+
// only (locale-independent); a language switch wipes it wholesale via
|
|
1295
|
+
// `clearCache()` so the next read re-fetches in the new locale — see the
|
|
1296
|
+
// shell's locale remount (issue #1319). Keeping the key locale-free here
|
|
1297
|
+
// means a metadata *write* still invalidates the single entry it knows
|
|
1298
|
+
// about, without having to fan out across every cached locale.
|
|
1237
1299
|
const schema = await this.metadataCache.get(objectName, async () => {
|
|
1238
1300
|
const result: any = await this.client.meta.getItem('object', objectName);
|
|
1239
1301
|
|
|
@@ -1348,8 +1410,10 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1348
1410
|
try {
|
|
1349
1411
|
const cacheKey = `view:${objectName}:${viewId}`;
|
|
1350
1412
|
return await this.metadataCache.get(cacheKey, async () => {
|
|
1351
|
-
//
|
|
1352
|
-
|
|
1413
|
+
// Views are an independent metadata type (ADR-0017) — the first
|
|
1414
|
+
// getItem argument is the metadata TYPE, not the object name.
|
|
1415
|
+
// (Passing objectName here hit /meta/<object>/<view> and always 404ed.)
|
|
1416
|
+
const result: any = await this.client.meta.getItem('view', viewId);
|
|
1353
1417
|
if (result && result.item) return result.item;
|
|
1354
1418
|
return result ?? null;
|
|
1355
1419
|
});
|
|
@@ -1427,13 +1491,43 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1427
1491
|
const items: any[] = Array.isArray(result?.items)
|
|
1428
1492
|
? result.items
|
|
1429
1493
|
: Array.isArray(result) ? result : [];
|
|
1494
|
+
// This feeds the list-view switcher (ViewTabBar), so it must return
|
|
1495
|
+
// LIST-family views only. The backend now exposes each view as an
|
|
1496
|
+
// independent ViewItem carrying a `viewKind` discriminant (ADR-0017);
|
|
1497
|
+
// form-family views (`form`/`detail`) are record forms, not list tabs,
|
|
1498
|
+
// and must be excluded — otherwise e.g. `crm_activity.default` (a form)
|
|
1499
|
+
// leaks in as a spurious switcher tab. Bare specs without `viewKind`
|
|
1500
|
+
// (legacy artifacts / saved views) are kept as list views.
|
|
1501
|
+
const FORM_FAMILY = new Set(['form', 'detail']);
|
|
1430
1502
|
return items.filter((v: any) => {
|
|
1431
1503
|
if (!v) return false;
|
|
1432
1504
|
// Handle both bare view spec and `{list: {...}}` artifact wrapper
|
|
1433
1505
|
const spec = v.list ?? v;
|
|
1434
1506
|
const obj = spec?.data?.object ?? spec?.object ?? spec?.objectName;
|
|
1435
|
-
|
|
1436
|
-
|
|
1507
|
+
if (obj !== objectName) return false;
|
|
1508
|
+
const viewKind = v.viewKind ?? spec?.viewKind;
|
|
1509
|
+
return !(viewKind && FORM_FAMILY.has(viewKind));
|
|
1510
|
+
}).map((v: any) => {
|
|
1511
|
+
const spec = v.list ?? v;
|
|
1512
|
+
// Canonical ViewItem (ADR-0017) carries its body under `config`;
|
|
1513
|
+
// the display `type` (grid/kanban/gallery/…) lives at `config.type`,
|
|
1514
|
+
// and only the list/form *family* sits at the top level (`viewKind`).
|
|
1515
|
+
// Flatten `config` up to the legacy NamedListView shape the switcher +
|
|
1516
|
+
// ObjectView consume — mirroring MetadataProvider.mergeViewsIntoObjects
|
|
1517
|
+
// so the two paths don't drift. Without this an un-flattened item has
|
|
1518
|
+
// no top-level `type`, so ObjectView's saved-view normalization defaults
|
|
1519
|
+
// it to 'grid' and overrides the metadata entry — a kanban/gallery/
|
|
1520
|
+
// calendar view then silently renders as a plain table.
|
|
1521
|
+
if (spec && spec.config && typeof spec.config === 'object') {
|
|
1522
|
+
return {
|
|
1523
|
+
...spec.config,
|
|
1524
|
+
name: spec.name ?? spec.config.name,
|
|
1525
|
+
label: spec.label ?? spec.config.label,
|
|
1526
|
+
isDefault: !!spec.isDefault,
|
|
1527
|
+
};
|
|
1528
|
+
}
|
|
1529
|
+
return spec;
|
|
1530
|
+
});
|
|
1437
1531
|
} catch (err) {
|
|
1438
1532
|
console.warn('[OBJECTSTACKDataSource] listViews failed:', err);
|
|
1439
1533
|
return [];
|
|
@@ -1732,6 +1826,111 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1732
1826
|
}
|
|
1733
1827
|
}
|
|
1734
1828
|
|
|
1829
|
+
/**
|
|
1830
|
+
* Run a semantic-layer `dataset` (ADR-0021) and return chart-ready rows.
|
|
1831
|
+
*
|
|
1832
|
+
* Posts to `POST /api/v1/analytics/dataset/query` (see `@objectstack/rest`
|
|
1833
|
+
* `registerAnalyticsEndpoints`). Accepts either a saved dataset name or an
|
|
1834
|
+
* inline draft definition — the inline form is what the Studio dataset
|
|
1835
|
+
* editor sends to preview an unsaved draft. The adapter's bearer token is
|
|
1836
|
+
* forwarded so tenant/RLS scoping (ADR-0021 D-C) is enforced server-side.
|
|
1837
|
+
*
|
|
1838
|
+
* Unlike {@link aggregate}, this does NOT fall back to client-side
|
|
1839
|
+
* aggregation: cross-object joins can only run on the server, so a failure
|
|
1840
|
+
* is surfaced to the caller (the preview panel shows the error) rather than
|
|
1841
|
+
* silently returning wrong numbers.
|
|
1842
|
+
*
|
|
1843
|
+
* @param dataset - An inline dataset definition (draft) OR a saved dataset name.
|
|
1844
|
+
* @param selection - Dimension/measure names to project + runtime directives.
|
|
1845
|
+
*/
|
|
1846
|
+
async queryDataset(
|
|
1847
|
+
dataset: Record<string, unknown> | string,
|
|
1848
|
+
selection: {
|
|
1849
|
+
dimensions?: string[];
|
|
1850
|
+
measures: string[];
|
|
1851
|
+
runtimeFilter?: Record<string, unknown>;
|
|
1852
|
+
timeDimensions?: unknown[];
|
|
1853
|
+
compareTo?: { kind: 'previousPeriod' | 'previousYear'; dimension: string };
|
|
1854
|
+
order?: Record<string, 'asc' | 'desc'>;
|
|
1855
|
+
limit?: number;
|
|
1856
|
+
offset?: number;
|
|
1857
|
+
timezone?: string;
|
|
1858
|
+
/** Marginal-aggregate groupings (e.g. `[rows, [colDim], []]`) — server
|
|
1859
|
+
* computes each subtotal with the measure's TRUE aggregate (never client
|
|
1860
|
+
* re-derived). `[]` is the grand total. */
|
|
1861
|
+
totals?: { groupings: string[][] };
|
|
1862
|
+
},
|
|
1863
|
+
): Promise<{
|
|
1864
|
+
rows: Array<Record<string, unknown>>;
|
|
1865
|
+
/** Column metadata: a display `label` (dimensions and measures), and a
|
|
1866
|
+
* measure's numeral `format` + declared `currency` for value formatting. */
|
|
1867
|
+
fields: Array<{ name: string; type: string; label?: string; format?: string; currency?: string }>;
|
|
1868
|
+
/** ADR-0021 D2 drill-through: the dataset's base object (records to drill into). */
|
|
1869
|
+
object?: string;
|
|
1870
|
+
/** Drillable dimension NAME → underlying object FIELD name. */
|
|
1871
|
+
dimensionFields?: Record<string, string>;
|
|
1872
|
+
/** Raw grouped values per row (aligned to `rows` by index) for drill filters. */
|
|
1873
|
+
drillRawRows?: Array<Record<string, unknown>>;
|
|
1874
|
+
/** Server-computed marginal aggregates, one entry per requested grouping. */
|
|
1875
|
+
totals?: Array<{ dimensions: string[]; rows: Array<Record<string, unknown>> }>;
|
|
1876
|
+
}> {
|
|
1877
|
+
await this.connect();
|
|
1878
|
+
const base = (this.baseUrl || '').replace(/\/$/, '');
|
|
1879
|
+
const url = `${base}/api/v1/analytics/dataset/query`;
|
|
1880
|
+
// ADR-0037 P3 — draft data preview. Preview mode is URL-keyed by design
|
|
1881
|
+
// (`?preview=draft` flips the whole document, incl. the Live Canvas
|
|
1882
|
+
// iframe), so the adapter reads it straight off the location rather than
|
|
1883
|
+
// threading a React context down through every widget package. When set,
|
|
1884
|
+
// the server overlays the pending seed draft's rows on the dataset query
|
|
1885
|
+
// and resolves draft-overlaid dataset definitions.
|
|
1886
|
+
const previewDrafts =
|
|
1887
|
+
typeof window !== 'undefined' &&
|
|
1888
|
+
new URLSearchParams(window.location.search).get('preview') === 'draft';
|
|
1889
|
+
const requestBody = typeof dataset === 'string'
|
|
1890
|
+
? { datasetName: dataset, selection, ...(previewDrafts ? { previewDrafts: true } : {}) }
|
|
1891
|
+
: { dataset, selection, ...(previewDrafts ? { previewDrafts: true } : {}) };
|
|
1892
|
+
|
|
1893
|
+
const res = await this.fetchImpl(url, {
|
|
1894
|
+
method: 'POST',
|
|
1895
|
+
headers: {
|
|
1896
|
+
'Content-Type': 'application/json',
|
|
1897
|
+
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
|
|
1898
|
+
},
|
|
1899
|
+
body: JSON.stringify(requestBody),
|
|
1900
|
+
});
|
|
1901
|
+
|
|
1902
|
+
if (!res.ok) {
|
|
1903
|
+
let detail = '';
|
|
1904
|
+
try {
|
|
1905
|
+
const errBody = await res.json();
|
|
1906
|
+
detail = errBody?.message || errBody?.error || JSON.stringify(errBody);
|
|
1907
|
+
} catch { /* non-JSON error body */ }
|
|
1908
|
+
throw new Error(`Dataset query failed: ${res.status} ${res.statusText}${detail ? ` — ${detail}` : ''}`);
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
const payload = await res.json();
|
|
1912
|
+
// Unwrap the standard `{ success, data }` envelope when present.
|
|
1913
|
+
const data = payload && typeof payload === 'object' && 'success' in payload && 'data' in payload
|
|
1914
|
+
? (payload as { data: unknown }).data
|
|
1915
|
+
: payload;
|
|
1916
|
+
const rows = Array.isArray((data as any)?.rows)
|
|
1917
|
+
? (data as any).rows
|
|
1918
|
+
: (Array.isArray(data) ? (data as any) : []);
|
|
1919
|
+
const fields = Array.isArray((data as any)?.fields) ? (data as any).fields : [];
|
|
1920
|
+
// Drill-through metadata (ADR-0021 D2): the server exposes the dataset's
|
|
1921
|
+
// base object + drillable dimension→field mapping, plus a parallel array of
|
|
1922
|
+
// RAW grouped values (the rows themselves carry display labels), so a host
|
|
1923
|
+
// can build an exact-match filter from a clicked bucket.
|
|
1924
|
+
const object = typeof (data as any)?.object === 'string' ? (data as any).object : undefined;
|
|
1925
|
+
const dimensionFields =
|
|
1926
|
+
(data as any)?.dimensionFields && typeof (data as any).dimensionFields === 'object'
|
|
1927
|
+
? ((data as any).dimensionFields as Record<string, string>)
|
|
1928
|
+
: undefined;
|
|
1929
|
+
const drillRawRows = Array.isArray((data as any)?.drillRawRows) ? (data as any).drillRawRows : undefined;
|
|
1930
|
+
const totals = Array.isArray((data as any)?.totals) ? (data as any).totals : undefined;
|
|
1931
|
+
return { rows, fields, object, dimensionFields, drillRawRows, totals };
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1735
1934
|
/** Client-side aggregation fallback */
|
|
1736
1935
|
private aggregateClientSide(records: any[], params: { field: string; function: string; groupBy: string }): any[] {
|
|
1737
1936
|
const { field, function: aggFn, groupBy } = params;
|
|
@@ -2076,11 +2275,20 @@ export { MetadataClient } from './metadata-client';
|
|
|
2076
2275
|
export type {
|
|
2077
2276
|
MetadataClientConfig,
|
|
2078
2277
|
MetadataListOptions,
|
|
2278
|
+
MetadataDraftHeader,
|
|
2079
2279
|
MetadataSaveOptions,
|
|
2280
|
+
MetadataGetOptions,
|
|
2281
|
+
MetadataDeleteOptions,
|
|
2080
2282
|
MetadataHistoryOptions,
|
|
2081
2283
|
MetadataError,
|
|
2082
2284
|
MetadataLayered,
|
|
2083
2285
|
MetadataReference,
|
|
2286
|
+
MetadataDiagnostics,
|
|
2287
|
+
MetadataDiagnosticsOptions,
|
|
2288
|
+
MetadataDiagnosticsEntry,
|
|
2289
|
+
MetadataDiagnosticsSummary,
|
|
2290
|
+
MetadataAuditEntry,
|
|
2291
|
+
MetadataAuditResponse,
|
|
2084
2292
|
} from './metadata-client';
|
|
2085
2293
|
|
|
2086
2294
|
export { SecurityManager } from './security';
|
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
/**
|
|
13
|
+
* `listViews(object)` feeds the list-view switcher (ObjectView → ViewTabBar),
|
|
14
|
+
* so it must return LIST-family views only. The backend exposes each view as
|
|
15
|
+
* an independent ViewItem carrying a `viewKind` discriminant (ADR-0017); a
|
|
16
|
+
* form-family view (e.g. `crm_activity.default`) must NOT leak in as a tab.
|
|
17
|
+
*/
|
|
18
|
+
function makeDS(items: any[]) {
|
|
19
|
+
const ds: any = new ObjectStackAdapter({
|
|
20
|
+
baseUrl: 'http://test.local',
|
|
21
|
+
fetch: vi.fn(async () =>
|
|
22
|
+
new Response(JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }), {
|
|
23
|
+
status: 200,
|
|
24
|
+
headers: { 'Content-Type': 'application/json' },
|
|
25
|
+
})),
|
|
26
|
+
});
|
|
27
|
+
ds.connected = true;
|
|
28
|
+
ds.connectionState = 'connected';
|
|
29
|
+
ds.client = { meta: { getItems: vi.fn(async () => ({ items })) } };
|
|
30
|
+
return ds;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('ObjectStackDataSource.listViews', () => {
|
|
34
|
+
// The crm_activity view set as the framework expands it.
|
|
35
|
+
const listAll = {
|
|
36
|
+
name: 'crm_activity.all', object: 'crm_activity', viewKind: 'list', isDefault: true,
|
|
37
|
+
label: 'All Activities', config: { type: 'grid', data: { object: 'crm_activity' } },
|
|
38
|
+
};
|
|
39
|
+
const listCalendar = {
|
|
40
|
+
name: 'crm_activity.calendar', object: 'crm_activity', viewKind: 'list',
|
|
41
|
+
label: 'Activity Calendar', config: { type: 'calendar', data: { object: 'crm_activity' } },
|
|
42
|
+
};
|
|
43
|
+
const formDefault = {
|
|
44
|
+
name: 'crm_activity.default', object: 'crm_activity', viewKind: 'form',
|
|
45
|
+
label: 'Activity', config: { type: 'simple' },
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
it('excludes form-family ViewItems from the list-view switcher', async () => {
|
|
49
|
+
const ds = makeDS([listAll, listCalendar, formDefault]);
|
|
50
|
+
const views = await ds.listViews('crm_activity');
|
|
51
|
+
const names = views.map((v: any) => v.name).sort();
|
|
52
|
+
expect(names).toEqual(['crm_activity.all', 'crm_activity.calendar']);
|
|
53
|
+
// The form view is the original leak — it must be gone.
|
|
54
|
+
expect(views.find((v: any) => v.name === 'crm_activity.default')).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('excludes detail-family views too', async () => {
|
|
58
|
+
const detail = { name: 'crm_activity.detail', object: 'crm_activity', viewKind: 'detail', config: {} };
|
|
59
|
+
const ds = makeDS([listAll, detail]);
|
|
60
|
+
const views = await ds.listViews('crm_activity');
|
|
61
|
+
expect(views.map((v: any) => v.name)).toEqual(['crm_activity.all']);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('filters by object binding', async () => {
|
|
65
|
+
const otherList = { name: 'crm_lead.all', object: 'crm_lead', viewKind: 'list', config: {} };
|
|
66
|
+
const ds = makeDS([listAll, otherList]);
|
|
67
|
+
const views = await ds.listViews('crm_activity');
|
|
68
|
+
expect(views.map((v: any) => v.name)).toEqual(['crm_activity.all']);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('keeps legacy bare specs without a viewKind (saved/list views)', async () => {
|
|
72
|
+
const legacy = { name: 'saved_grid', object: 'crm_activity', type: 'grid' };
|
|
73
|
+
const wrapped = { list: { name: 'wrapped_grid', object: 'crm_activity', type: 'grid' } };
|
|
74
|
+
const ds = makeDS([legacy, wrapped]);
|
|
75
|
+
const views = await ds.listViews('crm_activity');
|
|
76
|
+
expect(views.map((v: any) => v.name).sort()).toEqual(['saved_grid', 'wrapped_grid']);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -121,4 +121,145 @@ describe('MetadataClient', () => {
|
|
|
121
121
|
message: 'metadata_conflict',
|
|
122
122
|
});
|
|
123
123
|
});
|
|
124
|
+
|
|
125
|
+
it('listDrafts requests /meta/_drafts with packageId + type and parses {drafts}', async () => {
|
|
126
|
+
const seen: string[] = [];
|
|
127
|
+
const c = new MetadataClient({
|
|
128
|
+
baseUrl: 'http://localhost:3000',
|
|
129
|
+
fetch: mockFetch(async (url) => {
|
|
130
|
+
seen.push(url);
|
|
131
|
+
return jsonResponse({
|
|
132
|
+
drafts: [{ type: 'object', name: 'course', packageId: 'app.edu', updatedAt: 't', updatedBy: 'ai' }],
|
|
133
|
+
});
|
|
134
|
+
}),
|
|
135
|
+
});
|
|
136
|
+
const out = await c.listDrafts({ packageId: 'app.edu', type: 'object' });
|
|
137
|
+
expect(seen[0]).toBe('http://localhost:3000/api/v1/meta/_drafts?packageId=app.edu&type=object');
|
|
138
|
+
expect(out).toEqual([
|
|
139
|
+
{ type: 'object', name: 'course', packageId: 'app.edu', updatedAt: 't', updatedBy: 'ai' },
|
|
140
|
+
]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('listDrafts tolerates the {data:{drafts}} envelope and a bare array', async () => {
|
|
144
|
+
const enveloped = new MetadataClient({
|
|
145
|
+
baseUrl: '',
|
|
146
|
+
fetch: mockFetch(async () =>
|
|
147
|
+
jsonResponse({ data: { drafts: [{ type: 'view', name: 'v', packageId: null, updatedAt: null, updatedBy: null }] } })),
|
|
148
|
+
});
|
|
149
|
+
expect((await enveloped.listDrafts()).map((d) => d.name)).toEqual(['v']);
|
|
150
|
+
|
|
151
|
+
const bare = new MetadataClient({
|
|
152
|
+
baseUrl: '',
|
|
153
|
+
fetch: mockFetch(async () =>
|
|
154
|
+
jsonResponse([{ type: 'object', name: 'b', packageId: null, updatedAt: null, updatedBy: null }])),
|
|
155
|
+
});
|
|
156
|
+
expect((await bare.listDrafts()).map((d) => d.name)).toEqual(['b']);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('publishDraft POSTs /meta/:type/:name/publish (promotes a draft by ref, no package needed)', async () => {
|
|
160
|
+
const seen: { url: string; method?: string }[] = [];
|
|
161
|
+
const c = new MetadataClient({
|
|
162
|
+
baseUrl: 'http://localhost:3000',
|
|
163
|
+
fetch: mockFetch(async (url, init) => {
|
|
164
|
+
seen.push({ url, method: init?.method });
|
|
165
|
+
return jsonResponse({ success: true });
|
|
166
|
+
}),
|
|
167
|
+
});
|
|
168
|
+
await c.publishDraft('dashboard', 'sales_dashboard');
|
|
169
|
+
expect(seen[0]?.method).toBe('POST');
|
|
170
|
+
expect(seen[0]?.url).toBe('http://localhost:3000/api/v1/meta/dashboard/sales_dashboard/publish');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('publishDraft throws on a non-ok response', async () => {
|
|
174
|
+
const c = new MetadataClient({
|
|
175
|
+
baseUrl: '',
|
|
176
|
+
fetch: mockFetch(async () => new Response('nope', { status: 500 })),
|
|
177
|
+
});
|
|
178
|
+
await expect(c.publishDraft('view', 'x')).rejects.toBeTruthy();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('publishDraft returns the result (incl. seedApplied) and unwraps the {data} envelope', async () => {
|
|
182
|
+
// Flat shape (rest-server passes protocol result through).
|
|
183
|
+
const flat = new MetadataClient({
|
|
184
|
+
baseUrl: '',
|
|
185
|
+
fetch: mockFetch(async () =>
|
|
186
|
+
jsonResponse({ success: true, seedApplied: { success: false, error: 'no readable seed bodies' } })),
|
|
187
|
+
});
|
|
188
|
+
const r1 = await flat.publishDraft('seed', 'job_sample');
|
|
189
|
+
expect(r1.seedApplied).toEqual({ success: false, error: 'no readable seed bodies' });
|
|
190
|
+
|
|
191
|
+
// Dispatcher `{ success, data: {...} }` envelope.
|
|
192
|
+
const enveloped = new MetadataClient({
|
|
193
|
+
baseUrl: '',
|
|
194
|
+
fetch: mockFetch(async () =>
|
|
195
|
+
jsonResponse({ success: true, data: { success: true, seedApplied: { success: true, inserted: 6, updated: 0 } } })),
|
|
196
|
+
});
|
|
197
|
+
const r2 = await enveloped.publishDraft('seed', 'candidate_sample');
|
|
198
|
+
expect(r2.seedApplied).toEqual({ success: true, inserted: 6, updated: 0 });
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe('MetadataClient — ADR-0037 previewDrafts', () => {
|
|
203
|
+
function previewClient(opts: { previewDrafts?: boolean; environmentId?: string } = {}) {
|
|
204
|
+
const urls: string[] = [];
|
|
205
|
+
const c = new MetadataClient({
|
|
206
|
+
baseUrl: 'http://localhost:3000',
|
|
207
|
+
...opts,
|
|
208
|
+
fetch: mockFetch(async (url) => {
|
|
209
|
+
urls.push(url);
|
|
210
|
+
return jsonResponse([{ name: 'account' }]);
|
|
211
|
+
}),
|
|
212
|
+
});
|
|
213
|
+
return { c, urls };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
it('list() and get() append ?preview=draft when enabled', async () => {
|
|
217
|
+
const { c, urls } = previewClient({ previewDrafts: true });
|
|
218
|
+
await c.list('object');
|
|
219
|
+
await c.get('object', 'account');
|
|
220
|
+
expect(urls[0]).toBe('http://localhost:3000/api/v1/meta/object?preview=draft');
|
|
221
|
+
expect(urls[1]).toBe('http://localhost:3000/api/v1/meta/object/account?preview=draft');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('combines preview with the package filter on list()', async () => {
|
|
225
|
+
const { c, urls } = previewClient({ previewDrafts: true });
|
|
226
|
+
await c.list('object', { packageId: 'app.crm' });
|
|
227
|
+
expect(urls[0]).toBe('http://localhost:3000/api/v1/meta/object?package=app.crm&preview=draft');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('an explicit state=draft read wins over the overlay flag', async () => {
|
|
231
|
+
const { c, urls } = previewClient({ previewDrafts: true });
|
|
232
|
+
await c.get('object', 'account', { state: 'draft' });
|
|
233
|
+
expect(urls[0]).toBe('http://localhost:3000/api/v1/meta/object/account?state=draft');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('default client never sends the flag', async () => {
|
|
237
|
+
const { c, urls } = previewClient();
|
|
238
|
+
await c.list('object');
|
|
239
|
+
await c.get('object', 'account');
|
|
240
|
+
expect(urls[0]).not.toContain('preview');
|
|
241
|
+
expect(urls[1]).not.toContain('preview');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('withPreviewDrafts derives a preview client and PRESERVES the environment scope', async () => {
|
|
245
|
+
const { c } = previewClient({ environmentId: 'env_1' });
|
|
246
|
+
const p = c.withPreviewDrafts(true);
|
|
247
|
+
expect(p.previewDrafts).toBe(true);
|
|
248
|
+
// Same instance back when the flag already matches.
|
|
249
|
+
expect(p.withPreviewDrafts(true)).toBe(p);
|
|
250
|
+
// Environment-scoped path survives the derivation (regression guard:
|
|
251
|
+
// a naive rebuild dropped /environments/:id from the base).
|
|
252
|
+
const urls: string[] = [];
|
|
253
|
+
const scoped = new MetadataClient({
|
|
254
|
+
baseUrl: 'http://localhost:3000',
|
|
255
|
+
environmentId: 'env_1',
|
|
256
|
+
previewDrafts: false,
|
|
257
|
+
fetch: mockFetch(async (url) => {
|
|
258
|
+
urls.push(url);
|
|
259
|
+
return jsonResponse([]);
|
|
260
|
+
}),
|
|
261
|
+
}).withPreviewDrafts(true);
|
|
262
|
+
await scoped.list('object');
|
|
263
|
+
expect(urls[0]).toBe('http://localhost:3000/api/v1/environments/env_1/meta/object?preview=draft');
|
|
264
|
+
});
|
|
124
265
|
});
|