@peers-app/peers-ui 0.26.6 → 0.27.1
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/dist/hooks/index.d.ts +7 -0
- package/dist/hooks/index.js +24 -0
- package/dist/hooks/use-observable-state.d.ts +12 -0
- package/dist/hooks/use-observable-state.js +15 -0
- package/dist/hooks/use-observable-state.test.js +64 -0
- package/dist/hooks/use-observable.d.ts +8 -0
- package/dist/hooks/use-observable.js +35 -0
- package/dist/hooks/use-observable.test.d.ts +1 -0
- package/dist/{hooks.test.js → hooks/use-observable.test.js} +22 -62
- package/dist/hooks/use-on-screen.d.ts +3 -0
- package/dist/hooks/use-on-screen.js +23 -0
- package/dist/hooks/use-promise.d.ts +9 -0
- package/dist/hooks/use-promise.js +31 -0
- package/dist/hooks/use-rows.d.ts +42 -0
- package/dist/hooks/use-rows.js +83 -0
- package/dist/hooks/use-rows.test.d.ts +1 -0
- package/dist/hooks/use-rows.test.js +175 -0
- package/dist/hooks/use-subscription.d.ts +10 -0
- package/dist/hooks/use-subscription.js +21 -0
- package/dist/hooks/use-tool.d.ts +43 -0
- package/dist/hooks/use-tool.js +82 -0
- package/dist/hooks/use-tool.test.d.ts +1 -0
- package/dist/hooks/use-tool.test.js +190 -0
- package/dist/screens/tools/tool-catalog-ipc.d.ts +6 -54
- package/dist/utils.d.ts +9 -4
- package/dist/utils.js +9 -174
- package/package.json +3 -3
- package/src/hooks/index.ts +8 -0
- package/src/hooks/use-observable-state.test.tsx +101 -0
- package/src/hooks/use-observable-state.ts +29 -0
- package/src/{hooks.test.tsx → hooks/use-observable.test.tsx} +27 -100
- package/src/hooks/use-observable.ts +67 -0
- package/src/hooks/use-on-screen.ts +24 -0
- package/src/hooks/use-promise.ts +34 -0
- package/src/hooks/use-rows.test.tsx +196 -0
- package/src/hooks/use-rows.ts +120 -0
- package/src/hooks/use-subscription.ts +24 -0
- package/src/hooks/use-tool.test.tsx +225 -0
- package/src/hooks/use-tool.ts +116 -0
- package/src/utils.ts +9 -167
- package/dist/hooks.d.ts +0 -36
- package/dist/hooks.js +0 -108
- package/src/hooks.ts +0 -150
- /package/dist/{hooks.test.d.ts → hooks/use-observable-state.test.d.ts} +0 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
4
|
+
const react_1 = require("@testing-library/react");
|
|
5
|
+
const use_rows_1 = require("./use-rows");
|
|
6
|
+
/** A table whose reads are answered by the test, one at a time. */
|
|
7
|
+
function fakeTable(answer) {
|
|
8
|
+
const listeners = new Set();
|
|
9
|
+
const reads = [];
|
|
10
|
+
const table = {
|
|
11
|
+
list: jest.fn(async (filter) => {
|
|
12
|
+
reads.push(filter);
|
|
13
|
+
return answer(filter);
|
|
14
|
+
}),
|
|
15
|
+
dataChanged: {
|
|
16
|
+
subscribe: (handler, options) => {
|
|
17
|
+
const listener = { handler: () => handler({}), options };
|
|
18
|
+
listeners.add(listener);
|
|
19
|
+
return { unsubscribe: () => listeners.delete(listener) };
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
return {
|
|
24
|
+
table,
|
|
25
|
+
reads,
|
|
26
|
+
listeners,
|
|
27
|
+
change: () => {
|
|
28
|
+
for (const listener of listeners)
|
|
29
|
+
listener.handler();
|
|
30
|
+
},
|
|
31
|
+
reconnect: () => {
|
|
32
|
+
for (const listener of listeners)
|
|
33
|
+
listener.options?.onReconnect?.();
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function Rows({ table, filter }) {
|
|
38
|
+
const { rows, loading, error } = (0, use_rows_1.useRows)(table, filter);
|
|
39
|
+
return ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("span", { "data-testid": "rows", children: rows.map((row) => row.id).join(",") }), (0, jsx_runtime_1.jsx)("span", { "data-testid": "loading", children: String(loading) }), (0, jsx_runtime_1.jsx)("span", { "data-testid": "error", children: error?.message ?? "" })] }));
|
|
40
|
+
}
|
|
41
|
+
const text = (id) => react_1.screen.getByTestId(id).textContent;
|
|
42
|
+
describe("useRows", () => {
|
|
43
|
+
test("reads on mount and again when the table changes", async () => {
|
|
44
|
+
let rows = [{ id: "a" }];
|
|
45
|
+
const fake = fakeTable(() => rows);
|
|
46
|
+
(0, react_1.render)((0, jsx_runtime_1.jsx)(Rows, { table: fake.table }));
|
|
47
|
+
expect(text("loading")).toBe("true");
|
|
48
|
+
await (0, react_1.waitFor)(() => expect(text("rows")).toBe("a"));
|
|
49
|
+
expect(text("loading")).toBe("false");
|
|
50
|
+
rows = [{ id: "a" }, { id: "b" }];
|
|
51
|
+
(0, react_1.act)(() => fake.change());
|
|
52
|
+
await (0, react_1.waitFor)(() => expect(text("rows")).toBe("a,b"));
|
|
53
|
+
});
|
|
54
|
+
test("changes during a read cause one more read, not one per change", async () => {
|
|
55
|
+
let release;
|
|
56
|
+
let n = 0;
|
|
57
|
+
const fake = fakeTable(() => new Promise((resolve) => {
|
|
58
|
+
const id = String(++n);
|
|
59
|
+
release = () => resolve([{ id }]);
|
|
60
|
+
}));
|
|
61
|
+
(0, react_1.render)((0, jsx_runtime_1.jsx)(Rows, { table: fake.table }));
|
|
62
|
+
await (0, react_1.waitFor)(() => expect(fake.reads).toHaveLength(1));
|
|
63
|
+
(0, react_1.act)(() => {
|
|
64
|
+
fake.change();
|
|
65
|
+
fake.change();
|
|
66
|
+
fake.change();
|
|
67
|
+
});
|
|
68
|
+
await (0, react_1.act)(async () => release?.());
|
|
69
|
+
await (0, react_1.waitFor)(() => expect(fake.reads).toHaveLength(2));
|
|
70
|
+
await (0, react_1.act)(async () => release?.());
|
|
71
|
+
await (0, react_1.waitFor)(() => expect(text("rows")).toBe("2"));
|
|
72
|
+
expect(fake.reads).toHaveLength(2);
|
|
73
|
+
});
|
|
74
|
+
test("reads again after a reconnect", async () => {
|
|
75
|
+
let rows = [{ id: "a" }];
|
|
76
|
+
const fake = fakeTable(() => rows);
|
|
77
|
+
(0, react_1.render)((0, jsx_runtime_1.jsx)(Rows, { table: fake.table }));
|
|
78
|
+
await (0, react_1.waitFor)(() => expect(text("rows")).toBe("a"));
|
|
79
|
+
rows = [{ id: "missed" }];
|
|
80
|
+
(0, react_1.act)(() => fake.reconnect());
|
|
81
|
+
await (0, react_1.waitFor)(() => expect(text("rows")).toBe("missed"));
|
|
82
|
+
});
|
|
83
|
+
test("reports a failed read and keeps the last rows", async () => {
|
|
84
|
+
let fail = false;
|
|
85
|
+
const fake = fakeTable(() => {
|
|
86
|
+
if (fail)
|
|
87
|
+
throw new Error("host is down");
|
|
88
|
+
return [{ id: "a" }];
|
|
89
|
+
});
|
|
90
|
+
(0, react_1.render)((0, jsx_runtime_1.jsx)(Rows, { table: fake.table }));
|
|
91
|
+
await (0, react_1.waitFor)(() => expect(text("rows")).toBe("a"));
|
|
92
|
+
fail = true;
|
|
93
|
+
(0, react_1.act)(() => fake.change());
|
|
94
|
+
await (0, react_1.waitFor)(() => expect(text("error")).toBe("host is down"));
|
|
95
|
+
expect(text("rows")).toBe("a");
|
|
96
|
+
fail = false;
|
|
97
|
+
(0, react_1.act)(() => fake.change());
|
|
98
|
+
await (0, react_1.waitFor)(() => expect(text("error")).toBe(""));
|
|
99
|
+
});
|
|
100
|
+
test("reads again when the filter changes, and unsubscribes on unmount", async () => {
|
|
101
|
+
const fake = fakeTable((filter) => [{ id: JSON.stringify(filter) }]);
|
|
102
|
+
const { rerender, unmount } = (0, react_1.render)((0, jsx_runtime_1.jsx)(Rows, { table: fake.table, filter: { id: "x" } }));
|
|
103
|
+
await (0, react_1.waitFor)(() => expect(text("rows")).toBe('{"id":"x"}'));
|
|
104
|
+
// An equal filter in a new object doesn't read again.
|
|
105
|
+
rerender((0, jsx_runtime_1.jsx)(Rows, { table: fake.table, filter: { id: "x" } }));
|
|
106
|
+
rerender((0, jsx_runtime_1.jsx)(Rows, { table: fake.table, filter: { id: "y" } }));
|
|
107
|
+
await (0, react_1.waitFor)(() => expect(text("rows")).toBe('{"id":"y"}'));
|
|
108
|
+
expect(fake.reads).toEqual([{ id: "x" }, { id: "y" }]);
|
|
109
|
+
expect(fake.listeners.size).toBe(1);
|
|
110
|
+
unmount();
|
|
111
|
+
expect(fake.listeners.size).toBe(0);
|
|
112
|
+
});
|
|
113
|
+
test("reads again once an asynchronous subscription is live", async () => {
|
|
114
|
+
let rows = [{ id: "before" }];
|
|
115
|
+
const fake = fakeTable(() => rows);
|
|
116
|
+
let release;
|
|
117
|
+
const ready = new Promise((resolve) => {
|
|
118
|
+
release = resolve;
|
|
119
|
+
});
|
|
120
|
+
const subscribe = fake.table.dataChanged.subscribe;
|
|
121
|
+
fake.table.dataChanged.subscribe = (handler, options) => ({
|
|
122
|
+
...subscribe(handler, options),
|
|
123
|
+
ready,
|
|
124
|
+
});
|
|
125
|
+
(0, react_1.render)((0, jsx_runtime_1.jsx)(Rows, { table: fake.table }));
|
|
126
|
+
await (0, react_1.waitFor)(() => expect(text("rows")).toBe("before"));
|
|
127
|
+
// No notification can be delivered while the remote subscription is still attaching.
|
|
128
|
+
rows = [{ id: "during setup" }];
|
|
129
|
+
await (0, react_1.act)(async () => release(true));
|
|
130
|
+
await (0, react_1.waitFor)(() => expect(text("rows")).toBe("during setup"));
|
|
131
|
+
});
|
|
132
|
+
test("a synchronous list failure can be retried", async () => {
|
|
133
|
+
const fake = fakeTable(() => [{ id: "recovered" }]);
|
|
134
|
+
const list = fake.table.list;
|
|
135
|
+
fake.table.list = jest.fn(() => {
|
|
136
|
+
throw new Error("offline");
|
|
137
|
+
});
|
|
138
|
+
(0, react_1.render)((0, jsx_runtime_1.jsx)(Rows, { table: fake.table }));
|
|
139
|
+
await (0, react_1.waitFor)(() => expect(text("error")).toBe("offline"));
|
|
140
|
+
fake.table.list = list;
|
|
141
|
+
(0, react_1.act)(() => fake.change());
|
|
142
|
+
await (0, react_1.waitFor)(() => expect(text("rows")).toBe("recovered"));
|
|
143
|
+
});
|
|
144
|
+
test("reload retries a failed subscription", async () => {
|
|
145
|
+
const fake = fakeTable(() => [{ id: "recovered" }]);
|
|
146
|
+
const subscribe = fake.table.dataChanged.subscribe;
|
|
147
|
+
fake.table.dataChanged.subscribe = () => {
|
|
148
|
+
throw new Error("subscription unavailable");
|
|
149
|
+
};
|
|
150
|
+
const { result } = (0, react_1.renderHook)(() => (0, use_rows_1.useRows)(fake.table));
|
|
151
|
+
await (0, react_1.waitFor)(() => expect(result.current.error?.message).toBe("subscription unavailable"));
|
|
152
|
+
fake.table.dataChanged.subscribe = subscribe;
|
|
153
|
+
await (0, react_1.act)(async () => result.current.reload());
|
|
154
|
+
expect(result.current.rows).toEqual([{ id: "recovered" }]);
|
|
155
|
+
expect(result.current.error).toBeUndefined();
|
|
156
|
+
expect(fake.listeners.size).toBe(1);
|
|
157
|
+
});
|
|
158
|
+
test("reload resolves once the rows are current", async () => {
|
|
159
|
+
let rows = [{ id: "a" }];
|
|
160
|
+
const fake = fakeTable(() => rows);
|
|
161
|
+
let reload;
|
|
162
|
+
function Reloading() {
|
|
163
|
+
const result = (0, use_rows_1.useRows)(fake.table);
|
|
164
|
+
reload = result.reload;
|
|
165
|
+
return (0, jsx_runtime_1.jsx)("span", { "data-testid": "rows", children: result.rows.map((row) => row.id).join(",") });
|
|
166
|
+
}
|
|
167
|
+
(0, react_1.render)((0, jsx_runtime_1.jsx)(Reloading, {}));
|
|
168
|
+
await (0, react_1.waitFor)(() => expect(text("rows")).toBe("a"));
|
|
169
|
+
rows = [{ id: "b" }];
|
|
170
|
+
await (0, react_1.act)(async () => {
|
|
171
|
+
await reload?.();
|
|
172
|
+
});
|
|
173
|
+
expect(text("rows")).toBe("b");
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ReadonlyObservable } from "@peers-app/peers-sdk";
|
|
2
|
+
/**
|
|
3
|
+
* Use this to register a handler for an observable in a functional component.
|
|
4
|
+
* The handler is called immediately (unless `doNotCallOnChangeDuringSetup`) and whenever the
|
|
5
|
+
* observable changes.
|
|
6
|
+
* @param subscribable The observable to subscribe to
|
|
7
|
+
* @param onChange The function to call with the new value when the observable changes
|
|
8
|
+
* @param doNotCallOnChangeDuringSetup Skip the immediate call.
|
|
9
|
+
*/
|
|
10
|
+
export declare function useSubscription<T>(subscribable: ReadonlyObservable<T>, onChange: (value: T) => void, doNotCallOnChangeDuringSetup?: boolean): void;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.useSubscription = useSubscription;
|
|
4
|
+
const react_1 = require("react");
|
|
5
|
+
/**
|
|
6
|
+
* Use this to register a handler for an observable in a functional component.
|
|
7
|
+
* The handler is called immediately (unless `doNotCallOnChangeDuringSetup`) and whenever the
|
|
8
|
+
* observable changes.
|
|
9
|
+
* @param subscribable The observable to subscribe to
|
|
10
|
+
* @param onChange The function to call with the new value when the observable changes
|
|
11
|
+
* @param doNotCallOnChangeDuringSetup Skip the immediate call.
|
|
12
|
+
*/
|
|
13
|
+
function useSubscription(subscribable, onChange, doNotCallOnChangeDuringSetup) {
|
|
14
|
+
(0, react_1.useEffect)(() => {
|
|
15
|
+
if (!doNotCallOnChangeDuringSetup) {
|
|
16
|
+
onChange(subscribable());
|
|
17
|
+
}
|
|
18
|
+
const subscription = subscribable.subscribe(() => onChange(subscribable()));
|
|
19
|
+
return () => subscription.dispose();
|
|
20
|
+
}, [subscribable, onChange, doNotCallOnChangeDuringSetup]);
|
|
21
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** A call {@link useTool} can run: a contract tool, a table write, or any async function. */
|
|
2
|
+
export type UseToolCall = (...args: any[]) => Promise<unknown>;
|
|
3
|
+
/**
|
|
4
|
+
* Callbacks for {@link useTool}. The latest ones passed are used. Callback failures are
|
|
5
|
+
* logged; they do not change the call's result or error. Async callbacks are not awaited.
|
|
6
|
+
*/
|
|
7
|
+
export interface IUseToolOptions<T extends UseToolCall> {
|
|
8
|
+
/** After a run succeeds, with its result and arguments. Not called after unmount. */
|
|
9
|
+
onSuccess?: (result: Awaited<ReturnType<T>>, ...args: Parameters<T>) => void;
|
|
10
|
+
/** After a run fails, with its error and arguments. Not called after unmount. */
|
|
11
|
+
onError?: (error: Error, ...args: Parameters<T>) => void;
|
|
12
|
+
}
|
|
13
|
+
/** What {@link useTool} returns. */
|
|
14
|
+
export interface IUseToolResult<T extends UseToolCall> {
|
|
15
|
+
/** Run the call. Resolves to its result, or `undefined` when it fails; it never rejects. */
|
|
16
|
+
run: (...args: Parameters<T>) => Promise<Awaited<ReturnType<T>> | undefined>;
|
|
17
|
+
/** True while the latest run is in flight. */
|
|
18
|
+
pending: boolean;
|
|
19
|
+
/** Why the latest run failed. Cleared when a run starts. */
|
|
20
|
+
error: Error | undefined;
|
|
21
|
+
/** The latest run's result. Cleared when a run starts. */
|
|
22
|
+
result: Awaited<ReturnType<T>> | undefined;
|
|
23
|
+
/** Clear state and ignore pending state updates, without cancelling calls or their callbacks. */
|
|
24
|
+
reset: () => void;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Run a tool from a component and track it: `pending` while it runs, then `result` or `error`.
|
|
28
|
+
*
|
|
29
|
+
* ```tsx
|
|
30
|
+
* const refresh = useTool(news.tools.refresh);
|
|
31
|
+
* <button disabled={refresh.pending} onClick={() => refresh.run()}>Refresh</button>
|
|
32
|
+
* {refresh.error && <div>Couldn't refresh.</div>}
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* Typed from the call, so `run` takes the tool's input and `result` is its output. It also
|
|
36
|
+
* takes table writes (`useTool(news.tables.Feeds.save)`) or any async function. `run` keeps
|
|
37
|
+
* the same identity across renders and calls the latest function passed. When runs overlap,
|
|
38
|
+
* the state follows the latest one. Nothing updates after unmount.
|
|
39
|
+
*
|
|
40
|
+
* @param call - The tool or async function to run.
|
|
41
|
+
* @param options - `onSuccess` and `onError` callbacks.
|
|
42
|
+
*/
|
|
43
|
+
export declare function useTool<T extends UseToolCall>(call: T, options?: IUseToolOptions<T>): IUseToolResult<T>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.useTool = useTool;
|
|
4
|
+
const react_1 = require("react");
|
|
5
|
+
/**
|
|
6
|
+
* Run a tool from a component and track it: `pending` while it runs, then `result` or `error`.
|
|
7
|
+
*
|
|
8
|
+
* ```tsx
|
|
9
|
+
* const refresh = useTool(news.tools.refresh);
|
|
10
|
+
* <button disabled={refresh.pending} onClick={() => refresh.run()}>Refresh</button>
|
|
11
|
+
* {refresh.error && <div>Couldn't refresh.</div>}
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* Typed from the call, so `run` takes the tool's input and `result` is its output. It also
|
|
15
|
+
* takes table writes (`useTool(news.tables.Feeds.save)`) or any async function. `run` keeps
|
|
16
|
+
* the same identity across renders and calls the latest function passed. When runs overlap,
|
|
17
|
+
* the state follows the latest one. Nothing updates after unmount.
|
|
18
|
+
*
|
|
19
|
+
* @param call - The tool or async function to run.
|
|
20
|
+
* @param options - `onSuccess` and `onError` callbacks.
|
|
21
|
+
*/
|
|
22
|
+
function useTool(call, options = {}) {
|
|
23
|
+
const latest = (0, react_1.useRef)({ call, options });
|
|
24
|
+
latest.current = { call, options };
|
|
25
|
+
const runs = (0, react_1.useRef)(0);
|
|
26
|
+
const mounted = (0, react_1.useRef)(true);
|
|
27
|
+
const [state, setState] = (0, react_1.useState)({ pending: false });
|
|
28
|
+
(0, react_1.useEffect)(() => {
|
|
29
|
+
mounted.current = true;
|
|
30
|
+
return () => {
|
|
31
|
+
mounted.current = false;
|
|
32
|
+
};
|
|
33
|
+
}, []);
|
|
34
|
+
const run = (0, react_1.useCallback)(async (...args) => {
|
|
35
|
+
const id = ++runs.current;
|
|
36
|
+
if (mounted.current)
|
|
37
|
+
setState({ pending: true });
|
|
38
|
+
try {
|
|
39
|
+
const result = (await latest.current.call(...args));
|
|
40
|
+
if (mounted.current) {
|
|
41
|
+
if (id === runs.current)
|
|
42
|
+
setState({ pending: false, result });
|
|
43
|
+
notify(() => latest.current.options.onSuccess?.(result, ...args));
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
catch (cause) {
|
|
48
|
+
const error = toolError(cause);
|
|
49
|
+
if (mounted.current) {
|
|
50
|
+
if (id === runs.current)
|
|
51
|
+
setState({ pending: false, error });
|
|
52
|
+
notify(() => latest.current.options.onError?.(error, ...args));
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}, []);
|
|
57
|
+
const reset = (0, react_1.useCallback)(() => {
|
|
58
|
+
runs.current++;
|
|
59
|
+
if (mounted.current)
|
|
60
|
+
setState({ pending: false });
|
|
61
|
+
}, []);
|
|
62
|
+
return { run, reset, pending: state.pending, error: state.error, result: state.result };
|
|
63
|
+
}
|
|
64
|
+
function notify(callback) {
|
|
65
|
+
const failed = (cause) => console.error("useTool callback failed", cause);
|
|
66
|
+
try {
|
|
67
|
+
void Promise.resolve(callback()).catch(failed);
|
|
68
|
+
}
|
|
69
|
+
catch (cause) {
|
|
70
|
+
failed(cause);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function toolError(cause) {
|
|
74
|
+
try {
|
|
75
|
+
if (cause instanceof Error)
|
|
76
|
+
return cause;
|
|
77
|
+
return new Error(String(cause));
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return new Error("Tool call failed");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const react_1 = require("@testing-library/react");
|
|
4
|
+
const use_tool_1 = require("./use-tool");
|
|
5
|
+
function deferred() {
|
|
6
|
+
let resolve;
|
|
7
|
+
let reject;
|
|
8
|
+
const promise = new Promise((yes, no) => {
|
|
9
|
+
resolve = yes;
|
|
10
|
+
reject = no;
|
|
11
|
+
});
|
|
12
|
+
return { promise, resolve, reject };
|
|
13
|
+
}
|
|
14
|
+
describe("useTool", () => {
|
|
15
|
+
test("tracks a run from pending to its result", async () => {
|
|
16
|
+
const answer = deferred();
|
|
17
|
+
const tool = jest.fn((_input) => answer.promise);
|
|
18
|
+
const { result } = (0, react_1.renderHook)(() => (0, use_tool_1.useTool)(tool));
|
|
19
|
+
expect(result.current.pending).toBe(false);
|
|
20
|
+
let running;
|
|
21
|
+
(0, react_1.act)(() => {
|
|
22
|
+
running = result.current.run({ feed: "bbc" });
|
|
23
|
+
});
|
|
24
|
+
expect(result.current.pending).toBe(true);
|
|
25
|
+
expect(tool).toHaveBeenCalledWith({ feed: "bbc" });
|
|
26
|
+
await (0, react_1.act)(async () => answer.resolve({ count: 3 }));
|
|
27
|
+
await expect(running).resolves.toEqual({ count: 3 });
|
|
28
|
+
expect(result.current).toMatchObject({ pending: false, result: { count: 3 } });
|
|
29
|
+
expect(result.current.error).toBeUndefined();
|
|
30
|
+
const typeChecks = () => {
|
|
31
|
+
// @ts-expect-error the input is the tool's
|
|
32
|
+
void result.current.run({ feed: 1 });
|
|
33
|
+
const count = result.current.result?.count;
|
|
34
|
+
void count;
|
|
35
|
+
};
|
|
36
|
+
void typeChecks;
|
|
37
|
+
});
|
|
38
|
+
test("a failed run sets error and resolves undefined instead of throwing", async () => {
|
|
39
|
+
const onError = jest.fn();
|
|
40
|
+
const { result } = (0, react_1.renderHook)(() => (0, use_tool_1.useTool)(async () => {
|
|
41
|
+
throw "offline";
|
|
42
|
+
}, { onError }));
|
|
43
|
+
let outcome = "unset";
|
|
44
|
+
await (0, react_1.act)(async () => {
|
|
45
|
+
outcome = await result.current.run();
|
|
46
|
+
});
|
|
47
|
+
expect(outcome).toBeUndefined();
|
|
48
|
+
expect(result.current.error?.message).toBe("offline");
|
|
49
|
+
expect(result.current.pending).toBe(false);
|
|
50
|
+
expect(onError).toHaveBeenCalledWith(expect.any(Error));
|
|
51
|
+
});
|
|
52
|
+
test("a new run clears the last error and result", async () => {
|
|
53
|
+
let fail = true;
|
|
54
|
+
const { result } = (0, react_1.renderHook)(() => (0, use_tool_1.useTool)(async () => {
|
|
55
|
+
if (fail)
|
|
56
|
+
throw new Error("nope");
|
|
57
|
+
return "ok";
|
|
58
|
+
}));
|
|
59
|
+
await (0, react_1.act)(async () => {
|
|
60
|
+
await result.current.run();
|
|
61
|
+
});
|
|
62
|
+
expect(result.current.error).toBeDefined();
|
|
63
|
+
fail = false;
|
|
64
|
+
const next = deferred();
|
|
65
|
+
(0, react_1.act)(() => {
|
|
66
|
+
void result.current.run().then(() => next.resolve());
|
|
67
|
+
});
|
|
68
|
+
expect(result.current.error).toBeUndefined();
|
|
69
|
+
expect(result.current.pending).toBe(true);
|
|
70
|
+
await (0, react_1.act)(async () => next.promise);
|
|
71
|
+
expect(result.current.result).toBe("ok");
|
|
72
|
+
});
|
|
73
|
+
test("overlapping runs: the state follows the latest, each run resolves its own result", async () => {
|
|
74
|
+
const slow = deferred();
|
|
75
|
+
const fast = deferred();
|
|
76
|
+
const answers = [slow, fast];
|
|
77
|
+
const onSuccess = jest.fn();
|
|
78
|
+
const { result } = (0, react_1.renderHook)(() => (0, use_tool_1.useTool)(() => answers.shift().promise, {
|
|
79
|
+
onSuccess,
|
|
80
|
+
}));
|
|
81
|
+
let first;
|
|
82
|
+
let second;
|
|
83
|
+
(0, react_1.act)(() => {
|
|
84
|
+
first = result.current.run();
|
|
85
|
+
second = result.current.run();
|
|
86
|
+
});
|
|
87
|
+
await (0, react_1.act)(async () => fast.resolve("fast"));
|
|
88
|
+
await expect(second).resolves.toBe("fast");
|
|
89
|
+
expect(result.current).toMatchObject({ pending: false, result: "fast" });
|
|
90
|
+
await (0, react_1.act)(async () => slow.resolve("slow"));
|
|
91
|
+
await expect(first).resolves.toBe("slow");
|
|
92
|
+
expect(result.current.result).toBe("fast");
|
|
93
|
+
expect(onSuccess.mock.calls.map(([value]) => value)).toEqual(["fast", "slow"]);
|
|
94
|
+
});
|
|
95
|
+
test("reset clears state but lets the pending call and callback finish", async () => {
|
|
96
|
+
const answer = deferred();
|
|
97
|
+
const onSuccess = jest.fn();
|
|
98
|
+
const { result } = (0, react_1.renderHook)(() => (0, use_tool_1.useTool)(() => answer.promise, { onSuccess }));
|
|
99
|
+
(0, react_1.act)(() => {
|
|
100
|
+
void result.current.run();
|
|
101
|
+
});
|
|
102
|
+
(0, react_1.act)(() => result.current.reset());
|
|
103
|
+
expect(result.current.pending).toBe(false);
|
|
104
|
+
await (0, react_1.act)(async () => answer.resolve("late"));
|
|
105
|
+
expect(result.current.result).toBeUndefined();
|
|
106
|
+
expect(onSuccess).toHaveBeenCalledWith("late");
|
|
107
|
+
});
|
|
108
|
+
test("run is stable and calls the latest function and callbacks", async () => {
|
|
109
|
+
const onSuccess = jest.fn();
|
|
110
|
+
const { result, rerender } = (0, react_1.renderHook)(({ label }) => (0, use_tool_1.useTool)(async () => label, { onSuccess: (value) => onSuccess(label, value) }), { initialProps: { label: "first" } });
|
|
111
|
+
const run = result.current.run;
|
|
112
|
+
rerender({ label: "second" });
|
|
113
|
+
expect(result.current.run).toBe(run);
|
|
114
|
+
await (0, react_1.act)(async () => {
|
|
115
|
+
await run();
|
|
116
|
+
});
|
|
117
|
+
expect(result.current.result).toBe("second");
|
|
118
|
+
expect(onSuccess).toHaveBeenCalledWith("second", "second");
|
|
119
|
+
});
|
|
120
|
+
test("nothing updates after unmount", async () => {
|
|
121
|
+
const answer = deferred();
|
|
122
|
+
const onSuccess = jest.fn();
|
|
123
|
+
const errors = jest.spyOn(console, "error").mockImplementation(() => { });
|
|
124
|
+
const { result, unmount } = (0, react_1.renderHook)(() => (0, use_tool_1.useTool)(() => answer.promise, { onSuccess }));
|
|
125
|
+
let running;
|
|
126
|
+
(0, react_1.act)(() => {
|
|
127
|
+
running = result.current.run();
|
|
128
|
+
});
|
|
129
|
+
unmount();
|
|
130
|
+
answer.resolve("done");
|
|
131
|
+
await expect(running).resolves.toBe("done");
|
|
132
|
+
expect(onSuccess).not.toHaveBeenCalled();
|
|
133
|
+
expect(errors).not.toHaveBeenCalled();
|
|
134
|
+
errors.mockRestore();
|
|
135
|
+
});
|
|
136
|
+
test("callback failures do not change the call's outcome or reject run", async () => {
|
|
137
|
+
const errors = jest.spyOn(console, "error").mockImplementation(() => { });
|
|
138
|
+
const onError = jest.fn(() => {
|
|
139
|
+
throw new Error("error callback failed");
|
|
140
|
+
});
|
|
141
|
+
const { result, rerender } = (0, react_1.renderHook)(({ fail }) => (0, use_tool_1.useTool)(async () => {
|
|
142
|
+
if (fail)
|
|
143
|
+
throw new Error("tool failed");
|
|
144
|
+
return "saved";
|
|
145
|
+
}, {
|
|
146
|
+
onSuccess: () => {
|
|
147
|
+
throw new Error("success callback failed");
|
|
148
|
+
},
|
|
149
|
+
onError,
|
|
150
|
+
}), { initialProps: { fail: false } });
|
|
151
|
+
await (0, react_1.act)(async () => {
|
|
152
|
+
await expect(result.current.run()).resolves.toBe("saved");
|
|
153
|
+
});
|
|
154
|
+
expect(result.current.result).toBe("saved");
|
|
155
|
+
expect(result.current.error).toBeUndefined();
|
|
156
|
+
expect(onError).not.toHaveBeenCalled();
|
|
157
|
+
rerender({ fail: true });
|
|
158
|
+
await (0, react_1.act)(async () => {
|
|
159
|
+
await expect(result.current.run()).resolves.toBeUndefined();
|
|
160
|
+
});
|
|
161
|
+
expect(result.current.error?.message).toBe("tool failed");
|
|
162
|
+
expect(errors).toHaveBeenCalledTimes(2);
|
|
163
|
+
errors.mockRestore();
|
|
164
|
+
});
|
|
165
|
+
test("observes rejected async callbacks and unprintable rejection values", async () => {
|
|
166
|
+
const errors = jest.spyOn(console, "error").mockImplementation(() => { });
|
|
167
|
+
const { result, rerender } = (0, react_1.renderHook)(({ fail }) => (0, use_tool_1.useTool)(async () => {
|
|
168
|
+
if (fail)
|
|
169
|
+
throw Object.create(null);
|
|
170
|
+
return "ok";
|
|
171
|
+
}, {
|
|
172
|
+
onSuccess: async () => {
|
|
173
|
+
throw new Error("async success callback");
|
|
174
|
+
},
|
|
175
|
+
onError: async () => {
|
|
176
|
+
throw new Error("async error callback");
|
|
177
|
+
},
|
|
178
|
+
}), { initialProps: { fail: false } });
|
|
179
|
+
await (0, react_1.act)(async () => {
|
|
180
|
+
await expect(result.current.run()).resolves.toBe("ok");
|
|
181
|
+
});
|
|
182
|
+
rerender({ fail: true });
|
|
183
|
+
await (0, react_1.act)(async () => {
|
|
184
|
+
await expect(result.current.run()).resolves.toBeUndefined();
|
|
185
|
+
});
|
|
186
|
+
expect(result.current.error).toBeInstanceOf(Error);
|
|
187
|
+
expect(errors).toHaveBeenCalledTimes(2);
|
|
188
|
+
errors.mockRestore();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -9,27 +9,11 @@ export declare function getToolDetails(toolId: string): Promise<{
|
|
|
9
9
|
usageDescription: string;
|
|
10
10
|
inputSchema: {
|
|
11
11
|
type: import("@peers-app/peers-sdk").IOSchemaType;
|
|
12
|
-
fields:
|
|
13
|
-
name: string;
|
|
14
|
-
type: import("@peers-app/peers-sdk").FieldType;
|
|
15
|
-
optional?: boolean | undefined;
|
|
16
|
-
description?: string | undefined;
|
|
17
|
-
defaultValue?: any;
|
|
18
|
-
isArray?: boolean | undefined;
|
|
19
|
-
subType?: string | undefined;
|
|
20
|
-
}[];
|
|
12
|
+
fields: import("@peers-app/peers-sdk").IField[];
|
|
21
13
|
};
|
|
22
14
|
outputSchema: {
|
|
23
15
|
type: import("@peers-app/peers-sdk").IOSchemaType;
|
|
24
|
-
fields:
|
|
25
|
-
name: string;
|
|
26
|
-
type: import("@peers-app/peers-sdk").FieldType;
|
|
27
|
-
optional?: boolean | undefined;
|
|
28
|
-
description?: string | undefined;
|
|
29
|
-
defaultValue?: any;
|
|
30
|
-
isArray?: boolean | undefined;
|
|
31
|
-
subType?: string | undefined;
|
|
32
|
-
}[];
|
|
16
|
+
fields: import("@peers-app/peers-sdk").IField[];
|
|
33
17
|
};
|
|
34
18
|
toolId: string;
|
|
35
19
|
contractId?: string | undefined;
|
|
@@ -40,15 +24,7 @@ export declare function getToolDetails(toolId: string): Promise<{
|
|
|
40
24
|
catalogReason?: string | undefined;
|
|
41
25
|
configSchema?: {
|
|
42
26
|
type: import("@peers-app/peers-sdk").IOSchemaType;
|
|
43
|
-
fields:
|
|
44
|
-
name: string;
|
|
45
|
-
type: import("@peers-app/peers-sdk").FieldType;
|
|
46
|
-
optional?: boolean | undefined;
|
|
47
|
-
description?: string | undefined;
|
|
48
|
-
defaultValue?: any;
|
|
49
|
-
isArray?: boolean | undefined;
|
|
50
|
-
subType?: string | undefined;
|
|
51
|
-
}[];
|
|
27
|
+
fields: import("@peers-app/peers-sdk").IField[];
|
|
52
28
|
} | undefined;
|
|
53
29
|
isAssistantRunner?: boolean | undefined;
|
|
54
30
|
detailedDescription?: string | undefined;
|
|
@@ -67,27 +43,11 @@ export declare function listWorkflowMentionTools(query: string, limit: number):
|
|
|
67
43
|
usageDescription: string;
|
|
68
44
|
inputSchema: {
|
|
69
45
|
type: import("@peers-app/peers-sdk").IOSchemaType;
|
|
70
|
-
fields:
|
|
71
|
-
name: string;
|
|
72
|
-
type: import("@peers-app/peers-sdk").FieldType;
|
|
73
|
-
optional?: boolean | undefined;
|
|
74
|
-
description?: string | undefined;
|
|
75
|
-
defaultValue?: any;
|
|
76
|
-
isArray?: boolean | undefined;
|
|
77
|
-
subType?: string | undefined;
|
|
78
|
-
}[];
|
|
46
|
+
fields: import("@peers-app/peers-sdk").IField[];
|
|
79
47
|
};
|
|
80
48
|
outputSchema: {
|
|
81
49
|
type: import("@peers-app/peers-sdk").IOSchemaType;
|
|
82
|
-
fields:
|
|
83
|
-
name: string;
|
|
84
|
-
type: import("@peers-app/peers-sdk").FieldType;
|
|
85
|
-
optional?: boolean | undefined;
|
|
86
|
-
description?: string | undefined;
|
|
87
|
-
defaultValue?: any;
|
|
88
|
-
isArray?: boolean | undefined;
|
|
89
|
-
subType?: string | undefined;
|
|
90
|
-
}[];
|
|
50
|
+
fields: import("@peers-app/peers-sdk").IField[];
|
|
91
51
|
};
|
|
92
52
|
toolId: string;
|
|
93
53
|
contractId?: string | undefined;
|
|
@@ -98,15 +58,7 @@ export declare function listWorkflowMentionTools(query: string, limit: number):
|
|
|
98
58
|
catalogReason?: string | undefined;
|
|
99
59
|
configSchema?: {
|
|
100
60
|
type: import("@peers-app/peers-sdk").IOSchemaType;
|
|
101
|
-
fields:
|
|
102
|
-
name: string;
|
|
103
|
-
type: import("@peers-app/peers-sdk").FieldType;
|
|
104
|
-
optional?: boolean | undefined;
|
|
105
|
-
description?: string | undefined;
|
|
106
|
-
defaultValue?: any;
|
|
107
|
-
isArray?: boolean | undefined;
|
|
108
|
-
subType?: string | undefined;
|
|
109
|
-
}[];
|
|
61
|
+
fields: import("@peers-app/peers-sdk").IField[];
|
|
110
62
|
} | undefined;
|
|
111
63
|
isAssistantRunner?: boolean | undefined;
|
|
112
64
|
detailedDescription?: string | undefined;
|
package/dist/utils.d.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
+
/** Plain text content of an HTML string (parsed with `DOMParser`, so browser-only). */
|
|
1
2
|
export declare function htmlToText(html: string): string;
|
|
3
|
+
/** Replaces `<@type:name:id>` / `<$type:name:id>` mentions with the readable name, underscores as spaces. */
|
|
2
4
|
export declare function stripMentions(text: string): string;
|
|
5
|
+
/** Sanitizes HTML with DOMPurify, collapses repeated blank paragraphs, and drops a trailing one. */
|
|
3
6
|
export declare function trimAndSanitizeHtml(value: string): string;
|
|
4
|
-
|
|
5
|
-
export declare function js(jsCode: string, externalReferences?: any): any;
|
|
7
|
+
/** The `AsyncFunction` constructor, for compiling async code from a string. */
|
|
6
8
|
export declare const AsyncFunction: any;
|
|
9
|
+
/**
|
|
10
|
+
* Compiles and runs `jsCode` as the body of a strict async function. `utils`, `Promise`,
|
|
11
|
+
* `console` and each key of `externalReferences` are in scope; `process` and `global` are hidden.
|
|
12
|
+
* Runs arbitrary code: never pass it untrusted input.
|
|
13
|
+
*/
|
|
7
14
|
export declare function jsAsync(jsCode: string, externalReferences?: any): any;
|
|
8
|
-
export declare function toJSON(obj: any): any;
|
|
9
|
-
export declare function fromJSON(obj: any, externalReferences?: any): any;
|