@holistics/anfra-sdk 0.7.1 → 0.8.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/README.md +29 -4
- package/dist/api.d.ts +7 -4
- package/dist/api.js +30 -4
- package/dist/app.d.ts +36 -5
- package/dist/app.js +216 -12
- package/dist/{bridge-BKx7u5mX.d.ts → bridge-BmbS9J4V.d.ts} +50 -3
- package/dist/{chunk-KCTLO3CO.js → chunk-KBXITYQK.js} +4 -3
- package/dist/common.d.ts +2 -2
- package/dist/common.js +1 -2
- package/dist/host.d.ts +24 -5
- package/dist/host.js +108 -5
- package/dist/{types-BlzJVcUl.d.ts → types-IR4TNw-M.d.ts} +101 -1
- package/package.json +2 -1
- package/dist/chunk-PKBMQBKP.js +0 -7
package/README.md
CHANGED
|
@@ -44,6 +44,29 @@ app.subscribe(() => render(byRegion.result));
|
|
|
44
44
|
await app.execute();
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
+
A reader can download a query's whole result, with the inputs its result on screen was run with:
|
|
48
|
+
|
|
49
|
+
```js
|
|
50
|
+
if (Anfra.features.export && Anfra.user.permissions.canExportData) {
|
|
51
|
+
button.onclick = () => byRegion.export().catch((err) => showError(err.message));
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`export()` resolves once the file is made and the host has started saving it, with its link and
|
|
56
|
+
`rowCount`. The file is a CSV named after the app and the query unless you pass `filename`.
|
|
57
|
+
|
|
58
|
+
A reader can download a picture of the app, or of one block or container, as they see it:
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
await Anfra.render({ block: 'trend', format: 'png' }); // or 'pdf'; no node: the whole app
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
It works only where the host turned rendering on (`mountDataApp({ render: true })`; `anfra serve`
|
|
65
|
+
does). Check `Anfra.features.render` and `Anfra.user.permissions.canExportData` before offering it, and
|
|
66
|
+
mark what shouldn't be drawn (the download button itself) `data-anfra-render="skip"`. Serve images
|
|
67
|
+
and web fonts with CORS: an image without it is left blank (listed in the result's `omitted`), a
|
|
68
|
+
font without it falls back.
|
|
69
|
+
|
|
47
70
|
`Anfra.datasets` describes what there is to query. For TypeScript, `@holistics/anfra-sdk/ambient` declares the
|
|
48
71
|
global.
|
|
49
72
|
|
|
@@ -81,20 +104,22 @@ const mounted = mountDataApp({
|
|
|
81
104
|
baseHref: '/appserve/files/sales/', // where its relative URLs resolve
|
|
82
105
|
datasets,
|
|
83
106
|
user,
|
|
84
|
-
backend: coreApiBackend(client, { datasets }), // queries
|
|
107
|
+
backend: coreApiBackend(client, { datasets }), // queries, field suggestions and exports, as core ops
|
|
108
|
+
render: true, // let it save PNGs and PDFs of itself (off by default)
|
|
85
109
|
});
|
|
86
|
-
// mounted.setInspecting(true) to receive inspection snapshots; mounted.
|
|
110
|
+
// mounted.setInspecting(true) to receive inspection snapshots; mounted.render({ format: 'pdf' }) to
|
|
111
|
+
// save a picture of the app (needs render: true); mounted.unmount() when done.
|
|
87
112
|
```
|
|
88
113
|
|
|
89
114
|
`mountDataApp` puts the definition in an iframe with `sandbox="allow-scripts"`, as its `srcdoc`,
|
|
90
|
-
with the frame bootstrap and the provisioned data ahead of it. The bootstrap's Backend forwards each call to the host over postMessage; the host answers it with the Backend it was given.
|
|
115
|
+
with the frame bootstrap and the provisioned data ahead of it. The bootstrap's Backend forwards each call to the host over postMessage; the host answers it with the Backend it was given. An export's file is saved from the host's page, by following its link: the frame can't download. A Backend without `exportQuery` makes a host that can't export, and its frames see `Anfra.features.export` false.
|
|
91
116
|
|
|
92
117
|
## The boundary
|
|
93
118
|
|
|
94
119
|
A Data App definition can come from any author, often an agent, which may not think of security while building an analysis. So the SDK guards it for its author, and makes the safe way the easy one:
|
|
95
120
|
|
|
96
121
|
- Its frame has an opaque origin. Its own requests to the API are cross-origin, which `anfra serve` refuses, and carry no credentials on a platform that has them.
|
|
97
|
-
- The bridge answers only the Backend's
|
|
122
|
+
- The bridge answers only the Backend's methods, `submitQuery`, `fieldSuggestions` and `exportQuery`, and the two a rendering needs, the renderer's code and saving its file. Nothing else of the API is reachable from a frame.
|
|
98
123
|
- `@holistics/anfra-sdk/app` imports only `common`: the script injected into a frame carries no API client. A test on the built frame script holds this.
|
|
99
124
|
|
|
100
125
|
## Development
|
package/dist/api.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Client } from 'openapi-fetch';
|
|
2
|
-
import { D as DatasetDescriptor, B as Backend,
|
|
2
|
+
import { D as DatasetDescriptor, B as Backend, f as BackendExportRequest, h as BackendQueryRequest } from './types-IR4TNw-M.js';
|
|
3
3
|
import { D as DataAppError } from './errors-CKvqVaqU.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -1348,6 +1348,7 @@ type CoreClient = Client<paths>;
|
|
|
1348
1348
|
declare function coreClient(baseUrl: string, fetchImpl?: typeof fetch): CoreClient;
|
|
1349
1349
|
|
|
1350
1350
|
type QueryRunInput = components['schemas']['QueryRunInput'];
|
|
1351
|
+
type ExportInput = components['schemas']['ExportInput'];
|
|
1351
1352
|
/** How many values a filter control is offered at most. */
|
|
1352
1353
|
declare const SUGGESTION_LIMIT = 100;
|
|
1353
1354
|
interface CoreApiBackendOptions {
|
|
@@ -1359,9 +1360,11 @@ interface CoreApiBackendOptions {
|
|
|
1359
1360
|
}
|
|
1360
1361
|
/** The core.query input a Backend request is: the AQL, its Query Input, its paging and timezone. */
|
|
1361
1362
|
declare function toQueryInput(request: BackendQueryRequest): QueryRunInput;
|
|
1363
|
+
/** The core.query.export input an export request is: the query as a run sends it, and the file. */
|
|
1364
|
+
declare function toExportInput(request: BackendExportRequest): ExportInput;
|
|
1362
1365
|
/**
|
|
1363
|
-
* A Backend over the core API: a Data App's queries as `core.query`,
|
|
1364
|
-
*
|
|
1366
|
+
* A Backend over the core API: a Data App's queries as `core.query`, its field suggestions as one
|
|
1367
|
+
* `core.query` too, and its exports as `core.query.export`. The same on every host: `/api` on `anfra serve`, `/api/o/{org}` on
|
|
1365
1368
|
* anfra-cloud. A host's page uses it; a Data App's frame never does.
|
|
1366
1369
|
*/
|
|
1367
1370
|
declare function coreApiBackend(base: string | CoreClient, options?: CoreApiBackendOptions): Backend;
|
|
@@ -1404,4 +1407,4 @@ interface ApiErrorBody {
|
|
|
1404
1407
|
*/
|
|
1405
1408
|
declare function toSdkError(status: number, body: ApiErrorBody | undefined): DataAppError;
|
|
1406
1409
|
|
|
1407
|
-
export { type ApiErrorBody, type CoreApiBackendOptions, type CoreClient, type LoadedDatasets, SUGGESTION_LIMIT, type components, coreApiBackend, coreClient, loadDatasets, type paths, toDescriptor, toQueryInput, toSdkError };
|
|
1410
|
+
export { type ApiErrorBody, type CoreApiBackendOptions, type CoreClient, type LoadedDatasets, SUGGESTION_LIMIT, type components, coreApiBackend, coreClient, loadDatasets, type paths, toDescriptor, toExportInput, toQueryInput, toSdkError };
|
package/dist/api.js
CHANGED
|
@@ -2,8 +2,7 @@ import {
|
|
|
2
2
|
PermissionError,
|
|
3
3
|
QueryError,
|
|
4
4
|
TransportError
|
|
5
|
-
} from "./chunk-
|
|
6
|
-
import "./chunk-PKBMQBKP.js";
|
|
5
|
+
} from "./chunk-KBXITYQK.js";
|
|
7
6
|
|
|
8
7
|
// src/api/client.ts
|
|
9
8
|
import createClient from "openapi-fetch";
|
|
@@ -59,6 +58,18 @@ function toQueryInput(request) {
|
|
|
59
58
|
...request.timezone ? { timezone: request.timezone } : {}
|
|
60
59
|
};
|
|
61
60
|
}
|
|
61
|
+
function toExportInput(request) {
|
|
62
|
+
return {
|
|
63
|
+
query: request.aql,
|
|
64
|
+
dataset: request.dataset,
|
|
65
|
+
input: request.input,
|
|
66
|
+
...request.timezone ? { timezone: request.timezone } : {},
|
|
67
|
+
// The server names its formats; one it does not know it refuses, with a message the author sees.
|
|
68
|
+
...request.format ? { format: request.format } : {},
|
|
69
|
+
...request.formatOptions ? { format_options: request.formatOptions } : {},
|
|
70
|
+
...request.filename ? { filename: request.filename } : {}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
62
73
|
function toColumns(columns) {
|
|
63
74
|
return columns.map((column) => {
|
|
64
75
|
const { semantic_type: semanticType, arrow_type: arrowType, db_type: dbType } = column;
|
|
@@ -70,10 +81,10 @@ function toColumns(columns) {
|
|
|
70
81
|
return { ...column, semantic_type: semanticType, arrow_type: arrowType, db_type: dbType };
|
|
71
82
|
});
|
|
72
83
|
}
|
|
73
|
-
async function
|
|
84
|
+
async function answer(call) {
|
|
74
85
|
let result;
|
|
75
86
|
try {
|
|
76
|
-
result = await
|
|
87
|
+
result = await call();
|
|
77
88
|
} catch (err) {
|
|
78
89
|
if (isAbort(err)) throw err;
|
|
79
90
|
throw new TransportError(`The server could not be reached: ${err.message}`);
|
|
@@ -82,6 +93,9 @@ async function runQuery(client, body, signal) {
|
|
|
82
93
|
if (!data) throw toSdkError(response.status, error?.error);
|
|
83
94
|
return data;
|
|
84
95
|
}
|
|
96
|
+
function runQuery(client, body, signal) {
|
|
97
|
+
return answer(() => client.POST("/core.query", { body, signal }));
|
|
98
|
+
}
|
|
85
99
|
function coreApiBackend(base, options = {}) {
|
|
86
100
|
const client = typeof base === "string" ? coreClient(base) : base;
|
|
87
101
|
return {
|
|
@@ -127,6 +141,17 @@ function coreApiBackend(base, options = {}) {
|
|
|
127
141
|
page_size: SUGGESTION_LIMIT
|
|
128
142
|
}, signal);
|
|
129
143
|
return data.result.records.map((row) => row[0]).filter((v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean");
|
|
144
|
+
},
|
|
145
|
+
/** An export as `core.query.export`: the server runs the query to completion, then answers its link. */
|
|
146
|
+
async exportQuery(request, signal) {
|
|
147
|
+
const link = await answer(() => client.POST("/core.query.export", { body: toExportInput(request), signal }));
|
|
148
|
+
return {
|
|
149
|
+
url: link.url,
|
|
150
|
+
filename: link.filename,
|
|
151
|
+
format: link.format,
|
|
152
|
+
rowCount: link.row_count,
|
|
153
|
+
expiresAt: new Date(link.expires_at)
|
|
154
|
+
};
|
|
130
155
|
}
|
|
131
156
|
};
|
|
132
157
|
}
|
|
@@ -172,6 +197,7 @@ export {
|
|
|
172
197
|
coreClient,
|
|
173
198
|
loadDatasets,
|
|
174
199
|
toDescriptor,
|
|
200
|
+
toExportInput,
|
|
175
201
|
toQueryInput,
|
|
176
202
|
toSdkError
|
|
177
203
|
};
|
package/dist/app.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export { A as Aggregation, c as ArrowType, d as BackendCondition, e as BackendDateDrill, f as
|
|
1
|
+
import { P as Listener, a9 as Unsubscribe, D as DatasetDescriptor, E as EntityKind, k as Condition, H as FilterDeclaration, I as FilterValueType, B as Backend, l as ConditionValue, o as DateDrillDeclaration, p as DateGrain, G as FilterAggregation, m as ControlMappingJson, n as CrossFilterJson, a3 as SdkFeatures, a4 as Selection, a5 as SelectionCondition, a1 as Row, x as ExportOptions, y as ExportResult, Q as LocateKind, V as QueryDeclaration, Z as QueryState, X as QueryResult, Y as QuerySort, a as AppDeclaration, t as ExecuteOptions, u as ExecuteSummary, b as AppJson, J as InspectedApp, aa as User, a2 as SdkEnvironment, $ as RenderRequest, a0 as Rendering, O as InspectedStructure } from './types-IR4TNw-M.js';
|
|
2
|
+
export { A as Aggregation, c as ArrowType, d as BackendCondition, e as BackendDateDrill, f as BackendExportRequest, g as BackendFilter, h as BackendQueryRequest, i as BackendQueryResult, j as BackendSort, C as ColumnMeta, q as DateTransformation, r as EntityRef, s as EntityUsage, v as ExportFormat, w as ExportFormatOptions, F as FILTER_AGGREGATIONS, z as FieldSuggestionsRequest, K as InspectedControl, L as InspectedError, M as InspectedQuery, N as InspectedSelection, R as LocateTarget, S as MappingJson, T as Operator, U as QueryDebug, W as QueryInput, _ as RenderFormat, a6 as SemanticType, a7 as StructureKind, a8 as StructureNode } from './types-IR4TNw-M.js';
|
|
3
3
|
import { D as DataAppError } from './errors-CKvqVaqU.js';
|
|
4
4
|
export { P as PermissionError, Q as QueryError, T as TransportError, V as ValidationError } from './errors-CKvqVaqU.js';
|
|
5
|
-
export { B as BridgeError, a as BridgeMethod, F as FrameMessage, H as HostMessage, P as PROVISION_ELEMENT_ID, b as Provision } from './bridge-
|
|
5
|
+
export { B as BridgeError, a as BridgeMethod, F as FrameMessage, H as HostMessage, P as PROVISION_ELEMENT_ID, b as Provision } from './bridge-BmbS9J4V.js';
|
|
6
6
|
|
|
7
7
|
interface NotificationTarget {
|
|
8
8
|
notifyChange(): void;
|
|
@@ -190,6 +190,8 @@ interface AppContext {
|
|
|
190
190
|
select(query: Query, rows: readonly Row[], fields?: readonly string[]): void;
|
|
191
191
|
/** Runs one more page for a query that already has a result. */
|
|
192
192
|
fetchPage(query: Query): Promise<void>;
|
|
193
|
+
/** Exports a query's whole result, with what it was last run with. */
|
|
194
|
+
exportQuery(query: Query, options?: ExportOptions): Promise<ExportResult>;
|
|
193
195
|
/** Brings an entity's marked elements into view; false when none is on the page. */
|
|
194
196
|
locate(kind: LocateKind, name: string): boolean;
|
|
195
197
|
}
|
|
@@ -254,6 +256,17 @@ declare class Query extends Observable {
|
|
|
254
256
|
abort(): void;
|
|
255
257
|
/** Fetches the next page and appends it. Rejects rather than swallowing, unlike `execute()`. */
|
|
256
258
|
fetchMore(): Promise<void>;
|
|
259
|
+
/**
|
|
260
|
+
* Exports this query's whole result as a file, and has the host save it for the reader.
|
|
261
|
+
*
|
|
262
|
+
* Every row, not only the pages loaded, with what the result on screen was last run with: the
|
|
263
|
+
* mapped controls, the selection, the sort, the timezone. A control the reader changed but has
|
|
264
|
+
* not applied is not in it. Resolves once the file is made and its download has started, with
|
|
265
|
+
* its link and row count; rejects with a `DataAppError` naming this query, and changes nothing on
|
|
266
|
+
* the query either way. Show the button when `Anfra.features.export` and
|
|
267
|
+
* `Anfra.user.permissions.canExportData` are both true.
|
|
268
|
+
*/
|
|
269
|
+
export(options?: ExportOptions): Promise<ExportResult>;
|
|
257
270
|
/**
|
|
258
271
|
* Cross-filters this query's targets by the rows the reader picked.
|
|
259
272
|
*
|
|
@@ -389,6 +402,11 @@ declare class App extends Observable implements AppContext {
|
|
|
389
402
|
abort(): void;
|
|
390
403
|
/** @internal Backs `query.fetchMore()`. Rejects, so an explicit page request reports its failure. */
|
|
391
404
|
fetchPage(query: Query): Promise<void>;
|
|
405
|
+
/**
|
|
406
|
+
* @internal Backs `query.export()`. Built from the applied state, as `fetchPage` is, so the file
|
|
407
|
+
* holds what the reader is looking at, every row of it. Touches nothing on the query.
|
|
408
|
+
*/
|
|
409
|
+
exportQuery(query: Query, options?: ExportOptions): Promise<ExportResult>;
|
|
392
410
|
/**
|
|
393
411
|
* The declaration and nothing else — no datasets, no backend, no reader state. This is the
|
|
394
412
|
* shape a persisted Data App record will take, which is why mapping edges are already generic.
|
|
@@ -451,9 +469,22 @@ declare class Sdk {
|
|
|
451
469
|
readonly user: Readonly<User>;
|
|
452
470
|
private readonly indices;
|
|
453
471
|
private readonly backend;
|
|
454
|
-
|
|
472
|
+
/**
|
|
473
|
+
* What the host can do beyond queries. `export` says whether `query.export()` can work here, and
|
|
474
|
+
* `render` whether `render()` can: show either's button only when it and
|
|
475
|
+
* `user.permissions.canExportData` are both true.
|
|
476
|
+
*/
|
|
477
|
+
readonly features: Readonly<SdkFeatures>;
|
|
478
|
+
private readonly renderHook?;
|
|
455
479
|
constructor(environment: SdkEnvironment);
|
|
456
480
|
createApp(declaration?: AppDeclaration): App;
|
|
481
|
+
/**
|
|
482
|
+
* Render the whole app, or one block or container of its structure by id, as a PNG or a PDF, and
|
|
483
|
+
* have the host save it for the reader. What is drawn is what the page shows now: `await
|
|
484
|
+
* app.execute()` first for fresh results. Elements marked `data-anfra-render="skip"` are left out.
|
|
485
|
+
* Rejects with a `ValidationError` naming what to fix.
|
|
486
|
+
*/
|
|
487
|
+
render(request: RenderRequest): Promise<Rendering>;
|
|
457
488
|
/**
|
|
458
489
|
* Every app this instance has built, for a host rendering an inspection panel.
|
|
459
490
|
*
|
|
@@ -524,4 +555,4 @@ declare function buildStructure(doc: Document, apps: readonly App[]): BuiltStruc
|
|
|
524
555
|
/** Provision the SDK in `win` from its host, and install it. Returns once `Anfra` exists. */
|
|
525
556
|
declare function bootstrap(win: Window): void;
|
|
526
557
|
|
|
527
|
-
export { App, AppDeclaration, AppJson, Backend, Condition, ConditionValue, Control, ControlMappingJson, CrossFilter, CrossFilterJson, DataAppError, DatasetDescriptor, DateDrillControl, DateDrillDeclaration, DateGrain, type Edge, EntityKind, ExecuteOptions, ExecuteSummary, Filter, FilterAggregation, FilterDeclaration, FilterValueType, InspectedApp, InspectedStructure, Listener, LocateKind, Mapping, OVERLAY_ID, Query, QueryDeclaration, QueryResult, QuerySort, QueryState, Row, SANDBOX_GLOBAL, ATTR as STRUCTURE_ATTRIBUTES, Sdk, SdkEnvironment, SdkFeatures, Selection, SelectionCondition, Unsubscribe, User, VERSION, bootstrap, buildStructure, createSdk, installSandbox, parseRef };
|
|
558
|
+
export { App, AppDeclaration, AppJson, Backend, Condition, ConditionValue, Control, ControlMappingJson, CrossFilter, CrossFilterJson, DataAppError, DatasetDescriptor, DateDrillControl, DateDrillDeclaration, DateGrain, type Edge, EntityKind, ExecuteOptions, ExecuteSummary, ExportOptions, ExportResult, Filter, FilterAggregation, FilterDeclaration, FilterValueType, InspectedApp, InspectedStructure, Listener, LocateKind, Mapping, OVERLAY_ID, Query, QueryDeclaration, QueryResult, QuerySort, QueryState, RenderRequest, Rendering, Row, SANDBOX_GLOBAL, ATTR as STRUCTURE_ATTRIBUTES, Sdk, SdkEnvironment, SdkFeatures, Selection, SelectionCondition, Unsubscribe, User, VERSION, bootstrap, buildStructure, createSdk, installSandbox, parseRef };
|
package/dist/app.js
CHANGED
|
@@ -9,11 +9,9 @@ import {
|
|
|
9
9
|
PermissionError,
|
|
10
10
|
QueryError,
|
|
11
11
|
TransportError,
|
|
12
|
-
ValidationError
|
|
13
|
-
} from "./chunk-KCTLO3CO.js";
|
|
14
|
-
import {
|
|
12
|
+
ValidationError,
|
|
15
13
|
__publicField
|
|
16
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-KBXITYQK.js";
|
|
17
15
|
|
|
18
16
|
// src/app/observable.ts
|
|
19
17
|
var Observable = class {
|
|
@@ -169,6 +167,19 @@ var Query = class extends Observable {
|
|
|
169
167
|
this._page += 1;
|
|
170
168
|
await this.app.fetchPage(this);
|
|
171
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* Exports this query's whole result as a file, and has the host save it for the reader.
|
|
172
|
+
*
|
|
173
|
+
* Every row, not only the pages loaded, with what the result on screen was last run with: the
|
|
174
|
+
* mapped controls, the selection, the sort, the timezone. A control the reader changed but has
|
|
175
|
+
* not applied is not in it. Resolves once the file is made and its download has started, with
|
|
176
|
+
* its link and row count; rejects with a `DataAppError` naming this query, and changes nothing on
|
|
177
|
+
* the query either way. Show the button when `Anfra.features.export` and
|
|
178
|
+
* `Anfra.user.permissions.canExportData` are both true.
|
|
179
|
+
*/
|
|
180
|
+
export(options = {}) {
|
|
181
|
+
return this.app.exportQuery(this, options);
|
|
182
|
+
}
|
|
172
183
|
/**
|
|
173
184
|
* Cross-filters this query's targets by the rows the reader picked.
|
|
174
185
|
*
|
|
@@ -624,7 +635,7 @@ function toResult(query, data) {
|
|
|
624
635
|
...data.debug ? { debug: data.debug } : {}
|
|
625
636
|
};
|
|
626
637
|
}
|
|
627
|
-
function
|
|
638
|
+
function buildQuery(query, mappings, selection, context) {
|
|
628
639
|
const mappedFilters = mappings.map((mapping) => toFilter(mapping, mapping.from.appliedCondition)).filter((filter) => filter !== void 0);
|
|
629
640
|
const dateDrills = mappings.map((mapping) => toDateDrill(mapping, mapping.from.appliedCondition)).filter((drill) => drill !== void 0);
|
|
630
641
|
const selectionFilters = selection.conditions.map((condition) => ({
|
|
@@ -642,14 +653,33 @@ function buildRequest(query, mappings, selection, context) {
|
|
|
642
653
|
sorts: query.sort.map((sort) => ({ field: sort.field, direction: sort.direction ?? "asc" })),
|
|
643
654
|
dateDrills
|
|
644
655
|
},
|
|
645
|
-
// Omitted for a query that declares no page size, so the backend returns every row.
|
|
646
|
-
...query.pageSize !== void 0 ? { page: query.page, pageSize: query.pageSize } : {},
|
|
647
656
|
// Omitted entirely when the app declares none, so the backend applies its own default rather
|
|
648
657
|
// than the SDK guessing the browser's.
|
|
649
|
-
...context.timezone ? { timezone: context.timezone } : {}
|
|
658
|
+
...context.timezone ? { timezone: context.timezone } : {}
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
function buildRequest(query, mappings, selection, context) {
|
|
662
|
+
return {
|
|
663
|
+
...buildQuery(query, mappings, selection, context),
|
|
664
|
+
// Omitted for a query that declares no page size, so the backend returns every row.
|
|
665
|
+
...query.pageSize !== void 0 ? { page: query.page, pageSize: query.pageSize } : {},
|
|
650
666
|
...context.bustCache ? { bustCache: true } : {}
|
|
651
667
|
};
|
|
652
668
|
}
|
|
669
|
+
function defaultExportFilename(query, title) {
|
|
670
|
+
const safeTitle = title?.replace(/[/\\]/g, "-").replace(/\p{Cc}/gu, "").trim();
|
|
671
|
+
return safeTitle ? `${safeTitle} - ${query.name}` : query.name;
|
|
672
|
+
}
|
|
673
|
+
function buildExportRequest(query, mappings, selection, context, options) {
|
|
674
|
+
const format2 = options.format ?? "csv";
|
|
675
|
+
const formatOptions = format2 === "csv" ? { bom: true, ...options.formatOptions } : options.formatOptions;
|
|
676
|
+
return {
|
|
677
|
+
...buildQuery(query, mappings, selection, context),
|
|
678
|
+
format: format2,
|
|
679
|
+
...formatOptions ? { formatOptions } : {},
|
|
680
|
+
filename: options.filename?.trim() || defaultExportFilename(query, context.title)
|
|
681
|
+
};
|
|
682
|
+
}
|
|
653
683
|
async function runQuery(query, mappings, selection, context, signal) {
|
|
654
684
|
const request = buildRequest(query, mappings, selection, context);
|
|
655
685
|
const data = await context.backend.submitQuery(request, signal);
|
|
@@ -1204,6 +1234,14 @@ function toDataAppError(err, entity) {
|
|
|
1204
1234
|
if (err instanceof DataAppError) return err;
|
|
1205
1235
|
return new QueryError(err?.message ?? "The query failed.", entity, err);
|
|
1206
1236
|
}
|
|
1237
|
+
function toExportError(err, entity) {
|
|
1238
|
+
if (err instanceof DataAppError && err.entity) return err;
|
|
1239
|
+
const message = err?.message || "The export failed.";
|
|
1240
|
+
if (err instanceof PermissionError) return new PermissionError(message, entity);
|
|
1241
|
+
if (err instanceof TransportError) return new TransportError(message, err.status, entity);
|
|
1242
|
+
if (err instanceof ValidationError) return new ValidationError(message, entity);
|
|
1243
|
+
return new QueryError(message, entity, err);
|
|
1244
|
+
}
|
|
1207
1245
|
var App = class extends Observable {
|
|
1208
1246
|
constructor(declaration, datasets, backend, features) {
|
|
1209
1247
|
super();
|
|
@@ -1547,6 +1585,32 @@ var App = class extends Observable {
|
|
|
1547
1585
|
if (query.controller === controller) query.controller = void 0;
|
|
1548
1586
|
}
|
|
1549
1587
|
}
|
|
1588
|
+
/**
|
|
1589
|
+
* @internal Backs `query.export()`. Built from the applied state, as `fetchPage` is, so the file
|
|
1590
|
+
* holds what the reader is looking at, every row of it. Touches nothing on the query.
|
|
1591
|
+
*/
|
|
1592
|
+
async exportQuery(query, options = {}) {
|
|
1593
|
+
const { backend } = this;
|
|
1594
|
+
if (this.features?.export === false || !backend.exportQuery) {
|
|
1595
|
+
throw new TransportError("This host cannot make exports.", void 0, query.name);
|
|
1596
|
+
}
|
|
1597
|
+
const request = buildExportRequest(
|
|
1598
|
+
query,
|
|
1599
|
+
this.mappingsTo(query.name),
|
|
1600
|
+
{
|
|
1601
|
+
conditions: this.selectionConditionsFor(query.name, "applied"),
|
|
1602
|
+
expression: this.selectionExpressionFor(query.name, "applied")
|
|
1603
|
+
},
|
|
1604
|
+
{ backend, timezone: this._timezone, title: this.title },
|
|
1605
|
+
options
|
|
1606
|
+
);
|
|
1607
|
+
try {
|
|
1608
|
+
return await backend.exportQuery(request, options.signal ?? new AbortController().signal);
|
|
1609
|
+
} catch (err) {
|
|
1610
|
+
if (isAbort(err)) throw err;
|
|
1611
|
+
throw toExportError(err, query.name);
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1550
1614
|
/* ----------------------------------------------------------------
|
|
1551
1615
|
Serialisation
|
|
1552
1616
|
---------------------------------------------------------------- */
|
|
@@ -1661,7 +1725,7 @@ var App = class extends Observable {
|
|
|
1661
1725
|
};
|
|
1662
1726
|
|
|
1663
1727
|
// package.json
|
|
1664
|
-
var version = "0.
|
|
1728
|
+
var version = "0.8.0";
|
|
1665
1729
|
|
|
1666
1730
|
// src/app/sdk.ts
|
|
1667
1731
|
var VERSION = version;
|
|
@@ -1683,7 +1747,13 @@ var Sdk = class {
|
|
|
1683
1747
|
__publicField(this, "user");
|
|
1684
1748
|
__publicField(this, "indices");
|
|
1685
1749
|
__publicField(this, "backend");
|
|
1750
|
+
/**
|
|
1751
|
+
* What the host can do beyond queries. `export` says whether `query.export()` can work here, and
|
|
1752
|
+
* `render` whether `render()` can: show either's button only when it and
|
|
1753
|
+
* `user.permissions.canExportData` are both true.
|
|
1754
|
+
*/
|
|
1686
1755
|
__publicField(this, "features");
|
|
1756
|
+
__publicField(this, "renderHook");
|
|
1687
1757
|
if (!environment?.datasets) {
|
|
1688
1758
|
throw new ValidationError(
|
|
1689
1759
|
"No datasets provided. The host must call createSdk({ datasets }) before an app can be declared."
|
|
@@ -1711,7 +1781,14 @@ var Sdk = class {
|
|
|
1711
1781
|
])
|
|
1712
1782
|
);
|
|
1713
1783
|
this.backend = environment.backend;
|
|
1714
|
-
this.
|
|
1784
|
+
this.renderHook = environment.render;
|
|
1785
|
+
this.features = Object.freeze({
|
|
1786
|
+
...environment.features,
|
|
1787
|
+
// Known from the Backend itself when the host does not say. In a frame the bridge's Backend
|
|
1788
|
+
// has every method, so there it is the host's provision that says.
|
|
1789
|
+
export: environment.features?.export ?? typeof environment.backend.exportQuery === "function",
|
|
1790
|
+
render: environment.features?.render ?? typeof environment.render === "function"
|
|
1791
|
+
});
|
|
1715
1792
|
}
|
|
1716
1793
|
createApp(declaration = {}) {
|
|
1717
1794
|
const app = new App(declaration, this.indices, this.backend, this.features);
|
|
@@ -1719,6 +1796,18 @@ var Sdk = class {
|
|
|
1719
1796
|
this._apps.push(app);
|
|
1720
1797
|
return app;
|
|
1721
1798
|
}
|
|
1799
|
+
/**
|
|
1800
|
+
* Render the whole app, or one block or container of its structure by id, as a PNG or a PDF, and
|
|
1801
|
+
* have the host save it for the reader. What is drawn is what the page shows now: `await
|
|
1802
|
+
* app.execute()` first for fresh results. Elements marked `data-anfra-render="skip"` are left out.
|
|
1803
|
+
* Rejects with a `ValidationError` naming what to fix.
|
|
1804
|
+
*/
|
|
1805
|
+
render(request) {
|
|
1806
|
+
if (!this.renderHook || this.features.render === false) {
|
|
1807
|
+
return Promise.reject(new ValidationError("This host does not render Data Apps: check Anfra.features.render first."));
|
|
1808
|
+
}
|
|
1809
|
+
return this.renderHook(request);
|
|
1810
|
+
}
|
|
1722
1811
|
/**
|
|
1723
1812
|
* Every app this instance has built, for a host rendering an inspection panel.
|
|
1724
1813
|
*
|
|
@@ -1780,6 +1869,92 @@ function startPick(doc, handlers) {
|
|
|
1780
1869
|
};
|
|
1781
1870
|
}
|
|
1782
1871
|
|
|
1872
|
+
// src/render/types.ts
|
|
1873
|
+
var RENDERER_GLOBAL = "__anfraRenderer";
|
|
1874
|
+
|
|
1875
|
+
// src/app/render.ts
|
|
1876
|
+
var SKIP_ATTRIBUTE = "data-anfra-render";
|
|
1877
|
+
var EXTENSIONS = { png: "png", pdf: "pdf" };
|
|
1878
|
+
var SETTLE_MS = 1e4;
|
|
1879
|
+
function filenameFor(request, doc, apps, element) {
|
|
1880
|
+
const ext = EXTENSIONS[request.format];
|
|
1881
|
+
let name = request.filename?.trim();
|
|
1882
|
+
if (!name) {
|
|
1883
|
+
const title = apps.find((app) => app.title)?.title || doc.title.trim() || "Data App";
|
|
1884
|
+
const node = element && (element.getAttribute(ATTR.label) || request.block || request.container);
|
|
1885
|
+
name = node ? `${title} - ${node}` : title;
|
|
1886
|
+
}
|
|
1887
|
+
name = name.replace(/[/\\]/g, "-").replace(/\p{Cc}/gu, "").trim() || "Data App";
|
|
1888
|
+
return name.toLowerCase().endsWith(`.${ext}`) ? name : `${name}.${ext}`;
|
|
1889
|
+
}
|
|
1890
|
+
function targetOf(request, doc) {
|
|
1891
|
+
if (request.block !== void 0 && request.container !== void 0) {
|
|
1892
|
+
throw new ValidationError("Render a block or a container, not both.", request.block);
|
|
1893
|
+
}
|
|
1894
|
+
if (request.format !== "png" && request.format !== "pdf") {
|
|
1895
|
+
throw new ValidationError(`Can't render as '${String(request.format)}': the formats are 'png' and 'pdf'.`);
|
|
1896
|
+
}
|
|
1897
|
+
const { pixelRatio } = request;
|
|
1898
|
+
if (pixelRatio !== void 0 && !(Number.isFinite(pixelRatio) && pixelRatio > 0)) {
|
|
1899
|
+
throw new ValidationError(`pixelRatio ${String(pixelRatio)} is not a positive number.`);
|
|
1900
|
+
}
|
|
1901
|
+
const kind = request.block !== void 0 ? "block" : request.container !== void 0 ? "container" : void 0;
|
|
1902
|
+
if (!kind) return { element: null };
|
|
1903
|
+
const id = request.block ?? request.container;
|
|
1904
|
+
const element = [...doc.querySelectorAll(`[${ATTR[kind]}]`)].find((el) => el.getAttribute(ATTR[kind]) === id);
|
|
1905
|
+
if (!element) throw new ValidationError(`No element is marked ${ATTR[kind]}="${id}" on the page.`, id);
|
|
1906
|
+
return { element, entity: id };
|
|
1907
|
+
}
|
|
1908
|
+
function skip(element) {
|
|
1909
|
+
return element.id === OVERLAY_ID || element.getAttribute(SKIP_ATTRIBUTE) === "skip";
|
|
1910
|
+
}
|
|
1911
|
+
function createRender(win, deps) {
|
|
1912
|
+
let loading;
|
|
1913
|
+
const load = () => {
|
|
1914
|
+
const ready = win[RENDERER_GLOBAL];
|
|
1915
|
+
if (ready) return Promise.resolve(ready);
|
|
1916
|
+
loading ?? (loading = deps.ask.renderer().then((code) => {
|
|
1917
|
+
const script = win.document.createElement("script");
|
|
1918
|
+
script.textContent = code;
|
|
1919
|
+
(win.document.head ?? win.document.documentElement).appendChild(script);
|
|
1920
|
+
script.remove();
|
|
1921
|
+
const installed = win[RENDERER_GLOBAL];
|
|
1922
|
+
if (!installed) throw new ValidationError("The host sent a renderer that did not install.");
|
|
1923
|
+
return installed;
|
|
1924
|
+
}).catch((err) => {
|
|
1925
|
+
loading = void 0;
|
|
1926
|
+
throw err;
|
|
1927
|
+
}));
|
|
1928
|
+
return loading;
|
|
1929
|
+
};
|
|
1930
|
+
return async (request) => {
|
|
1931
|
+
const doc = win.document;
|
|
1932
|
+
const { element, entity } = targetOf(request ?? {}, doc);
|
|
1933
|
+
const filename = filenameFor(request, doc, deps.apps(), element);
|
|
1934
|
+
try {
|
|
1935
|
+
const renderer = await load();
|
|
1936
|
+
const drawing = await renderer.draw(element, { format: request.format, pixelRatio: request.pixelRatio, skip });
|
|
1937
|
+
await deps.ask.saveFile({ blob: drawing.blob, filename });
|
|
1938
|
+
return { filename, format: request.format, ...drawing };
|
|
1939
|
+
} catch (err) {
|
|
1940
|
+
if (err instanceof ValidationError && err.entity) throw err;
|
|
1941
|
+
throw new ValidationError(err?.message || "The rendering failed.", entity);
|
|
1942
|
+
}
|
|
1943
|
+
};
|
|
1944
|
+
}
|
|
1945
|
+
async function settle(win, apps, timeout = SETTLE_MS) {
|
|
1946
|
+
const busy = () => apps().some((app) => Object.values(app.queries).some((query) => query.state === "executing"));
|
|
1947
|
+
const deadline = Date.now() + timeout;
|
|
1948
|
+
while (busy() && Date.now() < deadline) {
|
|
1949
|
+
await new Promise((resolve) => {
|
|
1950
|
+
win.setTimeout(resolve, 100);
|
|
1951
|
+
});
|
|
1952
|
+
}
|
|
1953
|
+
await new Promise((resolve) => {
|
|
1954
|
+
win.requestAnimationFrame(() => resolve(void 0));
|
|
1955
|
+
});
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1783
1958
|
// src/app/bootstrap.ts
|
|
1784
1959
|
function abortError() {
|
|
1785
1960
|
const err = new Error("Aborted");
|
|
@@ -1792,6 +1967,8 @@ function toError(error) {
|
|
|
1792
1967
|
return new PermissionError(error.message);
|
|
1793
1968
|
case "TransportError":
|
|
1794
1969
|
return new TransportError(error.message);
|
|
1970
|
+
case "ValidationError":
|
|
1971
|
+
return new ValidationError(error.message);
|
|
1795
1972
|
default:
|
|
1796
1973
|
return new QueryError(error?.message || "The query failed.");
|
|
1797
1974
|
}
|
|
@@ -1829,6 +2006,14 @@ function bootstrap(win) {
|
|
|
1829
2006
|
case "anfra:pick-watch":
|
|
1830
2007
|
setPicking(message.on);
|
|
1831
2008
|
return;
|
|
2009
|
+
case "anfra:render": {
|
|
2010
|
+
const { id } = message;
|
|
2011
|
+
settle(win, () => instance.apps).then(() => instance.render(message.request)).then(
|
|
2012
|
+
(result) => post({ type: "anfra:rendered", id, ok: true, result }),
|
|
2013
|
+
(err) => post({ type: "anfra:rendered", id, ok: false, error: { name: err?.name ?? "ValidationError", message: err?.message ?? "The rendering failed." } })
|
|
2014
|
+
);
|
|
2015
|
+
return;
|
|
2016
|
+
}
|
|
1832
2017
|
default:
|
|
1833
2018
|
}
|
|
1834
2019
|
});
|
|
@@ -1853,9 +2038,28 @@ function bootstrap(win) {
|
|
|
1853
2038
|
if (result.debug?.executedAt) result.debug.executedAt = new Date(result.debug.executedAt);
|
|
1854
2039
|
return result;
|
|
1855
2040
|
}),
|
|
1856
|
-
fieldSuggestions: (request, signal) => call("fieldSuggestions", request, signal)
|
|
2041
|
+
fieldSuggestions: (request, signal) => call("fieldSuggestions", request, signal),
|
|
2042
|
+
exportQuery: (request, signal) => call("exportQuery", request, signal).then((result) => {
|
|
2043
|
+
result.expiresAt = new Date(result.expiresAt);
|
|
2044
|
+
return result;
|
|
2045
|
+
})
|
|
1857
2046
|
};
|
|
1858
|
-
const
|
|
2047
|
+
const render = createRender(win, {
|
|
2048
|
+
ask: {
|
|
2049
|
+
renderer: () => call("renderer", null, new AbortController().signal),
|
|
2050
|
+
saveFile: (request) => call("saveFile", request, new AbortController().signal)
|
|
2051
|
+
},
|
|
2052
|
+
apps: () => instance.apps
|
|
2053
|
+
});
|
|
2054
|
+
const instance = createSdk({
|
|
2055
|
+
datasets: provision.datasets,
|
|
2056
|
+
user: provision.user,
|
|
2057
|
+
backend,
|
|
2058
|
+
render,
|
|
2059
|
+
// A host older than `features` provisions none: say it can neither export nor render, rather
|
|
2060
|
+
// than let the bridge's Backend, which has every method, claim an export.
|
|
2061
|
+
features: { export: false, render: false, ...provision.features }
|
|
2062
|
+
});
|
|
1859
2063
|
const doc = win.document;
|
|
1860
2064
|
let structure;
|
|
1861
2065
|
let lastStructureJson = "";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { h as BackendQueryRequest, z as FieldSuggestionsRequest, f as BackendExportRequest, a0 as Rendering, J as InspectedApp, O as InspectedStructure, R as LocateTarget, $ as RenderRequest, D as DatasetDescriptor, aa as User, a3 as SdkFeatures } from './types-IR4TNw-M.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* The bridge: the postMessage protocol between a Data App's frame (`app`'s bootstrap) and the
|
|
@@ -6,12 +6,22 @@ import { g as BackendQueryRequest, u as FieldSuggestionsRequest, I as InspectedA
|
|
|
6
6
|
* makes to its Backend crosses here, and the host answers it.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
|
|
9
|
+
/**
|
|
10
|
+
* What a frame may ask its host: the Backend's methods, and the two a rendering needs (the
|
|
11
|
+
* renderer's code, and saving a file). The bridge serves these and nothing else.
|
|
12
|
+
*/
|
|
13
|
+
type BridgeMethod = 'submitQuery' | 'fieldSuggestions' | 'exportQuery' | 'renderer' | 'saveFile';
|
|
14
|
+
/** A file the frame made, for the host to save for the reader: the frame cannot download. */
|
|
15
|
+
interface SaveFileRequest {
|
|
16
|
+
blob: Blob;
|
|
17
|
+
filename: string;
|
|
18
|
+
}
|
|
11
19
|
/** What the host provisions a frame with, as JSON in `<script type="application/json" id="anfra-provision">`. */
|
|
12
20
|
interface Provision {
|
|
13
21
|
datasets: Record<string, DatasetDescriptor>;
|
|
14
22
|
user: User;
|
|
23
|
+
/** What the host can do beyond queries: exports, rendering. Absent from a host older than this field. */
|
|
24
|
+
features?: SdkFeatures;
|
|
15
25
|
/** The host page's origin: where the frame posts, so nothing else can read its messages. */
|
|
16
26
|
hostOrigin: string;
|
|
17
27
|
}
|
|
@@ -29,6 +39,37 @@ type FrameMessage = {
|
|
|
29
39
|
id: number;
|
|
30
40
|
method: 'fieldSuggestions';
|
|
31
41
|
request: FieldSuggestionsRequest;
|
|
42
|
+
}
|
|
43
|
+
/** The host makes the file, starts saving it for the reader, then answers its link. */
|
|
44
|
+
| {
|
|
45
|
+
type: 'anfra:request';
|
|
46
|
+
id: number;
|
|
47
|
+
method: 'exportQuery';
|
|
48
|
+
request: BackendExportRequest;
|
|
49
|
+
}
|
|
50
|
+
/** The renderer's code, asked for on the frame's first rendering; answered as a string. */
|
|
51
|
+
| {
|
|
52
|
+
type: 'anfra:request';
|
|
53
|
+
id: number;
|
|
54
|
+
method: 'renderer';
|
|
55
|
+
request: null;
|
|
56
|
+
} | {
|
|
57
|
+
type: 'anfra:request';
|
|
58
|
+
id: number;
|
|
59
|
+
method: 'saveFile';
|
|
60
|
+
request: SaveFileRequest;
|
|
61
|
+
}
|
|
62
|
+
/** The answer to the host's `anfra:render`, by its id. */
|
|
63
|
+
| {
|
|
64
|
+
type: 'anfra:rendered';
|
|
65
|
+
id: number;
|
|
66
|
+
ok: true;
|
|
67
|
+
result: Rendering;
|
|
68
|
+
} | {
|
|
69
|
+
type: 'anfra:rendered';
|
|
70
|
+
id: number;
|
|
71
|
+
ok: false;
|
|
72
|
+
error: BridgeError;
|
|
32
73
|
} | {
|
|
33
74
|
type: 'anfra:cancel';
|
|
34
75
|
id: number;
|
|
@@ -76,6 +117,12 @@ type HostMessage = {
|
|
|
76
117
|
| {
|
|
77
118
|
type: 'anfra:pick-watch';
|
|
78
119
|
on: boolean;
|
|
120
|
+
}
|
|
121
|
+
/** Render the app or one node, once its queries settle, and save it; the frame answers `anfra:rendered`. */
|
|
122
|
+
| {
|
|
123
|
+
type: 'anfra:render';
|
|
124
|
+
id: number;
|
|
125
|
+
request: RenderRequest;
|
|
79
126
|
};
|
|
80
127
|
|
|
81
128
|
export { type BridgeError as B, type FrameMessage as F, type HostMessage as H, PROVISION_ELEMENT_ID as P, type BridgeMethod as a, type Provision as b };
|