@datalayer/core 1.2.22 → 1.2.24
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 +4 -3
- package/lib/api/DatalayerApi.d.ts +16 -7
- package/lib/api/DatalayerApi.js +157 -121
- package/lib/api/index.d.ts +1 -1
- package/lib/components/buttons/CopyToClipboardButton.d.ts +13 -0
- package/lib/components/buttons/CopyToClipboardButton.js +73 -0
- package/lib/components/buttons/index.d.ts +1 -0
- package/lib/components/buttons/index.js +1 -0
- package/lib/hooks/deckSpec.d.ts +2 -0
- package/lib/hooks/deckSpec.js +22 -0
- package/lib/hooks/useCache.d.ts +48 -0
- package/lib/hooks/useCache.js +111 -2
- package/lib/hooks/useUpload.d.ts +7 -0
- package/lib/hooks/useUpload.js +13 -17
- package/lib/index.d.ts +1 -1
- package/lib/otel/hooks/index.js +1 -1
- package/lib/state/substates/NavigationState.js +14 -0
- package/lib/views/profile/UserIdentity.d.ts +41 -0
- package/lib/views/profile/UserIdentity.js +9 -0
- package/lib/views/profile/index.d.ts +2 -0
- package/lib/views/profile/index.js +1 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -100,10 +100,11 @@ Use `agent-runtimes` for runtime workloads (`RuntimeClient`, `@datalayer`, snaps
|
|
|
100
100
|
|
|
101
101
|
## Examples
|
|
102
102
|
|
|
103
|
-
|
|
103
|
+
The [examples](./examples/README.md) show the Datalayer Client at work:
|
|
104
104
|
|
|
105
|
-
- [
|
|
106
|
-
|
|
105
|
+
- [OpenTelemetry observability](./examples/otel/README.md): instrument Datalayer usage with OpenTelemetry traces and metrics, with a small FastAPI service and a React UI to visualize them (`make` targets in [examples/otel](./examples/otel)).
|
|
106
|
+
|
|
107
|
+
For agents, chats and sandboxes built on the client, see the [Agent Runtimes examples](https://github.com/datalayer/agent-runtimes/tree/main/examples).
|
|
107
108
|
|
|
108
109
|
## Platform Integration
|
|
109
110
|
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { AxiosRequestConfig } from 'axios';
|
|
2
1
|
/**
|
|
3
2
|
* Error wrapper for failed HTTP responses.
|
|
4
3
|
* Includes response details, warnings, errors, and tracebacks.
|
|
@@ -44,15 +43,15 @@ export declare class RunResponseError extends Error {
|
|
|
44
43
|
}
|
|
45
44
|
/**
|
|
46
45
|
* Error wrapper for network failures.
|
|
47
|
-
* Thrown when
|
|
46
|
+
* Thrown when `fetch` could not reach the server at all.
|
|
48
47
|
*/
|
|
49
48
|
export declare class NetworkError extends TypeError {
|
|
50
49
|
/**
|
|
51
|
-
* Creates a NetworkError from the
|
|
50
|
+
* Creates a NetworkError from the error `fetch` rejected with.
|
|
52
51
|
*
|
|
53
52
|
* @param original - The original network error
|
|
54
53
|
*/
|
|
55
|
-
constructor(original:
|
|
54
|
+
constructor(original: Error);
|
|
56
55
|
}
|
|
57
56
|
/**
|
|
58
57
|
* Thrown when a request is made with a JWT that has already expired.
|
|
@@ -85,6 +84,15 @@ export declare class TokenExpiredError extends Error {
|
|
|
85
84
|
readonly url: string;
|
|
86
85
|
constructor(url: string, expiredAt: number);
|
|
87
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* How the response body is read.
|
|
89
|
+
*
|
|
90
|
+
* `json` (the default) parses the body as JSON and falls back to the text
|
|
91
|
+
* when it does not parse, so an empty body or a plain-text answer still
|
|
92
|
+
* arrives as data rather than an error. `stream` hands back the body's
|
|
93
|
+
* `ReadableStream` unread.
|
|
94
|
+
*/
|
|
95
|
+
export type DatalayerAPIResponseType = 'json' | 'text' | 'blob' | 'arraybuffer' | 'stream';
|
|
88
96
|
/**
|
|
89
97
|
* Options for Datalayer API requests.
|
|
90
98
|
*/
|
|
@@ -93,7 +101,7 @@ export interface IRequestDatalayerAPIOptions {
|
|
|
93
101
|
url: string;
|
|
94
102
|
/** HTTP method (GET, POST, PUT, DELETE, etc.) */
|
|
95
103
|
method?: string;
|
|
96
|
-
/** Request body (JSON object or
|
|
104
|
+
/** Request body (JSON object, FormData, string, or raw bytes) */
|
|
97
105
|
body?: any;
|
|
98
106
|
/** Custom HTTP headers */
|
|
99
107
|
headers?: Record<string, string>;
|
|
@@ -101,8 +109,8 @@ export interface IRequestDatalayerAPIOptions {
|
|
|
101
109
|
token?: string;
|
|
102
110
|
/** AbortSignal for request cancellation */
|
|
103
111
|
signal?: AbortSignal;
|
|
104
|
-
/**
|
|
105
|
-
responseType?:
|
|
112
|
+
/** How to read the body; `json` when omitted. */
|
|
113
|
+
responseType?: DatalayerAPIResponseType;
|
|
106
114
|
}
|
|
107
115
|
export interface IDatalayerAPIResponse<T> {
|
|
108
116
|
data: T;
|
|
@@ -118,6 +126,7 @@ export interface IDatalayerAPIResponse<T> {
|
|
|
118
126
|
* @returns Promise resolving to response data
|
|
119
127
|
* @throws {NetworkError} On network failures
|
|
120
128
|
* @throws {RunResponseError} On HTTP error responses
|
|
129
|
+
* @throws {TokenExpiredError} On a 401 explained by an expired token
|
|
121
130
|
*
|
|
122
131
|
* @example
|
|
123
132
|
* ```typescript
|
package/lib/api/DatalayerApi.js
CHANGED
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
* @module api/DatalayerApi
|
|
10
10
|
*/
|
|
11
11
|
import { URLExt } from '@jupyterlab/coreutils';
|
|
12
|
-
import axios from 'axios';
|
|
13
12
|
import { sleep } from '../utils/Sleep.js';
|
|
14
13
|
import { getJwtExpiryMs, isJwtExpired } from '../utils/Jwt.js';
|
|
15
14
|
function isFormDataBody(body) {
|
|
@@ -106,11 +105,11 @@ export class RunResponseError extends Error {
|
|
|
106
105
|
}
|
|
107
106
|
/**
|
|
108
107
|
* Error wrapper for network failures.
|
|
109
|
-
* Thrown when
|
|
108
|
+
* Thrown when `fetch` could not reach the server at all.
|
|
110
109
|
*/
|
|
111
110
|
export class NetworkError extends TypeError {
|
|
112
111
|
/**
|
|
113
|
-
* Creates a NetworkError from the
|
|
112
|
+
* Creates a NetworkError from the error `fetch` rejected with.
|
|
114
113
|
*
|
|
115
114
|
* @param original - The original network error
|
|
116
115
|
*/
|
|
@@ -189,6 +188,80 @@ function throwIfTokenExpired(status, token, url) {
|
|
|
189
188
|
}
|
|
190
189
|
throw new TokenExpiredError(url, getJwtExpiryMs(token) ?? Date.now());
|
|
191
190
|
}
|
|
191
|
+
/**
|
|
192
|
+
* Bodies `fetch` sends as they are. Everything else is a JSON value and is
|
|
193
|
+
* stringified.
|
|
194
|
+
*/
|
|
195
|
+
function isRawBody(body) {
|
|
196
|
+
return (typeof body === 'string' ||
|
|
197
|
+
(typeof Blob !== 'undefined' && body instanceof Blob) ||
|
|
198
|
+
body instanceof ArrayBuffer ||
|
|
199
|
+
ArrayBuffer.isView(body) ||
|
|
200
|
+
(typeof URLSearchParams !== 'undefined' &&
|
|
201
|
+
body instanceof URLSearchParams) ||
|
|
202
|
+
(typeof ReadableStream !== 'undefined' && body instanceof ReadableStream));
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* The response headers as a plain record, keyed in lower case the way
|
|
206
|
+
* `Headers` reports them.
|
|
207
|
+
*/
|
|
208
|
+
function headersToRecord(headers) {
|
|
209
|
+
const record = {};
|
|
210
|
+
headers.forEach((value, name) => {
|
|
211
|
+
record[name] = value;
|
|
212
|
+
});
|
|
213
|
+
return record;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Reads the body the way `responseType` asks for.
|
|
217
|
+
*/
|
|
218
|
+
async function readBody(response, responseType) {
|
|
219
|
+
switch (responseType) {
|
|
220
|
+
case 'text':
|
|
221
|
+
return response.text();
|
|
222
|
+
case 'blob':
|
|
223
|
+
return response.blob();
|
|
224
|
+
case 'arraybuffer':
|
|
225
|
+
return response.arrayBuffer();
|
|
226
|
+
case 'stream':
|
|
227
|
+
return response.body;
|
|
228
|
+
default: {
|
|
229
|
+
const text = await response.text();
|
|
230
|
+
try {
|
|
231
|
+
return JSON.parse(text);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return text;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Sends one request and returns the response when the server accepted it.
|
|
241
|
+
*
|
|
242
|
+
* A failure to reach the server at all arrives as `NetworkError`; an abort
|
|
243
|
+
* through the caller's signal is rethrown as it came, since the caller asked
|
|
244
|
+
* for it. A non-2xx answer becomes `TokenExpiredError` when a 401 is explained
|
|
245
|
+
* by the token we sent, and `RunResponseError` otherwise.
|
|
246
|
+
*/
|
|
247
|
+
async function send(url, init, token) {
|
|
248
|
+
let response;
|
|
249
|
+
try {
|
|
250
|
+
response = await fetch(url, init);
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
// By name: a DOMException is not an `Error` in every realm.
|
|
254
|
+
if (error?.name === 'AbortError') {
|
|
255
|
+
throw error;
|
|
256
|
+
}
|
|
257
|
+
throw new NetworkError(error instanceof Error ? error : new TypeError(String(error)));
|
|
258
|
+
}
|
|
259
|
+
if (!response.ok) {
|
|
260
|
+
throwIfTokenExpired(response.status, token, url);
|
|
261
|
+
throw await RunResponseError.create(response);
|
|
262
|
+
}
|
|
263
|
+
return response;
|
|
264
|
+
}
|
|
192
265
|
/**
|
|
193
266
|
* Makes authenticated HTTP requests to Datalayer APIs.
|
|
194
267
|
* Handles JSON and FormData, includes auth headers, and manages redirects.
|
|
@@ -197,6 +270,7 @@ function throwIfTokenExpired(status, token, url) {
|
|
|
197
270
|
* @returns Promise resolving to response data
|
|
198
271
|
* @throws {NetworkError} On network failures
|
|
199
272
|
* @throws {RunResponseError} On HTTP error responses
|
|
273
|
+
* @throws {TokenExpiredError} On a 401 explained by an expired token
|
|
200
274
|
*
|
|
201
275
|
* @example
|
|
202
276
|
* ```typescript
|
|
@@ -208,147 +282,109 @@ function throwIfTokenExpired(status, token, url) {
|
|
|
208
282
|
* ```
|
|
209
283
|
*/
|
|
210
284
|
export async function requestDatalayerAPIWithResponse({ url, method, body, token, signal, responseType, headers = {}, }) {
|
|
211
|
-
|
|
212
|
-
const
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
withCredentials: true, // equivalent to credentials: 'include'
|
|
219
|
-
signal,
|
|
220
|
-
responseType,
|
|
221
|
-
// CORS mode is handled automatically by axios
|
|
222
|
-
// Cache control headers
|
|
223
|
-
};
|
|
224
|
-
// In Vitest+jsdom, axios may pick the XHR adapter and fail with browser-like
|
|
225
|
-
// network restrictions. Force the fetch adapter for integration reliability.
|
|
226
|
-
if (typeof process !== 'undefined' && process.env.VITEST) {
|
|
227
|
-
axiosConfig.adapter = 'fetch';
|
|
228
|
-
}
|
|
229
|
-
// Add cache control headers only for GET requests (equivalent to cache: 'no-store')
|
|
230
|
-
if (method === 'GET' || !method) {
|
|
231
|
-
if (!axiosConfig.headers['Cache-Control']) {
|
|
232
|
-
axiosConfig.headers['Cache-Control'] =
|
|
233
|
-
'no-store, no-cache, must-revalidate';
|
|
285
|
+
const requestMethod = method ?? 'GET';
|
|
286
|
+
const requestHeaders = new Headers(headers);
|
|
287
|
+
// A GET must not be answered from a cache (the equivalent of
|
|
288
|
+
// `cache: 'no-store'`, said in headers so proxies hear it too).
|
|
289
|
+
if (requestMethod === 'GET') {
|
|
290
|
+
if (!requestHeaders.has('Cache-Control')) {
|
|
291
|
+
requestHeaders.set('Cache-Control', 'no-store, no-cache, must-revalidate');
|
|
234
292
|
}
|
|
235
|
-
if (!
|
|
236
|
-
|
|
293
|
+
if (!requestHeaders.has('Pragma')) {
|
|
294
|
+
requestHeaders.set('Pragma', 'no-cache');
|
|
237
295
|
}
|
|
238
296
|
}
|
|
239
297
|
if (token) {
|
|
240
|
-
|
|
298
|
+
requestHeaders.set('Authorization', `Bearer ${token}`);
|
|
241
299
|
}
|
|
242
|
-
if (
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
300
|
+
if (!requestHeaders.has('Accept')) {
|
|
301
|
+
requestHeaders.set('Accept', 'application/json');
|
|
302
|
+
}
|
|
303
|
+
let requestBody;
|
|
304
|
+
if (isFormDataBody(body)) {
|
|
305
|
+
// No Content-Type: the browser sets multipart/form-data with its
|
|
306
|
+
// boundary.
|
|
307
|
+
requestBody = body;
|
|
249
308
|
}
|
|
250
309
|
else {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
axiosConfig.headers['Accept'] = 'application/json';
|
|
310
|
+
if (!requestHeaders.has('Content-Type')) {
|
|
311
|
+
requestHeaders.set('Content-Type', 'application/json');
|
|
254
312
|
}
|
|
255
|
-
if (
|
|
256
|
-
|
|
313
|
+
if (body === undefined) {
|
|
314
|
+
requestBody = undefined;
|
|
257
315
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
try {
|
|
261
|
-
const response = await axios(axiosConfig);
|
|
262
|
-
if (response.status < 300) {
|
|
263
|
-
// Handle redirections if needed.
|
|
264
|
-
if (response.status === 202 && response.headers.location) {
|
|
265
|
-
const data = await handleAxiosRedirection(response, axiosConfig);
|
|
266
|
-
return {
|
|
267
|
-
data: data,
|
|
268
|
-
status: response.status,
|
|
269
|
-
statusText: response.statusText,
|
|
270
|
-
headers: { ...response.headers },
|
|
271
|
-
};
|
|
272
|
-
}
|
|
316
|
+
else if (isRawBody(body)) {
|
|
317
|
+
requestBody = body;
|
|
273
318
|
}
|
|
274
319
|
else {
|
|
275
|
-
|
|
276
|
-
const adaptedResponse = {
|
|
277
|
-
ok: false,
|
|
278
|
-
status: response.status,
|
|
279
|
-
statusText: response.statusText,
|
|
280
|
-
json: async () => response?.data,
|
|
281
|
-
text: async () => JSON.stringify(response?.data),
|
|
282
|
-
};
|
|
283
|
-
throw await RunResponseError.create(adaptedResponse);
|
|
320
|
+
requestBody = JSON.stringify(body);
|
|
284
321
|
}
|
|
285
|
-
return {
|
|
286
|
-
data: response.data,
|
|
287
|
-
status: response.status,
|
|
288
|
-
statusText: response.statusText,
|
|
289
|
-
headers: { ...response.headers },
|
|
290
|
-
};
|
|
291
322
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
}
|
|
306
|
-
throw new NetworkError(error);
|
|
307
|
-
}
|
|
308
|
-
throw error;
|
|
323
|
+
const init = {
|
|
324
|
+
method: requestMethod,
|
|
325
|
+
headers: requestHeaders,
|
|
326
|
+
body: requestBody,
|
|
327
|
+
// Sends the browser's cookies along, cross-origin included.
|
|
328
|
+
credentials: 'include',
|
|
329
|
+
signal,
|
|
330
|
+
};
|
|
331
|
+
let response = await send(url, init, token);
|
|
332
|
+
// A 202 with a Location is the server saying the work is still running
|
|
333
|
+
// there: poll it until it is done.
|
|
334
|
+
if (response.status === 202 && response.headers.get('location')) {
|
|
335
|
+
response = await followAsyncRedirect(response, init, url, token);
|
|
309
336
|
}
|
|
337
|
+
return {
|
|
338
|
+
data: (await readBody(response, responseType)),
|
|
339
|
+
status: response.status,
|
|
340
|
+
statusText: response.statusText,
|
|
341
|
+
headers: headersToRecord(response.headers),
|
|
342
|
+
};
|
|
310
343
|
}
|
|
311
344
|
export async function requestDatalayerAPI(options) {
|
|
312
345
|
return (await requestDatalayerAPIWithResponse(options)).data;
|
|
313
346
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
347
|
+
/**
|
|
348
|
+
* Resolves the Location of a 202 against the request's URL.
|
|
349
|
+
*
|
|
350
|
+
* A proxy that emits an http Location for the same host while the original
|
|
351
|
+
* request was https gets it forced back to https, since the browser would
|
|
352
|
+
* refuse the mixed content and report it as a CORS or network failure.
|
|
353
|
+
*/
|
|
354
|
+
function resolveLocation(location, baseUrl) {
|
|
355
|
+
const normalized = location.replace(/^([a-z][a-z0-9+.-]*):\/(?!\/)/i, '$1://');
|
|
356
|
+
try {
|
|
357
|
+
const resolved = new URL(normalized, baseUrl);
|
|
358
|
+
const base = new URL(baseUrl, typeof window !== 'undefined' ? window.location.origin : undefined);
|
|
359
|
+
if (base.protocol === 'https:' &&
|
|
360
|
+
resolved.protocol === 'http:' &&
|
|
361
|
+
resolved.hostname === base.hostname) {
|
|
362
|
+
resolved.protocol = 'https:';
|
|
363
|
+
if (resolved.port === '80') {
|
|
364
|
+
resolved.port = '';
|
|
332
365
|
}
|
|
333
|
-
redirect = resolved.toString();
|
|
334
|
-
}
|
|
335
|
-
catch {
|
|
336
|
-
const parsedURL = URLExt.parse(baseUrl);
|
|
337
|
-
const fallbackBase = parsedURL.protocol + '//' + parsedURL.hostname;
|
|
338
|
-
redirect = URLExt.join(fallbackBase, normalizedRedirect);
|
|
339
366
|
}
|
|
367
|
+
return resolved.toString();
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
const parsedURL = URLExt.parse(baseUrl);
|
|
371
|
+
const fallbackBase = parsedURL.protocol + '//' + parsedURL.hostname;
|
|
372
|
+
return URLExt.join(fallbackBase, normalized);
|
|
340
373
|
}
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Polls the Location of a 202 with GETs, backing off from a second and
|
|
377
|
+
* doubling, until the answer is no longer a 202. The polls carry the
|
|
378
|
+
* original headers and credentials, never the body.
|
|
379
|
+
*/
|
|
380
|
+
async function followAsyncRedirect(response, init, url, token) {
|
|
381
|
+
const location = resolveLocation(response.headers.get('location'), url);
|
|
382
|
+
const pollInit = { ...init, method: 'GET', body: undefined };
|
|
341
383
|
let sleepTimeout = 1000;
|
|
342
|
-
while (response.status === 202
|
|
384
|
+
while (response.status === 202) {
|
|
343
385
|
await sleep(sleepTimeout);
|
|
344
386
|
sleepTimeout *= 2;
|
|
345
|
-
|
|
346
|
-
...originalConfig,
|
|
347
|
-
url: redirect,
|
|
348
|
-
method: 'GET',
|
|
349
|
-
data: undefined, // Don't send body on redirect
|
|
350
|
-
};
|
|
351
|
-
response = await axios(redirectConfig);
|
|
387
|
+
response = await send(location, pollInit, token);
|
|
352
388
|
}
|
|
353
|
-
return response
|
|
389
|
+
return response;
|
|
354
390
|
}
|
package/lib/api/index.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
export { requestDatalayerAPI, requestDatalayerAPIWithResponse, RunResponseError, NetworkError, TokenExpiredError, } from './DatalayerApi';
|
|
10
10
|
export type { IRequestDatalayerAPIOptions } from './DatalayerApi';
|
|
11
|
-
export type { IDatalayerAPIResponse } from './DatalayerApi';
|
|
11
|
+
export type { IDatalayerAPIResponse, DatalayerAPIResponseType, } from './DatalayerApi';
|
|
12
12
|
export * as iam from './iam';
|
|
13
13
|
export * as contents from './contents';
|
|
14
14
|
export * as evals from './evals';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type CopyToClipboardButtonProps = {
|
|
2
|
+
value: string;
|
|
3
|
+
ariaLabel?: string;
|
|
4
|
+
successText?: string;
|
|
5
|
+
errorText?: string;
|
|
6
|
+
direction?: 'n' | 's' | 'e' | 'w' | 'nw' | 'ne' | 'sw' | 'se';
|
|
7
|
+
disabled?: boolean;
|
|
8
|
+
size?: 'small' | 'medium';
|
|
9
|
+
variant?: 'default' | 'invisible' | 'primary' | 'danger';
|
|
10
|
+
onResult?: (copied: boolean) => void;
|
|
11
|
+
};
|
|
12
|
+
export declare function CopyToClipboardButton({ value, ariaLabel, successText, errorText, direction, disabled, size, variant, onResult, }: CopyToClipboardButtonProps): import("react").JSX.Element;
|
|
13
|
+
export default CopyToClipboardButton;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/*
|
|
3
|
+
* Copyright (c) 2023-2025 Datalayer, Inc.
|
|
4
|
+
* Distributed under the terms of the Modified BSD License.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* A button that copies a value, its tooltip saying what it copies, then
|
|
8
|
+
* whether it did.
|
|
9
|
+
*
|
|
10
|
+
* @module components/buttons/CopyToClipboardButton
|
|
11
|
+
*/
|
|
12
|
+
import { useMemo, useState } from 'react';
|
|
13
|
+
import { IconButton, Tooltip } from '@primer/react';
|
|
14
|
+
import { CopyIcon } from '@primer/octicons-react';
|
|
15
|
+
const ellipsize = (value, max = 96) => {
|
|
16
|
+
if (!value) {
|
|
17
|
+
return '';
|
|
18
|
+
}
|
|
19
|
+
if (value.length <= max) {
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
const head = Math.floor((max - 1) / 2);
|
|
23
|
+
const tail = max - head - 1;
|
|
24
|
+
return `${value.slice(0, head)}…${value.slice(-tail)}`;
|
|
25
|
+
};
|
|
26
|
+
export function CopyToClipboardButton({ value, ariaLabel = 'Copy to clipboard', successText = 'Copied', errorText = 'Unable to copy', direction = 'n', disabled = false, size = 'small', variant = 'invisible', onResult, }) {
|
|
27
|
+
const [status, setStatus] = useState('idle');
|
|
28
|
+
const hasValue = Boolean(value && value.trim());
|
|
29
|
+
const tooltip = useMemo(() => {
|
|
30
|
+
if (status === 'success') {
|
|
31
|
+
return successText;
|
|
32
|
+
}
|
|
33
|
+
if (status === 'error') {
|
|
34
|
+
return errorText;
|
|
35
|
+
}
|
|
36
|
+
return hasValue ? `Copy: ${ellipsize(value)}` : 'Nothing to copy';
|
|
37
|
+
}, [status, successText, errorText, hasValue, value]);
|
|
38
|
+
const handleClick = async () => {
|
|
39
|
+
if (!hasValue || disabled) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
if (!navigator?.clipboard?.writeText) {
|
|
44
|
+
throw new Error('Clipboard API unavailable');
|
|
45
|
+
}
|
|
46
|
+
await navigator.clipboard.writeText(value);
|
|
47
|
+
setStatus('success');
|
|
48
|
+
onResult?.(true);
|
|
49
|
+
window.setTimeout(() => setStatus('idle'), 1500);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
setStatus('error');
|
|
53
|
+
onResult?.(false);
|
|
54
|
+
window.setTimeout(() => setStatus('idle'), 1800);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const isDisabled = disabled || !hasValue;
|
|
58
|
+
const button = (_jsx(IconButton, { "aria-label": ariaLabel, icon: CopyIcon, size: size, variant: variant, disabled: isDisabled, onClick: handleClick }));
|
|
59
|
+
/*
|
|
60
|
+
* A disabled button wears no tooltip.
|
|
61
|
+
*
|
|
62
|
+
* `Tooltip` only takes interactive content — `button:not([disabled])` — and
|
|
63
|
+
* throws on anything else, which is what a disabled button is. Nothing is
|
|
64
|
+
* lost by leaving it out: a disabled element fires no pointer event, so the
|
|
65
|
+
* tooltip would never have shown. `IconButton` does the same with the one
|
|
66
|
+
* it renders of its own.
|
|
67
|
+
*/
|
|
68
|
+
if (isDisabled) {
|
|
69
|
+
return button;
|
|
70
|
+
}
|
|
71
|
+
return (_jsx(Tooltip, { text: tooltip, direction: direction, children: button }));
|
|
72
|
+
}
|
|
73
|
+
export default CopyToClipboardButton;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Copyright (c) 2023-2025 Datalayer, Inc.
|
|
3
3
|
* Distributed under the terms of the Modified BSD License.
|
|
4
4
|
*/
|
|
5
|
+
export * from './CopyToClipboardButton.js';
|
|
5
6
|
export * from './DownloadCSVButton.js';
|
|
6
7
|
export * from './DownloadJsonButton.js';
|
|
7
8
|
export * from './LongActionButton.js';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2023-2025 Datalayer, Inc.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* A deck's specification, as Spacer serves it: YAML.
|
|
7
|
+
*
|
|
8
|
+
* `GET /decks/{id}` inlines the specification as `model_s`, the text Spacer
|
|
9
|
+
* stored — YAML, or JSON for a deck stored before the specification was
|
|
10
|
+
* YAML, which the same parser reads (JSON is YAML). Saving sends the parsed
|
|
11
|
+
* value back (`PUT /decks/{id}/model`), and Spacer writes it as YAML.
|
|
12
|
+
*
|
|
13
|
+
* @module hooks/deckSpec
|
|
14
|
+
*/
|
|
15
|
+
import { parse } from 'yaml';
|
|
16
|
+
/** The value a deck's `model_s` holds, or `undefined` when it holds none. */
|
|
17
|
+
export const parseDeckSpec = (text) => {
|
|
18
|
+
if (typeof text !== 'string' || !text.trim()) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
return parse(text);
|
|
22
|
+
};
|
package/lib/hooks/useCache.d.ts
CHANGED
|
@@ -38,6 +38,27 @@ type IAnySpace = any;
|
|
|
38
38
|
export type CacheProps = {
|
|
39
39
|
loginRoute?: string;
|
|
40
40
|
};
|
|
41
|
+
/** A deck's video, as Spacer keeps it with the deck (`GET /decks/{id}/video`). */
|
|
42
|
+
export type SavedDeckVideo = {
|
|
43
|
+
/** Signed: plays it for `expiresIn` seconds. */
|
|
44
|
+
url: string;
|
|
45
|
+
/** Signed: downloads it as `<deck name>.mp4`. */
|
|
46
|
+
downloadUrl: string;
|
|
47
|
+
/** Signed: its first frame, a PNG. */
|
|
48
|
+
posterUrl: string;
|
|
49
|
+
/** Its captions, as WebVTT text. */
|
|
50
|
+
captions: string;
|
|
51
|
+
width: number;
|
|
52
|
+
height: number;
|
|
53
|
+
bytes: number;
|
|
54
|
+
madeAt: string;
|
|
55
|
+
madeBy: string;
|
|
56
|
+
/** When the deck was last edited, if it says. */
|
|
57
|
+
editedAt: string;
|
|
58
|
+
/** The deck has been edited since the video was made. */
|
|
59
|
+
stale: boolean;
|
|
60
|
+
expiresIn: number;
|
|
61
|
+
};
|
|
41
62
|
export type ISearchOpts = {
|
|
42
63
|
q: string;
|
|
43
64
|
types: string[];
|
|
@@ -282,6 +303,8 @@ export declare const queryKeys: {
|
|
|
282
303
|
readonly detail: (id: string) => readonly ["decks", "detail", string];
|
|
283
304
|
readonly bySpace: (spaceId: string) => readonly ["decks", "space", string];
|
|
284
305
|
readonly model: (deckId: string) => readonly ["decks", "detail", string, "model"];
|
|
306
|
+
/** Under the detail: saving the deck refreshes whether it is stale. */
|
|
307
|
+
readonly video: (deckId: string) => readonly ["decks", "detail", string, "video"];
|
|
285
308
|
};
|
|
286
309
|
readonly cells: {
|
|
287
310
|
readonly all: () => readonly ["cells"];
|
|
@@ -803,6 +826,15 @@ export declare const useCache: ({ loginRoute }?: CacheProps) => {
|
|
|
803
826
|
spec: unknown;
|
|
804
827
|
}, unknown>;
|
|
805
828
|
useCloneDeck: () => import("@tanstack/react-query").UseMutationResult<any, Error, string, unknown>;
|
|
829
|
+
useSavedDeckVideo: (deckId: string) => import("@tanstack/react-query").UseQueryResult<SavedDeckVideo | null, Error>;
|
|
830
|
+
useSaveDeckVideo: () => import("@tanstack/react-query").UseMutationResult<SavedDeckVideo, Error, {
|
|
831
|
+
deckId: string;
|
|
832
|
+
video: Blob;
|
|
833
|
+
captions: string;
|
|
834
|
+
poster: Blob;
|
|
835
|
+
spec: unknown;
|
|
836
|
+
}, unknown>;
|
|
837
|
+
useDeleteDeckVideo: () => import("@tanstack/react-query").UseMutationResult<any, Error, string, unknown>;
|
|
806
838
|
useDocument: (documentId: string) => import("@tanstack/react-query").UseQueryResult<any, Error>;
|
|
807
839
|
useDocumentsBySpace: (spaceId: string) => import("@tanstack/react-query").UseQueryResult<any, Error>;
|
|
808
840
|
useCreateDocument: () => import("@tanstack/react-query").UseMutationResult<any, Error, {
|
|
@@ -958,6 +990,12 @@ export declare const useCache: ({ loginRoute }?: CacheProps) => {
|
|
|
958
990
|
data?: string;
|
|
959
991
|
}, unknown>;
|
|
960
992
|
useResetItemImage: () => import("@tanstack/react-query").UseMutationResult<any, Error, string, unknown>;
|
|
993
|
+
useSetItemVideo: () => import("@tanstack/react-query").UseMutationResult<any, Error, {
|
|
994
|
+
itemId: string;
|
|
995
|
+
video: Blob;
|
|
996
|
+
captions: string;
|
|
997
|
+
poster: Blob;
|
|
998
|
+
}, unknown>;
|
|
961
999
|
useCountItemReuse: () => import("@tanstack/react-query").UseMutationResult<any, Error, {
|
|
962
1000
|
itemId: string;
|
|
963
1001
|
kind: "clone" | "instantiate";
|
|
@@ -975,6 +1013,14 @@ export declare const useCache: ({ loginRoute }?: CacheProps) => {
|
|
|
975
1013
|
ref?: string;
|
|
976
1014
|
data?: string;
|
|
977
1015
|
};
|
|
1016
|
+
/**
|
|
1017
|
+
* Publish a deck *as a video*: the video saved with it in its space
|
|
1018
|
+
* becomes the published deck's video — its page plays it and its
|
|
1019
|
+
* shared link announces it (`og:video`). The library copies it in
|
|
1020
|
+
* the same request that publishes, and refuses before publishing a
|
|
1021
|
+
* deck that has none.
|
|
1022
|
+
*/
|
|
1023
|
+
video?: boolean;
|
|
978
1024
|
/**
|
|
979
1025
|
* The publisher's own words for their work, which is what somebody
|
|
980
1026
|
* else will search for.
|
|
@@ -1415,6 +1461,8 @@ export declare const useCache: ({ loginRoute }?: CacheProps) => {
|
|
|
1415
1461
|
readonly detail: (id: string) => readonly ["decks", "detail", string];
|
|
1416
1462
|
readonly bySpace: (spaceId: string) => readonly ["decks", "space", string];
|
|
1417
1463
|
readonly model: (deckId: string) => readonly ["decks", "detail", string, "model"];
|
|
1464
|
+
/** Under the detail: saving the deck refreshes whether it is stale. */
|
|
1465
|
+
readonly video: (deckId: string) => readonly ["decks", "detail", string, "video"];
|
|
1418
1466
|
};
|
|
1419
1467
|
readonly cells: {
|
|
1420
1468
|
readonly all: () => readonly ["cells"];
|
package/lib/hooks/useCache.js
CHANGED
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
import { useQuery, useQueries, useMutation, useQueryClient, } from '@tanstack/react-query';
|
|
40
40
|
import { BOOTSTRAP_USER_ONBOARDING, asContact, asInvite, asOrganization, asSecret, asTeam, asToken, asUser, } from '../models/index.js';
|
|
41
41
|
import { asSpace } from './cacheConverters.js';
|
|
42
|
+
import { parseDeckSpec } from './deckSpec.js';
|
|
42
43
|
import { libraryAppFieldsOf } from './libraryApp.js';
|
|
43
44
|
import { unlinkIAMProvider } from './unlinkIAMProvider.js';
|
|
44
45
|
import { useCoreStore, useIAMStore, profileStore } from '../state/index.js';
|
|
@@ -368,6 +369,8 @@ export const queryKeys = {
|
|
|
368
369
|
detail: (id) => [...queryKeys.decks.details(), id],
|
|
369
370
|
bySpace: (spaceId) => [...queryKeys.decks.all(), 'space', spaceId],
|
|
370
371
|
model: (deckId) => [...queryKeys.decks.detail(deckId), 'model'],
|
|
372
|
+
/** Under the detail: saving the deck refreshes whether it is stale. */
|
|
373
|
+
video: (deckId) => [...queryKeys.decks.detail(deckId), 'video'],
|
|
371
374
|
},
|
|
372
375
|
// Cells
|
|
373
376
|
cells: {
|
|
@@ -721,6 +724,10 @@ export const useCache = ({ loginRoute = '/login' } = {}) => {
|
|
|
721
724
|
meta.publisher = raw.publisher;
|
|
722
725
|
if (raw.image !== undefined)
|
|
723
726
|
meta.image = raw.image;
|
|
727
|
+
// A deck's video, made by its owner: `url`, `captionsUrl`, `posterUrl`,
|
|
728
|
+
// `width`, `height`. Absent when it has none.
|
|
729
|
+
if (raw.video)
|
|
730
|
+
meta.video = raw.video;
|
|
724
731
|
/*
|
|
725
732
|
* Where the artifact can be opened for editing.
|
|
726
733
|
*
|
|
@@ -1074,7 +1081,8 @@ export const useCache = ({ loginRoute = '/login' } = {}) => {
|
|
|
1074
1081
|
name: raw.name_t,
|
|
1075
1082
|
description: raw.description_t,
|
|
1076
1083
|
tags: Array.isArray(raw.tags_ss) ? raw.tags_ss : [],
|
|
1077
|
-
|
|
1084
|
+
// YAML, as Spacer stores it (a deck stored as JSON reads the same).
|
|
1085
|
+
spec: parseDeckSpec(raw.model_s),
|
|
1078
1086
|
public: raw.is_public_b ?? false,
|
|
1079
1087
|
creationDate: new Date(raw.creation_ts_dt),
|
|
1080
1088
|
lastUpdateDate: raw.last_update_ts_dt
|
|
@@ -2628,6 +2636,77 @@ export const useCache = ({ loginRoute = '/login' } = {}) => {
|
|
|
2628
2636
|
},
|
|
2629
2637
|
});
|
|
2630
2638
|
};
|
|
2639
|
+
/**
|
|
2640
|
+
* The video saved with a deck in its space, or `null` when it has none.
|
|
2641
|
+
*
|
|
2642
|
+
* Signed URLs that read it for a few hours (`url`, `posterUrl`, and
|
|
2643
|
+
* `downloadUrl`, an attachment named after the deck), its captions inline
|
|
2644
|
+
* (WebVTT), its size, when it was made, and `stale` — the deck has been
|
|
2645
|
+
* edited since: its spec is not the one the video was made from.
|
|
2646
|
+
*/
|
|
2647
|
+
const useSavedDeckVideo = (deckId) => {
|
|
2648
|
+
return useQuery({
|
|
2649
|
+
queryKey: queryKeys.decks.video(deckId),
|
|
2650
|
+
queryFn: async () => {
|
|
2651
|
+
const resp = await requestDatalayer({
|
|
2652
|
+
url: `${configuration.spacerUrl}/api/spacer/v1/decks/${encodeURIComponent(deckId)}/video`,
|
|
2653
|
+
method: 'GET',
|
|
2654
|
+
});
|
|
2655
|
+
if (!resp.success) {
|
|
2656
|
+
throw new Error(resp.message || 'Failed to read the deck video');
|
|
2657
|
+
}
|
|
2658
|
+
return (resp.video ?? null);
|
|
2659
|
+
},
|
|
2660
|
+
...DEFAULT_QUERY_OPTIONS,
|
|
2661
|
+
enabled: !!deckId,
|
|
2662
|
+
});
|
|
2663
|
+
};
|
|
2664
|
+
/**
|
|
2665
|
+
* Keep the video the deck engine made of a deck with the deck, in its
|
|
2666
|
+
* space, replacing the one it had: the MP4, its WebVTT captions, its
|
|
2667
|
+
* poster, and the spec it was made from.
|
|
2668
|
+
*/
|
|
2669
|
+
const useSaveDeckVideo = () => {
|
|
2670
|
+
return useMutation({
|
|
2671
|
+
mutationFn: async ({ deckId, video, captions, poster, spec, }) => {
|
|
2672
|
+
const body = new FormData();
|
|
2673
|
+
body.append('video', video, 'video.mp4');
|
|
2674
|
+
body.append('captions', new Blob([captions], { type: 'text/vtt' }), 'captions.vtt');
|
|
2675
|
+
body.append('poster', poster, 'poster.png');
|
|
2676
|
+
body.append('spec', JSON.stringify(spec));
|
|
2677
|
+
const resp = await requestDatalayer({
|
|
2678
|
+
url: `${configuration.spacerUrl}/api/spacer/v1/decks/${encodeURIComponent(deckId)}/video`,
|
|
2679
|
+
method: 'PUT',
|
|
2680
|
+
body,
|
|
2681
|
+
});
|
|
2682
|
+
if (!resp.success) {
|
|
2683
|
+
throw new Error(resp.message || 'Failed to save the video');
|
|
2684
|
+
}
|
|
2685
|
+
return resp.video;
|
|
2686
|
+
},
|
|
2687
|
+
onSuccess: (video, { deckId }) => {
|
|
2688
|
+
queryClient.setQueryData(queryKeys.decks.video(deckId), video);
|
|
2689
|
+
},
|
|
2690
|
+
});
|
|
2691
|
+
};
|
|
2692
|
+
/** The deck without its saved video. */
|
|
2693
|
+
const useDeleteDeckVideo = () => {
|
|
2694
|
+
return useMutation({
|
|
2695
|
+
mutationFn: async (deckId) => {
|
|
2696
|
+
const resp = await requestDatalayer({
|
|
2697
|
+
url: `${configuration.spacerUrl}/api/spacer/v1/decks/${encodeURIComponent(deckId)}/video`,
|
|
2698
|
+
method: 'DELETE',
|
|
2699
|
+
});
|
|
2700
|
+
if (!resp.success) {
|
|
2701
|
+
throw new Error(resp.message || 'Failed to remove the video');
|
|
2702
|
+
}
|
|
2703
|
+
return resp;
|
|
2704
|
+
},
|
|
2705
|
+
onSuccess: (_, deckId) => {
|
|
2706
|
+
queryClient.setQueryData(queryKeys.decks.video(deckId), null);
|
|
2707
|
+
},
|
|
2708
|
+
});
|
|
2709
|
+
};
|
|
2631
2710
|
/**
|
|
2632
2711
|
* Clone notebook
|
|
2633
2712
|
*/
|
|
@@ -6915,6 +6994,31 @@ export const useCache = ({ loginRoute = '/login' } = {}) => {
|
|
|
6915
6994
|
onSuccess: invalidateArtifactVisibility,
|
|
6916
6995
|
});
|
|
6917
6996
|
};
|
|
6997
|
+
/**
|
|
6998
|
+
* A published deck's video, made in its owner's browser by the deck
|
|
6999
|
+
* engine: the MP4, its WebVTT captions and its poster (the first slide).
|
|
7000
|
+
* Making it again replaces it.
|
|
7001
|
+
*/
|
|
7002
|
+
const useSetItemVideo = () => {
|
|
7003
|
+
return useMutation({
|
|
7004
|
+
mutationFn: async ({ itemId, video, captions, poster, }) => {
|
|
7005
|
+
const body = new FormData();
|
|
7006
|
+
body.append('video', video, 'video.mp4');
|
|
7007
|
+
body.append('captions', new Blob([captions], { type: 'text/vtt' }), 'captions.vtt');
|
|
7008
|
+
body.append('poster', poster, 'poster.png');
|
|
7009
|
+
const resp = await requestDatalayer({
|
|
7010
|
+
url: `${configuration.libraryUrl}/api/library/v1/items/${encodeURIComponent(itemId)}/video`,
|
|
7011
|
+
method: 'PUT',
|
|
7012
|
+
body,
|
|
7013
|
+
});
|
|
7014
|
+
if (!resp.success) {
|
|
7015
|
+
throw new Error(resp.message || 'Failed to set the video');
|
|
7016
|
+
}
|
|
7017
|
+
return resp.video;
|
|
7018
|
+
},
|
|
7019
|
+
onSuccess: invalidateArtifactVisibility,
|
|
7020
|
+
});
|
|
7021
|
+
};
|
|
6918
7022
|
/** Publish a public artifact natively to LinkedIn through Library's saga. */
|
|
6919
7023
|
const usePublishLinkedInShare = () => {
|
|
6920
7024
|
return useMutation({
|
|
@@ -7131,7 +7235,7 @@ export const useCache = ({ loginRoute = '/login' } = {}) => {
|
|
|
7131
7235
|
const usePublishArtifact = () => {
|
|
7132
7236
|
const setImage = useSetItemImage();
|
|
7133
7237
|
return useMutation({
|
|
7134
|
-
mutationFn: async ({ itemId, image, tags, description, }) => {
|
|
7238
|
+
mutationFn: async ({ itemId, image, tags, description, video, }) => {
|
|
7135
7239
|
/*
|
|
7136
7240
|
* Publish first, then dress it.
|
|
7137
7241
|
*
|
|
@@ -7156,6 +7260,7 @@ export const useCache = ({ loginRoute = '/login' } = {}) => {
|
|
|
7156
7260
|
// words must not be able to clear them by omission.
|
|
7157
7261
|
...(tags === undefined ? {} : { tags }),
|
|
7158
7262
|
...(description === undefined ? {} : { description }),
|
|
7263
|
+
...(video ? { video: true } : {}),
|
|
7159
7264
|
},
|
|
7160
7265
|
});
|
|
7161
7266
|
if (!resp.success) {
|
|
@@ -8584,6 +8689,9 @@ export const useCache = ({ loginRoute = '/login' } = {}) => {
|
|
|
8584
8689
|
useUpdateDeck,
|
|
8585
8690
|
useUpdateDeckModel,
|
|
8586
8691
|
useCloneDeck,
|
|
8692
|
+
useSavedDeckVideo,
|
|
8693
|
+
useSaveDeckVideo,
|
|
8694
|
+
useDeleteDeckVideo,
|
|
8587
8695
|
// Documents
|
|
8588
8696
|
useDocument,
|
|
8589
8697
|
useDocumentsBySpace,
|
|
@@ -8655,6 +8763,7 @@ export const useCache = ({ loginRoute = '/login' } = {}) => {
|
|
|
8655
8763
|
useSetFeaturedRank,
|
|
8656
8764
|
useSetItemImage,
|
|
8657
8765
|
useResetItemImage,
|
|
8766
|
+
useSetItemVideo,
|
|
8658
8767
|
useCountItemReuse,
|
|
8659
8768
|
useSyncArtifactCatalogue,
|
|
8660
8769
|
usePublishArtifact,
|
package/lib/hooks/useUpload.d.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Posts a form to `url` through the API transport.
|
|
3
|
+
*
|
|
4
|
+
* `progress` is 0 while the upload is in flight and 100 once the server has
|
|
5
|
+
* answered: `fetch` reports no upload progress, so there is nothing in
|
|
6
|
+
* between to show.
|
|
7
|
+
*/
|
|
1
8
|
export declare const useUploadForm: (url: string) => {
|
|
2
9
|
uploadAndSubmit: (formData: FormData) => Promise<any>;
|
|
3
10
|
isSuccess: boolean;
|
package/lib/hooks/useUpload.js
CHANGED
|
@@ -3,16 +3,20 @@
|
|
|
3
3
|
* Distributed under the terms of the Modified BSD License.
|
|
4
4
|
*/
|
|
5
5
|
import { useState } from 'react';
|
|
6
|
-
import
|
|
7
|
-
// import useRun from "./useRun.js";
|
|
6
|
+
import { requestDatalayerAPI } from '../api/DatalayerApi.js';
|
|
8
7
|
import { useIAMStore } from '../state/index.js';
|
|
9
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Posts a form to `url` through the API transport.
|
|
10
|
+
*
|
|
11
|
+
* `progress` is 0 while the upload is in flight and 100 once the server has
|
|
12
|
+
* answered: `fetch` reports no upload progress, so there is nothing in
|
|
13
|
+
* between to show.
|
|
14
|
+
*/
|
|
10
15
|
export const useUploadForm = (url) => {
|
|
11
16
|
const [isSuccess, setIsSuccess] = useState(false);
|
|
12
17
|
const [isLoading, setIsLoading] = useState(false);
|
|
13
18
|
const [progress, setProgress] = useState(0);
|
|
14
19
|
const { token } = useIAMStore();
|
|
15
|
-
// const { requestRun } = useRun();
|
|
16
20
|
const reset = () => {
|
|
17
21
|
setIsSuccess(false);
|
|
18
22
|
setIsLoading(false);
|
|
@@ -21,19 +25,11 @@ export const useUploadForm = (url) => {
|
|
|
21
25
|
const uploadAndSubmit = async (formData) => {
|
|
22
26
|
setIsLoading(true);
|
|
23
27
|
try {
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
onUploadProgress: progressEvent => {
|
|
30
|
-
const progress = (progressEvent.loaded / progressEvent.total) * 50;
|
|
31
|
-
setProgress(progress);
|
|
32
|
-
},
|
|
33
|
-
onDownloadProgress: progressEvent => {
|
|
34
|
-
const progress = 50 + (progressEvent.loaded / progressEvent.total) * 50;
|
|
35
|
-
setProgress(progress);
|
|
36
|
-
},
|
|
28
|
+
const data = await requestDatalayerAPI({
|
|
29
|
+
url,
|
|
30
|
+
method: 'POST',
|
|
31
|
+
body: formData,
|
|
32
|
+
token,
|
|
37
33
|
});
|
|
38
34
|
setProgress(100);
|
|
39
35
|
await new Promise(resolve => {
|
package/lib/index.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export * from './navigation';
|
|
|
5
5
|
export * from './hooks';
|
|
6
6
|
export { requestDatalayerAPI, requestDatalayerAPIWithResponse, RunResponseError, NetworkError, } from './api/DatalayerApi';
|
|
7
7
|
export type { IRequestDatalayerAPIOptions } from './api/DatalayerApi';
|
|
8
|
-
export type { IDatalayerAPIResponse } from './api/DatalayerApi';
|
|
8
|
+
export type { IDatalayerAPIResponse, DatalayerAPIResponseType, } from './api/DatalayerApi';
|
|
9
9
|
export { API_BASE_PATHS } from './api/constants';
|
|
10
10
|
export * as iamApi from './api/iam';
|
|
11
11
|
export * as contentsApi from './api/contents';
|
package/lib/otel/hooks/index.js
CHANGED
|
@@ -30,7 +30,7 @@ export function setOtelOnUnauthorized(cb) {
|
|
|
30
30
|
}
|
|
31
31
|
/**
|
|
32
32
|
* Lightweight fetch helper for OTEL API calls.
|
|
33
|
-
* Uses plain `fetch` to avoid pulling in `@jupyterlab/coreutils
|
|
33
|
+
* Uses plain `fetch` to avoid pulling in `@jupyterlab/coreutils`.
|
|
34
34
|
*
|
|
35
35
|
* If the response is **401 Unauthorized** and a global `onUnauthorized`
|
|
36
36
|
* handler has been registered via {@link setOtelOnUnauthorized}, the
|
|
@@ -301,8 +301,22 @@ export function viewForRoute(route, current) {
|
|
|
301
301
|
if (path === '/apps' || path.startsWith('/apps/')) {
|
|
302
302
|
return 'apps';
|
|
303
303
|
}
|
|
304
|
+
// A space's decks and teaching pages are drawn beside the Agentify sidebar:
|
|
305
|
+
// filed under the Platform, the Platform button came back to a deck with
|
|
306
|
+
// the Agentify sidebar beside it (reported 23 October 2026).
|
|
307
|
+
if (AGENTIFY_SPACE_PAGE.test(path) || AGENTIFY_ACCOUNT_PAGE.test(path)) {
|
|
308
|
+
return 'agentify';
|
|
309
|
+
}
|
|
304
310
|
return 'home';
|
|
305
311
|
}
|
|
312
|
+
/**
|
|
313
|
+
* `/<account>/<space>/<kind>[/…]` for the kinds a space shows beside the
|
|
314
|
+
* Agentify sidebar — decks, courses and what a course is made of. The
|
|
315
|
+
* documentation (`/docs/…`) is never an account.
|
|
316
|
+
*/
|
|
317
|
+
const AGENTIFY_SPACE_PAGE = /^\/(?!docs\/)[^/]+\/[^/]+\/(?:decks|courses|course|lessons|exercises|assignments)(?:\/|$)/;
|
|
318
|
+
/** `/<account>/courses`: an account's courses, beside the Agentify sidebar. */
|
|
319
|
+
const AGENTIFY_ACCOUNT_PAGE = /^\/(?!docs\/)[^/]+\/courses$/;
|
|
306
320
|
/** Whether a page may be the one a shell returns to. */
|
|
307
321
|
function isRouteOfView(route, view) {
|
|
308
322
|
return (typeof route === 'string' &&
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who an account is, to be read: its avatar, then its username, display
|
|
3
|
+
* name, initials, origin and identifier, the identifier with a button that
|
|
4
|
+
* copies it.
|
|
5
|
+
*
|
|
6
|
+
* It is given the identity and fetches nothing, so any host draws it from
|
|
7
|
+
* what it already holds: the web's profile page beside its form, VS Code's
|
|
8
|
+
* Profile view from its signed-in user.
|
|
9
|
+
*
|
|
10
|
+
* @module views/profile/UserIdentity
|
|
11
|
+
*/
|
|
12
|
+
import type { JSX } from 'react';
|
|
13
|
+
/** What {@link UserIdentity} shows of an account. */
|
|
14
|
+
export interface UserIdentityProfile {
|
|
15
|
+
/** The account's identifier. */
|
|
16
|
+
id?: string;
|
|
17
|
+
username: string;
|
|
18
|
+
display_name: string;
|
|
19
|
+
initials: string;
|
|
20
|
+
/** Its photograph, when it has one. */
|
|
21
|
+
avatar_url?: string;
|
|
22
|
+
/** Its chosen picture, which wins over the photograph. */
|
|
23
|
+
avatar_icon?: string;
|
|
24
|
+
/** Where the account comes from (the identity provider). */
|
|
25
|
+
origin?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface UserIdentityProps {
|
|
28
|
+
identity: UserIdentityProfile;
|
|
29
|
+
/** Whether the identifier is shown. @default true */
|
|
30
|
+
displayId?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Whether the display name and the initials are shown. They are computed
|
|
33
|
+
* from the first and the last name, so a host showing the form that edits
|
|
34
|
+
* those states them there instead. @default true
|
|
35
|
+
*/
|
|
36
|
+
displayComputed?: boolean;
|
|
37
|
+
/** Rendered under the avatar, typically the button changing it. */
|
|
38
|
+
avatarAction?: JSX.Element;
|
|
39
|
+
}
|
|
40
|
+
export declare const UserIdentity: ({ identity, displayId, displayComputed, avatarAction, }: UserIdentityProps) => JSX.Element;
|
|
41
|
+
export default UserIdentity;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Text } from '@primer/react';
|
|
3
|
+
import { Box } from '@datalayer/primer-addons';
|
|
4
|
+
import { UserAvatar } from '../../components/avatars/index.js';
|
|
5
|
+
import { CopyToClipboardButton } from '../../components/buttons/index.js';
|
|
6
|
+
/** One row of the list: its label, then its value. */
|
|
7
|
+
const Row = ({ label, children, }) => (_jsxs(_Fragment, { children: [_jsx(Box, { p: 1, children: _jsx(Text, { style: { fontWeight: 'bold' }, children: label }) }), _jsx(Box, { p: 1, display: "flex", alignItems: "center", gap: 1, children: typeof children === 'string' ? _jsx(Text, { children: children }) : children })] }));
|
|
8
|
+
export const UserIdentity = ({ identity, displayId = true, displayComputed = true, avatarAction, }) => (_jsxs(Box, { children: [_jsxs(Box, { display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 2, children: [_jsx(UserAvatar, { avatarUrl: identity.avatar_url, avatar: identity.avatar_icon, size: 100 }), avatarAction] }), _jsxs(Box, { display: "grid", gridTemplateColumns: "1fr 2fr", rowGap: 3, columnGap: 3, maxWidth: 500, mt: 3, children: [_jsx(Row, { label: "Username", children: identity.username }), displayComputed && (_jsxs(_Fragment, { children: [_jsx(Row, { label: "Display name", children: identity.display_name }), _jsx(Row, { label: "Initials", children: identity.initials })] })), identity.origin && _jsx(Row, { label: "Origin", children: identity.origin }), displayId && identity.id && (_jsx(Row, { label: "UID", children: _jsxs(_Fragment, { children: [_jsx(Text, { children: identity.id }), _jsx(CopyToClipboardButton, { value: identity.id, ariaLabel: "Copy the identifier of the account" })] }) }))] })] }));
|
|
9
|
+
export default UserIdentity;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@datalayer/core",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.24",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"workspaces": [
|
|
6
6
|
".",
|
|
@@ -148,7 +148,6 @@
|
|
|
148
148
|
"@tanstack/react-query": "^5.90.6",
|
|
149
149
|
"@toon-format/toon": "^1.3.0",
|
|
150
150
|
"ansi-to-html": "^0.7.2",
|
|
151
|
-
"axios": "^1.7.7",
|
|
152
151
|
"boring-avatars": "^2.0.1",
|
|
153
152
|
"date-fns": "^2.29.3",
|
|
154
153
|
"deepmerge": "^4.3.1",
|
|
@@ -178,6 +177,7 @@
|
|
|
178
177
|
"usehooks-ts": "3.1.1",
|
|
179
178
|
"uuid": "^13.0.0",
|
|
180
179
|
"validator": "^13.7.0",
|
|
180
|
+
"yaml": "^2.9.1",
|
|
181
181
|
"zod": "^4.1.13",
|
|
182
182
|
"zod-to-json-schema": "^3.25.0",
|
|
183
183
|
"zustand": "^4.4.1"
|