Spaces:
Running
Running
Set up Biome for JS linting/formatting.
Browse files- biome.json +24 -0
- lynxkite-app/web/eslint.config.js +12 -12
- lynxkite-app/web/playwright.config.ts +10 -11
- lynxkite-app/web/postcss.config.js +1 -1
- lynxkite-app/web/src/Directory.tsx +178 -178
- lynxkite-app/web/src/apiTypes.ts +30 -30
- lynxkite-app/web/src/index.css +0 -2
- lynxkite-app/web/src/main.tsx +9 -9
- lynxkite-app/web/src/workspace/EnvironmentSelector.tsx +12 -5
- lynxkite-app/web/src/workspace/LynxKiteState.ts +1 -1
- lynxkite-app/web/src/workspace/NodeSearch.tsx +41 -26
- lynxkite-app/web/src/workspace/Workspace.tsx +211 -152
- lynxkite-app/web/src/workspace/nodes/LynxKiteNode.tsx +61 -34
- lynxkite-app/web/src/workspace/nodes/NodeParameter.tsx +64 -43
- lynxkite-app/web/src/workspace/nodes/NodeWithImage.tsx +4 -2
- lynxkite-app/web/src/workspace/nodes/NodeWithParams.tsx +13 -8
- lynxkite-app/web/src/workspace/nodes/NodeWithTableView.tsx +59 -32
- lynxkite-app/web/src/workspace/nodes/NodeWithVisualization.tsx +10 -6
- lynxkite-app/web/src/workspace/nodes/Table.tsx +20 -15
- lynxkite-app/web/tailwind.config.js +8 -8
- lynxkite-app/web/tests/basic.spec.ts +18 -23
- lynxkite-app/web/tests/directory.spec.ts +60 -60
- lynxkite-app/web/tests/errors.spec.ts +24 -24
- lynxkite-app/web/tests/examples.spec.ts +32 -40
- lynxkite-app/web/tests/lynxkite.ts +61 -50
- lynxkite-app/web/tests/upload.spec.ts +28 -26
- lynxkite-app/web/tsconfig.app.json +0 -4
- lynxkite-app/web/tsconfig.node.json +0 -4
- lynxkite-app/web/vite.config.ts +9 -12
biome.json
ADDED
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"formatter": {
|
3 |
+
"ignore": ["**/node_modules/**", "**/dist/**"],
|
4 |
+
"indentStyle": "space"
|
5 |
+
},
|
6 |
+
"linter": {
|
7 |
+
"ignore": ["**/node_modules/**", "**/dist/**"],
|
8 |
+
"rules": {
|
9 |
+
"suspicious": {
|
10 |
+
"noExplicitAny": "off",
|
11 |
+
"noArrayIndexKey": "off"
|
12 |
+
},
|
13 |
+
"style": {
|
14 |
+
"noNonNullAssertion": "off"
|
15 |
+
},
|
16 |
+
"a11y": {
|
17 |
+
"useKeyWithClickEvents": "off",
|
18 |
+
"useValidAnchor": "off",
|
19 |
+
"useButtonType": "off",
|
20 |
+
"noNoninteractiveTabindex": "off"
|
21 |
+
}
|
22 |
+
}
|
23 |
+
}
|
24 |
+
}
|
lynxkite-app/web/eslint.config.js
CHANGED
@@ -1,28 +1,28 @@
|
|
1 |
-
import js from
|
2 |
-
import
|
3 |
-
import
|
4 |
-
import
|
5 |
-
import tseslint from
|
6 |
|
7 |
export default tseslint.config(
|
8 |
-
{ ignores: [
|
9 |
{
|
10 |
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
11 |
-
files: [
|
12 |
languageOptions: {
|
13 |
ecmaVersion: 2020,
|
14 |
globals: globals.browser,
|
15 |
},
|
16 |
plugins: {
|
17 |
-
|
18 |
-
|
19 |
},
|
20 |
rules: {
|
21 |
...reactHooks.configs.recommended.rules,
|
22 |
-
|
23 |
-
|
24 |
{ allowConstantExport: true },
|
25 |
],
|
26 |
},
|
27 |
},
|
28 |
-
)
|
|
|
1 |
+
import js from "@eslint/js";
|
2 |
+
import reactHooks from "eslint-plugin-react-hooks";
|
3 |
+
import reactRefresh from "eslint-plugin-react-refresh";
|
4 |
+
import globals from "globals";
|
5 |
+
import tseslint from "typescript-eslint";
|
6 |
|
7 |
export default tseslint.config(
|
8 |
+
{ ignores: ["dist"] },
|
9 |
{
|
10 |
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
11 |
+
files: ["**/*.{ts,tsx}"],
|
12 |
languageOptions: {
|
13 |
ecmaVersion: 2020,
|
14 |
globals: globals.browser,
|
15 |
},
|
16 |
plugins: {
|
17 |
+
"react-hooks": reactHooks,
|
18 |
+
"react-refresh": reactRefresh,
|
19 |
},
|
20 |
rules: {
|
21 |
...reactHooks.configs.recommended.rules,
|
22 |
+
"react-refresh/only-export-components": [
|
23 |
+
"warn",
|
24 |
{ allowConstantExport: true },
|
25 |
],
|
26 |
},
|
27 |
},
|
28 |
+
);
|
lynxkite-app/web/playwright.config.ts
CHANGED
@@ -1,31 +1,30 @@
|
|
1 |
-
import { defineConfig, devices } from
|
2 |
-
|
3 |
|
4 |
export default defineConfig({
|
5 |
-
testDir:
|
6 |
timeout: 30000,
|
7 |
fullyParallel: false,
|
8 |
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
9 |
forbidOnly: !!process.env.CI,
|
10 |
retries: process.env.CI ? 1 : 0,
|
11 |
workers: 1,
|
12 |
-
reporter: process.env.CI ? [[
|
13 |
use: {
|
14 |
/* Base URL to use in actions like `await page.goto('/')`. */
|
15 |
-
baseURL:
|
16 |
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
17 |
-
trace:
|
18 |
-
testIdAttribute:
|
19 |
},
|
20 |
projects: [
|
21 |
{
|
22 |
-
name:
|
23 |
-
use: { ...devices[
|
24 |
},
|
25 |
],
|
26 |
webServer: {
|
27 |
-
command:
|
28 |
-
url:
|
29 |
reuseExistingServer: false,
|
30 |
},
|
31 |
});
|
|
|
1 |
+
import { defineConfig, devices } from "@playwright/test";
|
|
|
2 |
|
3 |
export default defineConfig({
|
4 |
+
testDir: "./tests",
|
5 |
timeout: 30000,
|
6 |
fullyParallel: false,
|
7 |
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
8 |
forbidOnly: !!process.env.CI,
|
9 |
retries: process.env.CI ? 1 : 0,
|
10 |
workers: 1,
|
11 |
+
reporter: process.env.CI ? [["github"], ["html"]] : "html",
|
12 |
use: {
|
13 |
/* Base URL to use in actions like `await page.goto('/')`. */
|
14 |
+
baseURL: "http://127.0.0.1:8000",
|
15 |
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
16 |
+
trace: "on-first-retry",
|
17 |
+
testIdAttribute: "data-nodeid", // Useful for easily selecting nodes using getByTestId
|
18 |
},
|
19 |
projects: [
|
20 |
{
|
21 |
+
name: "chromium",
|
22 |
+
use: { ...devices["Desktop Chrome"] },
|
23 |
},
|
24 |
],
|
25 |
webServer: {
|
26 |
+
command: "cd .. && lynxkite",
|
27 |
+
url: "http://127.0.0.1:8000",
|
28 |
reuseExistingServer: false,
|
29 |
},
|
30 |
});
|
lynxkite-app/web/postcss.config.js
CHANGED
@@ -3,4 +3,4 @@ export default {
|
|
3 |
tailwindcss: {},
|
4 |
autoprefixer: {},
|
5 |
},
|
6 |
-
}
|
|
|
3 |
tailwindcss: {},
|
4 |
autoprefixer: {},
|
5 |
},
|
6 |
+
};
|
lynxkite-app/web/src/Directory.tsx
CHANGED
@@ -21,182 +21,182 @@ import logo from "./assets/logo.png";
|
|
21 |
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
22 |
|
23 |
export default function () {
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
114 |
-
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
|
140 |
-
|
141 |
-
|
142 |
-
|
143 |
-
|
144 |
-
|
145 |
-
|
146 |
-
|
147 |
-
|
148 |
-
|
149 |
-
|
150 |
-
|
151 |
-
|
152 |
-
|
153 |
-
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
|
159 |
-
|
160 |
-
|
161 |
-
|
162 |
-
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
|
172 |
-
|
173 |
-
|
174 |
-
|
175 |
-
|
176 |
-
|
177 |
-
|
178 |
-
|
179 |
-
|
180 |
-
|
181 |
-
|
182 |
-
|
183 |
-
|
184 |
-
|
185 |
-
|
186 |
-
|
187 |
-
|
188 |
-
|
189 |
-
|
190 |
-
|
191 |
-
|
192 |
-
|
193 |
-
|
194 |
-
|
195 |
-
|
196 |
-
|
197 |
-
|
198 |
-
|
199 |
-
|
200 |
-
|
201 |
-
|
202 |
}
|
|
|
21 |
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
22 |
|
23 |
export default function () {
|
24 |
+
const { path } = useParams();
|
25 |
+
const encodedPath = encodeURIComponent(path || "");
|
26 |
+
const list = useSWR(`/api/dir/list?path=${encodedPath}`, fetcher);
|
27 |
+
const navigate = useNavigate();
|
28 |
+
const [isCreatingDir, setIsCreatingDir] = useState(false);
|
29 |
+
const [isCreatingWorkspace, setIsCreatingWorkspace] = useState(false);
|
30 |
+
|
31 |
+
function link(item: DirectoryEntry) {
|
32 |
+
if (item.type === "directory") {
|
33 |
+
return `/dir/${item.name}`;
|
34 |
+
}
|
35 |
+
return `/edit/${item.name}`;
|
36 |
+
}
|
37 |
+
|
38 |
+
function shortName(item: DirectoryEntry) {
|
39 |
+
return item.name.split("/").pop();
|
40 |
+
}
|
41 |
+
|
42 |
+
function newName(list: DirectoryEntry[], baseName = "Untitled") {
|
43 |
+
let i = 0;
|
44 |
+
while (true) {
|
45 |
+
const name = `${baseName}${i ? ` ${i}` : ""}`;
|
46 |
+
if (!list.find((item) => item.name === name)) {
|
47 |
+
return name;
|
48 |
+
}
|
49 |
+
i++;
|
50 |
+
}
|
51 |
+
}
|
52 |
+
|
53 |
+
function newWorkspaceIn(
|
54 |
+
path: string,
|
55 |
+
list: DirectoryEntry[],
|
56 |
+
workspaceName?: string,
|
57 |
+
) {
|
58 |
+
const pathSlash = path ? `${path}/` : "";
|
59 |
+
const name = workspaceName || newName(list);
|
60 |
+
navigate(`/edit/${pathSlash}${name}`, { replace: true });
|
61 |
+
}
|
62 |
+
|
63 |
+
async function newFolderIn(
|
64 |
+
path: string,
|
65 |
+
list: DirectoryEntry[],
|
66 |
+
folderName?: string,
|
67 |
+
) {
|
68 |
+
const name = folderName || newName(list, "New Folder");
|
69 |
+
const pathSlash = path ? `${path}/` : "";
|
70 |
+
|
71 |
+
const res = await fetch("/api/dir/mkdir", {
|
72 |
+
method: "POST",
|
73 |
+
headers: { "Content-Type": "application/json" },
|
74 |
+
body: JSON.stringify({ path: pathSlash + name }),
|
75 |
+
});
|
76 |
+
if (res.ok) {
|
77 |
+
navigate(`/dir/${pathSlash}${name}`);
|
78 |
+
} else {
|
79 |
+
alert("Failed to create folder.");
|
80 |
+
}
|
81 |
+
}
|
82 |
+
|
83 |
+
async function deleteItem(item: DirectoryEntry) {
|
84 |
+
if (!window.confirm(`Are you sure you want to delete "${item.name}"?`))
|
85 |
+
return;
|
86 |
+
const pathSlash = path ? `${path}/` : "";
|
87 |
+
|
88 |
+
const apiPath =
|
89 |
+
item.type === "directory" ? "/api/dir/delete" : "/api/delete";
|
90 |
+
await fetch(apiPath, {
|
91 |
+
method: "POST",
|
92 |
+
headers: { "Content-Type": "application/json" },
|
93 |
+
body: JSON.stringify({ path: pathSlash + item.name }),
|
94 |
+
});
|
95 |
+
}
|
96 |
+
|
97 |
+
return (
|
98 |
+
<div className="directory">
|
99 |
+
<div className="logo">
|
100 |
+
<a href="https://lynxkite.com/">
|
101 |
+
<img src={logo} className="logo-image" alt="LynxKite logo" />
|
102 |
+
</a>
|
103 |
+
<div className="tagline">The Complete Graph Data Science Platform</div>
|
104 |
+
</div>
|
105 |
+
<div className="entry-list">
|
106 |
+
{list.error && <p className="error">{list.error.message}</p>}
|
107 |
+
{list.isLoading && (
|
108 |
+
<output className="loading spinner-border">
|
109 |
+
<span className="visually-hidden">Loading...</span>
|
110 |
+
</output>
|
111 |
+
)}
|
112 |
+
|
113 |
+
{list.data && (
|
114 |
+
<>
|
115 |
+
<div className="actions">
|
116 |
+
<div className="new-workspace">
|
117 |
+
{isCreatingWorkspace && (
|
118 |
+
// @ts-ignore
|
119 |
+
<form
|
120 |
+
onSubmit={(e) => {
|
121 |
+
e.preventDefault();
|
122 |
+
newWorkspaceIn(
|
123 |
+
path || "",
|
124 |
+
list.data,
|
125 |
+
e.target.workspaceName.value.trim(),
|
126 |
+
);
|
127 |
+
}}
|
128 |
+
>
|
129 |
+
<input
|
130 |
+
type="text"
|
131 |
+
name="workspaceName"
|
132 |
+
defaultValue={newName(list.data)}
|
133 |
+
placeholder={newName(list.data)}
|
134 |
+
/>
|
135 |
+
</form>
|
136 |
+
)}
|
137 |
+
<button
|
138 |
+
type="button"
|
139 |
+
onClick={() => setIsCreatingWorkspace(true)}
|
140 |
+
>
|
141 |
+
<FolderPlus /> New workspace
|
142 |
+
</button>
|
143 |
+
</div>
|
144 |
+
|
145 |
+
<div className="new-folder">
|
146 |
+
{isCreatingDir && (
|
147 |
+
// @ts-ignore
|
148 |
+
<form
|
149 |
+
onSubmit={(e) => {
|
150 |
+
e.preventDefault();
|
151 |
+
newFolderIn(
|
152 |
+
path || "",
|
153 |
+
list.data,
|
154 |
+
e.target.folderName.value.trim(),
|
155 |
+
);
|
156 |
+
}}
|
157 |
+
>
|
158 |
+
<input
|
159 |
+
type="text"
|
160 |
+
name="folderName"
|
161 |
+
defaultValue={newName(list.data)}
|
162 |
+
placeholder={newName(list.data)}
|
163 |
+
/>
|
164 |
+
</form>
|
165 |
+
)}
|
166 |
+
<button type="button" onClick={() => setIsCreatingDir(true)}>
|
167 |
+
<FolderPlus /> New folder
|
168 |
+
</button>
|
169 |
+
</div>
|
170 |
+
</div>
|
171 |
+
|
172 |
+
{path && (
|
173 |
+
<div className="breadcrumbs">
|
174 |
+
<a href="/dir/">
|
175 |
+
<Home />
|
176 |
+
</a>{" "}
|
177 |
+
<span className="current-folder">{path}</span>
|
178 |
+
</div>
|
179 |
+
)}
|
180 |
+
|
181 |
+
{list.data.map((item: DirectoryEntry) => (
|
182 |
+
<div key={item.name} className="entry">
|
183 |
+
<a key={link(item)} href={link(item)}>
|
184 |
+
{item.type === "directory" ? <Folder /> : <File />}
|
185 |
+
{shortName(item)}
|
186 |
+
</a>
|
187 |
+
<button
|
188 |
+
type="button"
|
189 |
+
onClick={() => {
|
190 |
+
deleteItem(item);
|
191 |
+
}}
|
192 |
+
>
|
193 |
+
<Trash />
|
194 |
+
</button>
|
195 |
+
</div>
|
196 |
+
))}
|
197 |
+
</>
|
198 |
+
)}
|
199 |
+
</div>{" "}
|
200 |
+
</div>
|
201 |
+
);
|
202 |
}
|
lynxkite-app/web/src/apiTypes.ts
CHANGED
@@ -6,13 +6,13 @@
|
|
6 |
*/
|
7 |
|
8 |
export interface DirectoryEntry {
|
9 |
-
|
10 |
-
|
11 |
}
|
12 |
export interface SaveRequest {
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
}
|
17 |
/**
|
18 |
* A workspace is a representation of a computational graph that consists of nodes and edges.
|
@@ -22,37 +22,37 @@ export interface SaveRequest {
|
|
22 |
* that can be performed in the workspace and the execution method for the operations.
|
23 |
*/
|
24 |
export interface Workspace {
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
}
|
30 |
export interface WorkspaceNode {
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
}
|
37 |
export interface WorkspaceNodeData {
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
}
|
46 |
export interface Position {
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
}
|
51 |
export interface WorkspaceEdge {
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
}
|
|
|
6 |
*/
|
7 |
|
8 |
export interface DirectoryEntry {
|
9 |
+
name: string;
|
10 |
+
type: string;
|
11 |
}
|
12 |
export interface SaveRequest {
|
13 |
+
path: string;
|
14 |
+
ws: Workspace;
|
15 |
+
[k: string]: unknown;
|
16 |
}
|
17 |
/**
|
18 |
* A workspace is a representation of a computational graph that consists of nodes and edges.
|
|
|
22 |
* that can be performed in the workspace and the execution method for the operations.
|
23 |
*/
|
24 |
export interface Workspace {
|
25 |
+
env?: string;
|
26 |
+
nodes?: WorkspaceNode[];
|
27 |
+
edges?: WorkspaceEdge[];
|
28 |
+
[k: string]: unknown;
|
29 |
}
|
30 |
export interface WorkspaceNode {
|
31 |
+
id: string;
|
32 |
+
type: string;
|
33 |
+
data: WorkspaceNodeData;
|
34 |
+
position: Position;
|
35 |
+
[k: string]: unknown;
|
36 |
}
|
37 |
export interface WorkspaceNodeData {
|
38 |
+
title: string;
|
39 |
+
params: {
|
40 |
+
[k: string]: unknown;
|
41 |
+
};
|
42 |
+
display?: unknown;
|
43 |
+
error?: string | null;
|
44 |
+
[k: string]: unknown;
|
45 |
}
|
46 |
export interface Position {
|
47 |
+
x: number;
|
48 |
+
y: number;
|
49 |
+
[k: string]: unknown;
|
50 |
}
|
51 |
export interface WorkspaceEdge {
|
52 |
+
id: string;
|
53 |
+
source: string;
|
54 |
+
target: string;
|
55 |
+
sourceHandle: string;
|
56 |
+
targetHandle: string;
|
57 |
+
[k: string]: unknown;
|
58 |
}
|
lynxkite-app/web/src/index.css
CHANGED
@@ -154,7 +154,6 @@ body {
|
|
154 |
width: fit-content;
|
155 |
padding: 2px 8px;
|
156 |
border-radius: 4px 4px 0 0;
|
157 |
-
;
|
158 |
}
|
159 |
|
160 |
.collapsed-param {
|
@@ -317,7 +316,6 @@ body {
|
|
317 |
}
|
318 |
}
|
319 |
|
320 |
-
|
321 |
path.react-flow__edge-path {
|
322 |
stroke-width: 2;
|
323 |
stroke: black;
|
|
|
154 |
width: fit-content;
|
155 |
padding: 2px 8px;
|
156 |
border-radius: 4px 4px 0 0;
|
|
|
157 |
}
|
158 |
|
159 |
.collapsed-param {
|
|
|
316 |
}
|
317 |
}
|
318 |
|
|
|
319 |
path.react-flow__edge-path {
|
320 |
stroke-width: 2;
|
321 |
stroke: black;
|
lynxkite-app/web/src/main.tsx
CHANGED
@@ -1,12 +1,12 @@
|
|
1 |
-
import { StrictMode } from
|
2 |
-
import { createRoot } from
|
3 |
-
import
|
4 |
-
import
|
5 |
-
import
|
6 |
-
import
|
7 |
-
import
|
8 |
|
9 |
-
createRoot(document.getElementById(
|
10 |
<StrictMode>
|
11 |
<BrowserRouter>
|
12 |
<Routes>
|
@@ -17,4 +17,4 @@ createRoot(document.getElementById('root')!).render(
|
|
17 |
</Routes>
|
18 |
</BrowserRouter>
|
19 |
</StrictMode>,
|
20 |
-
)
|
|
|
1 |
+
import { StrictMode } from "react";
|
2 |
+
import { createRoot } from "react-dom/client";
|
3 |
+
import "@xyflow/react/dist/style.css";
|
4 |
+
import "./index.css";
|
5 |
+
import { BrowserRouter, Route, Routes } from "react-router";
|
6 |
+
import Directory from "./Directory.tsx";
|
7 |
+
import Workspace from "./workspace/Workspace.tsx";
|
8 |
|
9 |
+
createRoot(document.getElementById("root")!).render(
|
10 |
<StrictMode>
|
11 |
<BrowserRouter>
|
12 |
<Routes>
|
|
|
17 |
</Routes>
|
18 |
</BrowserRouter>
|
19 |
</StrictMode>,
|
20 |
+
);
|
lynxkite-app/web/src/workspace/EnvironmentSelector.tsx
CHANGED
@@ -1,14 +1,21 @@
|
|
1 |
-
export default function EnvironmentSelector(props: {
|
|
|
|
|
|
|
|
|
2 |
return (
|
3 |
<>
|
4 |
-
<select
|
|
|
5 |
name="workspace-env"
|
6 |
value={props.value}
|
7 |
onChange={(evt) => props.onChange(evt.currentTarget.value)}
|
8 |
>
|
9 |
-
{props.options.map(option =>
|
10 |
-
<option key={option} value={option}>
|
11 |
-
|
|
|
|
|
12 |
</select>
|
13 |
</>
|
14 |
);
|
|
|
1 |
+
export default function EnvironmentSelector(props: {
|
2 |
+
options: string[];
|
3 |
+
value: string;
|
4 |
+
onChange: (val: string) => void;
|
5 |
+
}) {
|
6 |
return (
|
7 |
<>
|
8 |
+
<select
|
9 |
+
className="select w-full max-w-xs"
|
10 |
name="workspace-env"
|
11 |
value={props.value}
|
12 |
onChange={(evt) => props.onChange(evt.currentTarget.value)}
|
13 |
>
|
14 |
+
{props.options.map((option) => (
|
15 |
+
<option key={option} value={option}>
|
16 |
+
{option}
|
17 |
+
</option>
|
18 |
+
))}
|
19 |
</select>
|
20 |
</>
|
21 |
);
|
lynxkite-app/web/src/workspace/LynxKiteState.ts
CHANGED
@@ -1,4 +1,4 @@
|
|
1 |
import { createContext } from "react";
|
2 |
-
import { Workspace } from "../apiTypes.ts";
|
3 |
|
4 |
export const LynxKiteState = createContext({ workspace: {} as Workspace });
|
|
|
1 |
import { createContext } from "react";
|
2 |
+
import type { Workspace } from "../apiTypes.ts";
|
3 |
|
4 |
export const LynxKiteState = createContext({ workspace: {} as Workspace });
|
lynxkite-app/web/src/workspace/NodeSearch.tsx
CHANGED
@@ -1,22 +1,33 @@
|
|
1 |
-
import Fuse from
|
2 |
-
import { useEffect, useMemo, useRef, useState } from
|
3 |
|
4 |
export type OpsOp = {
|
5 |
-
name: string
|
6 |
-
type: string
|
7 |
-
position: { x: number
|
8 |
-
params: { name: string
|
9 |
-
}
|
10 |
export type Catalog = { [op: string]: OpsOp };
|
11 |
export type Catalogs = { [env: string]: Catalog };
|
12 |
|
13 |
-
export default function (props: {
|
|
|
|
|
|
|
|
|
|
|
14 |
const searchBox = useRef(null as unknown as HTMLInputElement);
|
15 |
-
const [searchText, setSearchText] = useState(
|
16 |
-
const fuse = useMemo(
|
17 |
-
|
18 |
-
|
19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
20 |
const [selectedIndex, setSelectedIndex] = useState(0);
|
21 |
useEffect(() => searchBox.current.focus());
|
22 |
function typed(text: string) {
|
@@ -24,15 +35,15 @@ export default function (props: { boxes: Catalog, onCancel: any, onAdd: any, pos
|
|
24 |
setSelectedIndex(Math.max(0, Math.min(selectedIndex, hits.length - 1)));
|
25 |
}
|
26 |
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
27 |
-
if (e.key ===
|
28 |
e.preventDefault();
|
29 |
setSelectedIndex(Math.min(selectedIndex + 1, hits.length - 1));
|
30 |
-
} else if (e.key ===
|
31 |
e.preventDefault();
|
32 |
setSelectedIndex(Math.max(selectedIndex - 1, 0));
|
33 |
-
} else if (e.key ===
|
34 |
addSelected();
|
35 |
-
} else if (e.key ===
|
36 |
props.onCancel();
|
37 |
}
|
38 |
}
|
@@ -43,33 +54,37 @@ export default function (props: { boxes: Catalog, onCancel: any, onAdd: any, pos
|
|
43 |
}
|
44 |
async function lostFocus(e: any) {
|
45 |
// If it's a click on a result, let the click handler handle it.
|
46 |
-
if (e.relatedTarget
|
47 |
props.onCancel();
|
48 |
}
|
49 |
|
50 |
-
|
51 |
return (
|
52 |
-
<div
|
|
|
|
|
|
|
53 |
<input
|
54 |
ref={searchBox}
|
55 |
value={searchText}
|
56 |
-
onChange={event => typed(event.target.value)}
|
57 |
onKeyDown={onKeyDown}
|
58 |
onBlur={lostFocus}
|
59 |
-
placeholder="Search for box"
|
|
|
60 |
<div className="matches">
|
61 |
-
{hits.map((box, index) =>
|
62 |
<div
|
63 |
key={box.item.name}
|
64 |
tabIndex={0}
|
65 |
onFocus={() => setSelectedIndex(index)}
|
66 |
onMouseEnter={() => setSelectedIndex(index)}
|
67 |
onClick={addSelected}
|
68 |
-
className={`search-result ${index === selectedIndex ?
|
|
|
69 |
{box.item.name}
|
70 |
</div>
|
71 |
-
)}
|
72 |
</div>
|
73 |
-
</div
|
74 |
);
|
75 |
}
|
|
|
1 |
+
import Fuse from "fuse.js";
|
2 |
+
import { useEffect, useMemo, useRef, useState } from "react";
|
3 |
|
4 |
export type OpsOp = {
|
5 |
+
name: string;
|
6 |
+
type: string;
|
7 |
+
position: { x: number; y: number };
|
8 |
+
params: { name: string; default: any }[];
|
9 |
+
};
|
10 |
export type Catalog = { [op: string]: OpsOp };
|
11 |
export type Catalogs = { [env: string]: Catalog };
|
12 |
|
13 |
+
export default function (props: {
|
14 |
+
boxes: Catalog;
|
15 |
+
onCancel: any;
|
16 |
+
onAdd: any;
|
17 |
+
pos: { x: number; y: number };
|
18 |
+
}) {
|
19 |
const searchBox = useRef(null as unknown as HTMLInputElement);
|
20 |
+
const [searchText, setSearchText] = useState("");
|
21 |
+
const fuse = useMemo(
|
22 |
+
() =>
|
23 |
+
new Fuse(Object.values(props.boxes), {
|
24 |
+
keys: ["name"],
|
25 |
+
}),
|
26 |
+
[props.boxes],
|
27 |
+
);
|
28 |
+
const hits: { item: OpsOp }[] = searchText
|
29 |
+
? fuse.search<OpsOp>(searchText)
|
30 |
+
: Object.values(props.boxes).map((box) => ({ item: box }));
|
31 |
const [selectedIndex, setSelectedIndex] = useState(0);
|
32 |
useEffect(() => searchBox.current.focus());
|
33 |
function typed(text: string) {
|
|
|
35 |
setSelectedIndex(Math.max(0, Math.min(selectedIndex, hits.length - 1)));
|
36 |
}
|
37 |
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
38 |
+
if (e.key === "ArrowDown") {
|
39 |
e.preventDefault();
|
40 |
setSelectedIndex(Math.min(selectedIndex + 1, hits.length - 1));
|
41 |
+
} else if (e.key === "ArrowUp") {
|
42 |
e.preventDefault();
|
43 |
setSelectedIndex(Math.max(selectedIndex - 1, 0));
|
44 |
+
} else if (e.key === "Enter") {
|
45 |
addSelected();
|
46 |
+
} else if (e.key === "Escape") {
|
47 |
props.onCancel();
|
48 |
}
|
49 |
}
|
|
|
54 |
}
|
55 |
async function lostFocus(e: any) {
|
56 |
// If it's a click on a result, let the click handler handle it.
|
57 |
+
if (e.relatedTarget?.closest(".node-search")) return;
|
58 |
props.onCancel();
|
59 |
}
|
60 |
|
|
|
61 |
return (
|
62 |
+
<div
|
63 |
+
className="node-search"
|
64 |
+
style={{ top: props.pos.y, left: props.pos.x }}
|
65 |
+
>
|
66 |
<input
|
67 |
ref={searchBox}
|
68 |
value={searchText}
|
69 |
+
onChange={(event) => typed(event.target.value)}
|
70 |
onKeyDown={onKeyDown}
|
71 |
onBlur={lostFocus}
|
72 |
+
placeholder="Search for box"
|
73 |
+
/>
|
74 |
<div className="matches">
|
75 |
+
{hits.map((box, index) => (
|
76 |
<div
|
77 |
key={box.item.name}
|
78 |
tabIndex={0}
|
79 |
onFocus={() => setSelectedIndex(index)}
|
80 |
onMouseEnter={() => setSelectedIndex(index)}
|
81 |
onClick={addSelected}
|
82 |
+
className={`search-result ${index === selectedIndex ? "selected" : ""}`}
|
83 |
+
>
|
84 |
{box.item.name}
|
85 |
</div>
|
86 |
+
))}
|
87 |
</div>
|
88 |
+
</div>
|
89 |
);
|
90 |
}
|
lynxkite-app/web/src/workspace/Workspace.tsx
CHANGED
@@ -1,40 +1,50 @@
|
|
1 |
-
|
2 |
-
import { useParams } from "react-router";
|
3 |
-
import useSWR, { Fetcher } from 'swr';
|
4 |
-
import { useEffect, useMemo, useCallback, useState, MouseEvent } from "react";
|
5 |
-
import favicon from '../assets/favicon.ico';
|
6 |
import {
|
7 |
-
|
8 |
Controls,
|
|
|
9 |
MarkerType,
|
|
|
|
|
|
|
10 |
ReactFlowProvider,
|
|
|
11 |
applyEdgeChanges,
|
12 |
applyNodeChanges,
|
13 |
-
useUpdateNodeInternals,
|
14 |
-
type XYPosition,
|
15 |
-
type Node,
|
16 |
-
type Edge,
|
17 |
-
type Connection,
|
18 |
useReactFlow,
|
19 |
-
|
20 |
-
} from
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
21 |
// @ts-ignore
|
22 |
-
import ArrowBack from
|
23 |
// @ts-ignore
|
24 |
-
import
|
25 |
// @ts-ignore
|
26 |
-
import
|
27 |
-
import {
|
28 |
-
import
|
29 |
-
import NodeWithParams from './nodes/NodeWithParams';
|
30 |
// import NodeWithTableView from './NodeWithTableView';
|
31 |
-
import EnvironmentSelector from
|
32 |
-
import { LynxKiteState } from
|
33 |
-
import
|
34 |
-
|
35 |
-
|
|
|
|
|
36 |
import NodeWithImage from "./nodes/NodeWithImage.tsx";
|
|
|
37 |
import NodeWithTableView from "./nodes/NodeWithTableView.tsx";
|
|
|
38 |
|
39 |
export default function (props: any) {
|
40 |
return (
|
@@ -44,9 +54,8 @@ export default function (props: any) {
|
|
44 |
);
|
45 |
}
|
46 |
|
47 |
-
|
48 |
function LynxKiteFlow() {
|
49 |
-
const updateNodeInternals = useUpdateNodeInternals()
|
50 |
const reactFlow = useReactFlow();
|
51 |
const [nodes, setNodes] = useState([] as Node[]);
|
52 |
const [edges, setEdges] = useState([] as Edge[]);
|
@@ -56,7 +65,11 @@ function LynxKiteFlow() {
|
|
56 |
const state = syncedStore({ workspace: {} as Workspace });
|
57 |
setState(state);
|
58 |
const doc = getYjsDoc(state);
|
59 |
-
const wsProvider = new WebsocketProvider(
|
|
|
|
|
|
|
|
|
60 |
const onChange = (_update: any, origin: any, _doc: any, _tr: any) => {
|
61 |
if (origin === wsProvider) {
|
62 |
// An update from the CRDT. Apply it to the local state.
|
@@ -72,154 +85,196 @@ function LynxKiteFlow() {
|
|
72 |
}
|
73 |
}
|
74 |
};
|
75 |
-
doc.on(
|
76 |
return () => {
|
77 |
doc.destroy();
|
78 |
wsProvider.destroy();
|
79 |
-
}
|
80 |
-
}, [path]);
|
81 |
|
82 |
-
const onNodesChange = useCallback(
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
const
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
114 |
}
|
115 |
-
}
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
wedges.
|
125 |
-
|
126 |
-
|
127 |
-
|
|
|
|
|
|
|
128 |
}
|
129 |
-
}
|
130 |
-
|
|
|
131 |
|
132 |
-
const fetcher: Fetcher<Catalogs> = (resource: string, init?: RequestInit) =>
|
133 |
-
|
|
|
134 |
const [suppressSearchUntil, setSuppressSearchUntil] = useState(0);
|
135 |
-
const [nodeSearchSettings, setNodeSearchSettings] = useState(
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
|
140 |
-
|
141 |
-
|
142 |
-
|
143 |
-
|
144 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
145 |
const closeNodeSearch = useCallback(() => {
|
146 |
setNodeSearchSettings(undefined);
|
147 |
setSuppressSearchUntil(Date.now() + 200);
|
148 |
-
}, [
|
149 |
-
const toggleNodeSearch = useCallback(
|
150 |
-
|
151 |
-
|
152 |
-
|
153 |
-
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
|
159 |
-
|
160 |
-
|
161 |
-
|
162 |
-
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
|
172 |
-
|
173 |
-
|
174 |
-
|
175 |
-
|
176 |
-
|
177 |
-
|
178 |
-
|
|
|
|
|
|
|
|
|
179 |
node.id = `${title} ${i}`;
|
180 |
-
|
181 |
-
|
182 |
-
|
183 |
-
|
184 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
185 |
|
186 |
-
const onConnect = useCallback(
|
187 |
-
|
188 |
-
|
189 |
-
|
190 |
-
|
191 |
-
|
192 |
-
|
193 |
-
|
194 |
-
|
195 |
-
|
196 |
-
|
197 |
-
|
198 |
-
|
|
|
|
|
|
|
199 |
return (
|
200 |
<div className="workspace">
|
201 |
<div className="top-bar bg-neutral">
|
202 |
-
<a className="logo" href=""
|
203 |
-
|
204 |
-
|
205 |
-
</div>
|
206 |
<EnvironmentSelector
|
207 |
options={Object.keys(catalog.data || {})}
|
208 |
value={state.workspace.env!}
|
209 |
-
onChange={(env) => {
|
|
|
|
|
210 |
/>
|
211 |
<div className="tools text-secondary">
|
212 |
-
<a href=""
|
213 |
-
|
214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
215 |
</div>
|
216 |
</div>
|
217 |
-
<div style={{ height: "100%", width:
|
218 |
<LynxKiteState.Provider value={state}>
|
219 |
<ReactFlow
|
220 |
nodes={nodes}
|
221 |
edges={edges}
|
222 |
-
nodeTypes={nodeTypes}
|
|
|
223 |
onNodesChange={onNodesChange}
|
224 |
onEdgesChange={onEdgesChange}
|
225 |
onPaneClick={toggleNodeSearch}
|
@@ -231,13 +286,17 @@ function LynxKiteFlow() {
|
|
231 |
>
|
232 |
<Controls />
|
233 |
<MiniMap />
|
234 |
-
{nodeSearchSettings &&
|
235 |
-
<NodeSearch
|
236 |
-
|
|
|
|
|
|
|
|
|
|
|
237 |
</ReactFlow>
|
238 |
</LynxKiteState.Provider>
|
239 |
</div>
|
240 |
</div>
|
241 |
-
|
242 |
);
|
243 |
}
|
|
|
1 |
+
import { getYjsDoc, syncedStore } from "@syncedstore/core";
|
|
|
|
|
|
|
|
|
2 |
import {
|
3 |
+
type Connection,
|
4 |
Controls,
|
5 |
+
type Edge,
|
6 |
MarkerType,
|
7 |
+
MiniMap,
|
8 |
+
type Node,
|
9 |
+
ReactFlow,
|
10 |
ReactFlowProvider,
|
11 |
+
type XYPosition,
|
12 |
applyEdgeChanges,
|
13 |
applyNodeChanges,
|
|
|
|
|
|
|
|
|
|
|
14 |
useReactFlow,
|
15 |
+
useUpdateNodeInternals,
|
16 |
+
} from "@xyflow/react";
|
17 |
+
import {
|
18 |
+
type MouseEvent,
|
19 |
+
useCallback,
|
20 |
+
useEffect,
|
21 |
+
useMemo,
|
22 |
+
useState,
|
23 |
+
} from "react";
|
24 |
+
// The LynxKite workspace editor.
|
25 |
+
import { useParams } from "react-router";
|
26 |
+
import useSWR, { type Fetcher } from "swr";
|
27 |
+
import { WebsocketProvider } from "y-websocket";
|
28 |
// @ts-ignore
|
29 |
+
import ArrowBack from "~icons/tabler/arrow-back.jsx";
|
30 |
// @ts-ignore
|
31 |
+
import Atom from "~icons/tabler/atom.jsx";
|
32 |
// @ts-ignore
|
33 |
+
import Backspace from "~icons/tabler/backspace.jsx";
|
34 |
+
import type { Workspace, WorkspaceNode } from "../apiTypes.ts";
|
35 |
+
import favicon from "../assets/favicon.ico";
|
|
|
36 |
// import NodeWithTableView from './NodeWithTableView';
|
37 |
+
import EnvironmentSelector from "./EnvironmentSelector";
|
38 |
+
import { LynxKiteState } from "./LynxKiteState";
|
39 |
+
import NodeSearch, {
|
40 |
+
type OpsOp,
|
41 |
+
type Catalog,
|
42 |
+
type Catalogs,
|
43 |
+
} from "./NodeSearch.tsx";
|
44 |
import NodeWithImage from "./nodes/NodeWithImage.tsx";
|
45 |
+
import NodeWithParams from "./nodes/NodeWithParams";
|
46 |
import NodeWithTableView from "./nodes/NodeWithTableView.tsx";
|
47 |
+
import NodeWithVisualization from "./nodes/NodeWithVisualization.tsx";
|
48 |
|
49 |
export default function (props: any) {
|
50 |
return (
|
|
|
54 |
);
|
55 |
}
|
56 |
|
|
|
57 |
function LynxKiteFlow() {
|
58 |
+
const updateNodeInternals = useUpdateNodeInternals();
|
59 |
const reactFlow = useReactFlow();
|
60 |
const [nodes, setNodes] = useState([] as Node[]);
|
61 |
const [edges, setEdges] = useState([] as Edge[]);
|
|
|
65 |
const state = syncedStore({ workspace: {} as Workspace });
|
66 |
setState(state);
|
67 |
const doc = getYjsDoc(state);
|
68 |
+
const wsProvider = new WebsocketProvider(
|
69 |
+
`ws://${location.host}/ws/crdt`,
|
70 |
+
path!,
|
71 |
+
doc,
|
72 |
+
);
|
73 |
const onChange = (_update: any, origin: any, _doc: any, _tr: any) => {
|
74 |
if (origin === wsProvider) {
|
75 |
// An update from the CRDT. Apply it to the local state.
|
|
|
85 |
}
|
86 |
}
|
87 |
};
|
88 |
+
doc.on("update", onChange);
|
89 |
return () => {
|
90 |
doc.destroy();
|
91 |
wsProvider.destroy();
|
92 |
+
};
|
93 |
+
}, [path, updateNodeInternals]);
|
94 |
|
95 |
+
const onNodesChange = useCallback(
|
96 |
+
(changes: any[]) => {
|
97 |
+
// An update from the UI. Apply it to the local state...
|
98 |
+
setNodes((nds) => applyNodeChanges(changes, nds));
|
99 |
+
// ...and to the CRDT state. (Which could be the same, except for ReactFlow's internal copies.)
|
100 |
+
const wnodes = state.workspace?.nodes;
|
101 |
+
if (!wnodes) return;
|
102 |
+
for (const ch of changes) {
|
103 |
+
const nodeIndex = wnodes.findIndex((n) => n.id === ch.id);
|
104 |
+
if (nodeIndex === -1) continue;
|
105 |
+
const node = wnodes[nodeIndex];
|
106 |
+
if (!node) continue;
|
107 |
+
// Position events sometimes come with NaN values. Ignore them.
|
108 |
+
if (
|
109 |
+
ch.type === "position" &&
|
110 |
+
!Number.isNaN(ch.position.x) &&
|
111 |
+
!Number.isNaN(ch.position.y)
|
112 |
+
) {
|
113 |
+
getYjsDoc(state).transact(() => {
|
114 |
+
Object.assign(node.position, ch.position);
|
115 |
+
});
|
116 |
+
} else if (ch.type === "select") {
|
117 |
+
} else if (ch.type === "dimensions") {
|
118 |
+
getYjsDoc(state).transact(() => Object.assign(node, ch.dimensions));
|
119 |
+
} else if (ch.type === "remove") {
|
120 |
+
wnodes.splice(nodeIndex, 1);
|
121 |
+
} else if (ch.type === "replace") {
|
122 |
+
// Ideally we would only update the parameter that changed. But ReactFlow does not give us that detail.
|
123 |
+
const u = {
|
124 |
+
collapsed: ch.item.data.collapsed,
|
125 |
+
// The "..." expansion on a Y.map returns an empty object. Copying with fromEntries/entries instead.
|
126 |
+
params: {
|
127 |
+
...Object.fromEntries(Object.entries(ch.item.data.params)),
|
128 |
+
},
|
129 |
+
__execution_delay: ch.item.data.__execution_delay,
|
130 |
+
};
|
131 |
+
getYjsDoc(state).transact(() => Object.assign(node.data, u));
|
132 |
+
} else {
|
133 |
+
console.log("Unknown node change", ch);
|
134 |
+
}
|
135 |
}
|
136 |
+
},
|
137 |
+
[state],
|
138 |
+
);
|
139 |
+
const onEdgesChange = useCallback(
|
140 |
+
(changes: any[]) => {
|
141 |
+
setEdges((eds) => applyEdgeChanges(changes, eds));
|
142 |
+
const wedges = state.workspace?.edges;
|
143 |
+
if (!wedges) return;
|
144 |
+
for (const ch of changes) {
|
145 |
+
const edgeIndex = wedges.findIndex((e) => e.id === ch.id);
|
146 |
+
if (ch.type === "remove") {
|
147 |
+
wedges.splice(edgeIndex, 1);
|
148 |
+
} else if (ch.type === "select") {
|
149 |
+
} else {
|
150 |
+
console.log("Unknown edge change", ch);
|
151 |
+
}
|
152 |
}
|
153 |
+
},
|
154 |
+
[state],
|
155 |
+
);
|
156 |
|
157 |
+
const fetcher: Fetcher<Catalogs> = (resource: string, init?: RequestInit) =>
|
158 |
+
fetch(resource, init).then((res) => res.json());
|
159 |
+
const catalog = useSWR("/api/catalog", fetcher);
|
160 |
const [suppressSearchUntil, setSuppressSearchUntil] = useState(0);
|
161 |
+
const [nodeSearchSettings, setNodeSearchSettings] = useState(
|
162 |
+
undefined as
|
163 |
+
| {
|
164 |
+
pos: XYPosition;
|
165 |
+
boxes: Catalog;
|
166 |
+
}
|
167 |
+
| undefined,
|
168 |
+
);
|
169 |
+
const nodeTypes = useMemo(
|
170 |
+
() => ({
|
171 |
+
basic: NodeWithParams,
|
172 |
+
visualization: NodeWithVisualization,
|
173 |
+
image: NodeWithImage,
|
174 |
+
table_view: NodeWithTableView,
|
175 |
+
}),
|
176 |
+
[],
|
177 |
+
);
|
178 |
const closeNodeSearch = useCallback(() => {
|
179 |
setNodeSearchSettings(undefined);
|
180 |
setSuppressSearchUntil(Date.now() + 200);
|
181 |
+
}, []);
|
182 |
+
const toggleNodeSearch = useCallback(
|
183 |
+
(event: MouseEvent) => {
|
184 |
+
if (suppressSearchUntil > Date.now()) return;
|
185 |
+
if (nodeSearchSettings) {
|
186 |
+
closeNodeSearch();
|
187 |
+
return;
|
188 |
+
}
|
189 |
+
event.preventDefault();
|
190 |
+
setNodeSearchSettings({
|
191 |
+
pos: { x: event.clientX, y: event.clientY },
|
192 |
+
boxes: catalog.data![state.workspace.env!],
|
193 |
+
});
|
194 |
+
},
|
195 |
+
[catalog, state, nodeSearchSettings, suppressSearchUntil, closeNodeSearch],
|
196 |
+
);
|
197 |
+
const addNode = useCallback(
|
198 |
+
(meta: OpsOp) => {
|
199 |
+
const node: Partial<WorkspaceNode> = {
|
200 |
+
type: meta.type,
|
201 |
+
data: {
|
202 |
+
meta: meta,
|
203 |
+
title: meta.name,
|
204 |
+
params: Object.fromEntries(
|
205 |
+
Object.values(meta.params).map((p) => [p.name, p.default]),
|
206 |
+
),
|
207 |
+
},
|
208 |
+
};
|
209 |
+
const nss = nodeSearchSettings!;
|
210 |
+
node.position = reactFlow.screenToFlowPosition({
|
211 |
+
x: nss.pos.x,
|
212 |
+
y: nss.pos.y,
|
213 |
+
});
|
214 |
+
const title = meta.name;
|
215 |
+
let i = 1;
|
216 |
node.id = `${title} ${i}`;
|
217 |
+
const wnodes = state.workspace.nodes!;
|
218 |
+
while (wnodes.find((x) => x.id === node.id)) {
|
219 |
+
i += 1;
|
220 |
+
node.id = `${title} ${i}`;
|
221 |
+
}
|
222 |
+
wnodes.push(node as WorkspaceNode);
|
223 |
+
setNodes([...nodes, node as WorkspaceNode]);
|
224 |
+
closeNodeSearch();
|
225 |
+
},
|
226 |
+
[nodeSearchSettings, state, reactFlow, nodes, closeNodeSearch],
|
227 |
+
);
|
228 |
|
229 |
+
const onConnect = useCallback(
|
230 |
+
(connection: Connection) => {
|
231 |
+
setSuppressSearchUntil(Date.now() + 200);
|
232 |
+
const edge = {
|
233 |
+
id: `${connection.source} ${connection.target}`,
|
234 |
+
source: connection.source,
|
235 |
+
sourceHandle: connection.sourceHandle!,
|
236 |
+
target: connection.target,
|
237 |
+
targetHandle: connection.targetHandle!,
|
238 |
+
};
|
239 |
+
state.workspace.edges!.push(edge);
|
240 |
+
setEdges((oldEdges) => [...oldEdges, edge]);
|
241 |
+
},
|
242 |
+
[state],
|
243 |
+
);
|
244 |
+
const parentDir = path!.split("/").slice(0, -1).join("/");
|
245 |
return (
|
246 |
<div className="workspace">
|
247 |
<div className="top-bar bg-neutral">
|
248 |
+
<a className="logo" href="">
|
249 |
+
<img alt="" src={favicon} />
|
250 |
+
</a>
|
251 |
+
<div className="ws-name">{path}</div>
|
252 |
<EnvironmentSelector
|
253 |
options={Object.keys(catalog.data || {})}
|
254 |
value={state.workspace.env!}
|
255 |
+
onChange={(env) => {
|
256 |
+
state.workspace.env = env;
|
257 |
+
}}
|
258 |
/>
|
259 |
<div className="tools text-secondary">
|
260 |
+
<a href="">
|
261 |
+
<Atom />
|
262 |
+
</a>
|
263 |
+
<a href="">
|
264 |
+
<Backspace />
|
265 |
+
</a>
|
266 |
+
<a href={`/dir/${parentDir}`}>
|
267 |
+
<ArrowBack />
|
268 |
+
</a>
|
269 |
</div>
|
270 |
</div>
|
271 |
+
<div style={{ height: "100%", width: "100vw" }}>
|
272 |
<LynxKiteState.Provider value={state}>
|
273 |
<ReactFlow
|
274 |
nodes={nodes}
|
275 |
edges={edges}
|
276 |
+
nodeTypes={nodeTypes}
|
277 |
+
fitView
|
278 |
onNodesChange={onNodesChange}
|
279 |
onEdgesChange={onEdgesChange}
|
280 |
onPaneClick={toggleNodeSearch}
|
|
|
286 |
>
|
287 |
<Controls />
|
288 |
<MiniMap />
|
289 |
+
{nodeSearchSettings && (
|
290 |
+
<NodeSearch
|
291 |
+
pos={nodeSearchSettings.pos}
|
292 |
+
boxes={nodeSearchSettings.boxes}
|
293 |
+
onCancel={closeNodeSearch}
|
294 |
+
onAdd={addNode}
|
295 |
+
/>
|
296 |
+
)}
|
297 |
</ReactFlow>
|
298 |
</LynxKiteState.Provider>
|
299 |
</div>
|
300 |
</div>
|
|
|
301 |
);
|
302 |
}
|
lynxkite-app/web/src/workspace/nodes/LynxKiteNode.tsx
CHANGED
@@ -1,6 +1,11 @@
|
|
1 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
2 |
// @ts-ignore
|
3 |
-
import ChevronDownRight from
|
4 |
|
5 |
interface LynxKiteNodeProps {
|
6 |
id: string;
|
@@ -13,18 +18,18 @@ interface LynxKiteNodeProps {
|
|
13 |
|
14 |
function getHandles(inputs: object, outputs: object) {
|
15 |
const handles: {
|
16 |
-
position:
|
17 |
-
name: string
|
18 |
-
index: number
|
19 |
-
offsetPercentage: number
|
20 |
-
showLabel: boolean
|
21 |
-
type:
|
22 |
}[] = [];
|
23 |
for (const e of Object.values(inputs)) {
|
24 |
-
handles.push({ ...e, type:
|
25 |
}
|
26 |
for (const e of Object.values(outputs)) {
|
27 |
-
handles.push({ ...e, type:
|
28 |
}
|
29 |
const counts = { top: 0, bottom: 0, left: 0, right: 0 };
|
30 |
for (const e of handles) {
|
@@ -32,9 +37,11 @@ function getHandles(inputs: object, outputs: object) {
|
|
32 |
counts[e.position]++;
|
33 |
}
|
34 |
for (const e of handles) {
|
35 |
-
e.offsetPercentage = 100 * (e.index + 1) / (counts[e.position] + 1);
|
36 |
-
const simpleHorizontal =
|
37 |
-
|
|
|
|
|
38 |
e.showLabel = !simpleHorizontal && !simpleVertical;
|
39 |
}
|
40 |
return handles;
|
@@ -48,37 +55,57 @@ export default function LynxKiteNode(props: LynxKiteNodeProps) {
|
|
48 |
function titleClicked() {
|
49 |
reactFlow.updateNodeData(props.id, { collapsed: expanded });
|
50 |
}
|
51 |
-
const handleOffsetDirection = {
|
|
|
|
|
|
|
|
|
|
|
52 |
|
53 |
return (
|
54 |
-
<div
|
55 |
-
|
|
|
|
|
|
|
|
|
|
|
56 |
<div className="lynxkite-node" style={props.nodeStyle}>
|
57 |
<div className="title bg-primary" onClick={titleClicked}>
|
58 |
{data.title}
|
59 |
{data.error && <span className="title-icon">⚠️</span>}
|
60 |
{expanded || <span className="title-icon">⋯</span>}
|
61 |
</div>
|
62 |
-
{expanded &&
|
63 |
-
|
64 |
-
<div className="error">{data.error}</div>
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
{handles.map(handle => (
|
76 |
<Handle
|
77 |
key={handle.name}
|
78 |
-
id={handle.name}
|
79 |
-
|
80 |
-
|
81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
82 |
))}
|
83 |
</div>
|
84 |
</div>
|
|
|
1 |
+
import {
|
2 |
+
Handle,
|
3 |
+
NodeResizeControl,
|
4 |
+
type Position,
|
5 |
+
useReactFlow,
|
6 |
+
} from "@xyflow/react";
|
7 |
// @ts-ignore
|
8 |
+
import ChevronDownRight from "~icons/tabler/chevron-down-right.jsx";
|
9 |
|
10 |
interface LynxKiteNodeProps {
|
11 |
id: string;
|
|
|
18 |
|
19 |
function getHandles(inputs: object, outputs: object) {
|
20 |
const handles: {
|
21 |
+
position: "top" | "bottom" | "left" | "right";
|
22 |
+
name: string;
|
23 |
+
index: number;
|
24 |
+
offsetPercentage: number;
|
25 |
+
showLabel: boolean;
|
26 |
+
type: "source" | "target";
|
27 |
}[] = [];
|
28 |
for (const e of Object.values(inputs)) {
|
29 |
+
handles.push({ ...e, type: "target" });
|
30 |
}
|
31 |
for (const e of Object.values(outputs)) {
|
32 |
+
handles.push({ ...e, type: "source" });
|
33 |
}
|
34 |
const counts = { top: 0, bottom: 0, left: 0, right: 0 };
|
35 |
for (const e of handles) {
|
|
|
37 |
counts[e.position]++;
|
38 |
}
|
39 |
for (const e of handles) {
|
40 |
+
e.offsetPercentage = (100 * (e.index + 1)) / (counts[e.position] + 1);
|
41 |
+
const simpleHorizontal =
|
42 |
+
counts.top === 0 && counts.bottom === 0 && handles.length <= 2;
|
43 |
+
const simpleVertical =
|
44 |
+
counts.left === 0 && counts.right === 0 && handles.length <= 2;
|
45 |
e.showLabel = !simpleHorizontal && !simpleVertical;
|
46 |
}
|
47 |
return handles;
|
|
|
55 |
function titleClicked() {
|
56 |
reactFlow.updateNodeData(props.id, { collapsed: expanded });
|
57 |
}
|
58 |
+
const handleOffsetDirection = {
|
59 |
+
top: "left",
|
60 |
+
bottom: "left",
|
61 |
+
left: "top",
|
62 |
+
right: "top",
|
63 |
+
};
|
64 |
|
65 |
return (
|
66 |
+
<div
|
67 |
+
className={`node-container·${expanded ? "expanded" : "collapsed"} `}
|
68 |
+
style={{
|
69 |
+
width: props.width || 200,
|
70 |
+
height: expanded ? props.height || 200 : undefined,
|
71 |
+
}}
|
72 |
+
>
|
73 |
<div className="lynxkite-node" style={props.nodeStyle}>
|
74 |
<div className="title bg-primary" onClick={titleClicked}>
|
75 |
{data.title}
|
76 |
{data.error && <span className="title-icon">⚠️</span>}
|
77 |
{expanded || <span className="title-icon">⋯</span>}
|
78 |
</div>
|
79 |
+
{expanded && (
|
80 |
+
<>
|
81 |
+
{data.error && <div className="error">{data.error}</div>}
|
82 |
+
{props.children}
|
83 |
+
<NodeResizeControl
|
84 |
+
minWidth={100}
|
85 |
+
minHeight={50}
|
86 |
+
style={{ background: "transparent", border: "none" }}
|
87 |
+
>
|
88 |
+
<ChevronDownRight className="node-resizer" />
|
89 |
+
</NodeResizeControl>
|
90 |
+
</>
|
91 |
+
)}
|
92 |
+
{handles.map((handle) => (
|
93 |
<Handle
|
94 |
key={handle.name}
|
95 |
+
id={handle.name}
|
96 |
+
type={handle.type}
|
97 |
+
position={handle.position as Position}
|
98 |
+
style={{
|
99 |
+
[handleOffsetDirection[handle.position]]:
|
100 |
+
`${handle.offsetPercentage}% `,
|
101 |
+
}}
|
102 |
+
>
|
103 |
+
{handle.showLabel && (
|
104 |
+
<span className="handle-name">
|
105 |
+
{handle.name.replace(/_/g, " ")}
|
106 |
+
</span>
|
107 |
+
)}
|
108 |
+
</Handle>
|
109 |
))}
|
110 |
</div>
|
111 |
</div>
|
lynxkite-app/web/src/workspace/nodes/NodeParameter.tsx
CHANGED
@@ -1,7 +1,9 @@
|
|
1 |
const BOOLEAN = "<class 'bool'>";
|
2 |
|
3 |
function ParamName({ name }: { name: string }) {
|
4 |
-
return
|
|
|
|
|
5 |
}
|
6 |
|
7 |
interface NodeParameterProps {
|
@@ -11,50 +13,69 @@ interface NodeParameterProps {
|
|
11 |
onChange: (value: any, options?: { delay: number }) => void;
|
12 |
}
|
13 |
|
14 |
-
export default function NodeParameter({
|
|
|
|
|
|
|
|
|
|
|
15 |
return (
|
|
|
16 |
<label className="param">
|
17 |
-
{meta?.type?.format ===
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
46 |
/>
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
<ParamName name={name} />
|
51 |
-
<input className="input input-bordered w-full max-w-xs"
|
52 |
-
value={value || ""}
|
53 |
-
onChange={(evt) => onChange(evt.currentTarget.value, { delay: 2 })}
|
54 |
-
onBlur={(evt) => onChange(evt.currentTarget.value, { delay: 0 })}
|
55 |
-
/>
|
56 |
-
</>
|
57 |
-
}
|
58 |
-
</label >
|
59 |
);
|
60 |
}
|
|
|
1 |
const BOOLEAN = "<class 'bool'>";
|
2 |
|
3 |
function ParamName({ name }: { name: string }) {
|
4 |
+
return (
|
5 |
+
<span className="param-name bg-base-200">{name.replace(/_/g, " ")}</span>
|
6 |
+
);
|
7 |
}
|
8 |
|
9 |
interface NodeParameterProps {
|
|
|
13 |
onChange: (value: any, options?: { delay: number }) => void;
|
14 |
}
|
15 |
|
16 |
+
export default function NodeParameter({
|
17 |
+
name,
|
18 |
+
value,
|
19 |
+
meta,
|
20 |
+
onChange,
|
21 |
+
}: NodeParameterProps) {
|
22 |
return (
|
23 |
+
// biome-ignore lint/a11y/noLabelWithoutControl: Most of the time there is a control.
|
24 |
<label className="param">
|
25 |
+
{meta?.type?.format === "collapsed" ? (
|
26 |
+
<>
|
27 |
+
<ParamName name={name} />
|
28 |
+
<button className="collapsed-param">⋯</button>
|
29 |
+
</>
|
30 |
+
) : meta?.type?.format === "textarea" ? (
|
31 |
+
<>
|
32 |
+
<ParamName name={name} />
|
33 |
+
<textarea
|
34 |
+
className="textarea textarea-bordered w-full max-w-xs"
|
35 |
+
rows={6}
|
36 |
+
value={value}
|
37 |
+
onChange={(evt) => onChange(evt.currentTarget.value, { delay: 2 })}
|
38 |
+
onBlur={(evt) => onChange(evt.currentTarget.value, { delay: 0 })}
|
39 |
+
/>
|
40 |
+
</>
|
41 |
+
) : meta?.type?.enum ? (
|
42 |
+
<>
|
43 |
+
<ParamName name={name} />
|
44 |
+
<select
|
45 |
+
className="select select-bordered w-full max-w-xs"
|
46 |
+
value={value || meta.type.enum[0]}
|
47 |
+
onChange={(evt) => onChange(evt.currentTarget.value)}
|
48 |
+
>
|
49 |
+
{meta.type.enum.map((option: string) => (
|
50 |
+
<option key={option} value={option}>
|
51 |
+
{option}
|
52 |
+
</option>
|
53 |
+
))}
|
54 |
+
</select>
|
55 |
+
</>
|
56 |
+
) : meta?.type?.type === BOOLEAN ? (
|
57 |
+
<div className="form-control">
|
58 |
+
<label className="label cursor-pointer">
|
59 |
+
<input
|
60 |
+
className="checkbox"
|
61 |
+
type="checkbox"
|
62 |
+
checked={value}
|
63 |
+
onChange={(evt) => onChange(evt.currentTarget.checked)}
|
64 |
+
/>
|
65 |
+
{name.replace(/_/g, " ")}
|
66 |
+
</label>
|
67 |
+
</div>
|
68 |
+
) : (
|
69 |
+
<>
|
70 |
+
<ParamName name={name} />
|
71 |
+
<input
|
72 |
+
className="input input-bordered w-full max-w-xs"
|
73 |
+
value={value || ""}
|
74 |
+
onChange={(evt) => onChange(evt.currentTarget.value, { delay: 2 })}
|
75 |
+
onBlur={(evt) => onChange(evt.currentTarget.value, { delay: 0 })}
|
76 |
/>
|
77 |
+
</>
|
78 |
+
)}
|
79 |
+
</label>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
80 |
);
|
81 |
}
|
lynxkite-app/web/src/workspace/nodes/NodeWithImage.tsx
CHANGED
@@ -1,9 +1,11 @@
|
|
1 |
-
import NodeWithParams from
|
2 |
|
3 |
const NodeWithImage = (props: any) => {
|
4 |
return (
|
5 |
<NodeWithParams {...props}>
|
6 |
-
{props.data.display &&
|
|
|
|
|
7 |
</NodeWithParams>
|
8 |
);
|
9 |
};
|
|
|
1 |
+
import NodeWithParams from "./NodeWithParams";
|
2 |
|
3 |
const NodeWithImage = (props: any) => {
|
4 |
return (
|
5 |
<NodeWithParams {...props}>
|
6 |
+
{props.data.display && (
|
7 |
+
<img src={props.data.display} alt="Node Display" />
|
8 |
+
)}
|
9 |
</NodeWithParams>
|
10 |
);
|
11 |
};
|
lynxkite-app/web/src/workspace/nodes/NodeWithParams.tsx
CHANGED
@@ -1,6 +1,6 @@
|
|
1 |
-
import
|
2 |
-
import
|
3 |
-
import NodeParameter from
|
4 |
|
5 |
export type UpdateOptions = { delay?: number };
|
6 |
|
@@ -8,23 +8,28 @@ function NodeWithParams(props: any) {
|
|
8 |
const reactFlow = useReactFlow();
|
9 |
const metaParams = props.data.meta?.params;
|
10 |
function setParam(name: string, newValue: any, opts: UpdateOptions) {
|
11 |
-
reactFlow.updateNodeData(props.id, {
|
|
|
|
|
|
|
12 |
}
|
13 |
const params = props.data?.params ? Object.entries(props.data.params) : [];
|
14 |
|
15 |
return (
|
16 |
<LynxKiteNode {...props}>
|
17 |
-
{params.map(([name, value]) =>
|
18 |
<NodeParameter
|
19 |
name={name}
|
20 |
key={name}
|
21 |
value={value}
|
22 |
meta={metaParams?.[name]}
|
23 |
-
onChange={(value: any, opts?: UpdateOptions) =>
|
|
|
|
|
24 |
/>
|
25 |
-
)}
|
26 |
{props.children}
|
27 |
-
</LynxKiteNode
|
28 |
);
|
29 |
}
|
30 |
|
|
|
1 |
+
import { useReactFlow } from "@xyflow/react";
|
2 |
+
import LynxKiteNode from "./LynxKiteNode";
|
3 |
+
import NodeParameter from "./NodeParameter";
|
4 |
|
5 |
export type UpdateOptions = { delay?: number };
|
6 |
|
|
|
8 |
const reactFlow = useReactFlow();
|
9 |
const metaParams = props.data.meta?.params;
|
10 |
function setParam(name: string, newValue: any, opts: UpdateOptions) {
|
11 |
+
reactFlow.updateNodeData(props.id, {
|
12 |
+
params: { ...props.data.params, [name]: newValue },
|
13 |
+
__execution_delay: opts.delay || 0,
|
14 |
+
});
|
15 |
}
|
16 |
const params = props.data?.params ? Object.entries(props.data.params) : [];
|
17 |
|
18 |
return (
|
19 |
<LynxKiteNode {...props}>
|
20 |
+
{params.map(([name, value]) => (
|
21 |
<NodeParameter
|
22 |
name={name}
|
23 |
key={name}
|
24 |
value={value}
|
25 |
meta={metaParams?.[name]}
|
26 |
+
onChange={(value: any, opts?: UpdateOptions) =>
|
27 |
+
setParam(name, value, opts || {})
|
28 |
+
}
|
29 |
/>
|
30 |
+
))}
|
31 |
{props.children}
|
32 |
+
</LynxKiteNode>
|
33 |
);
|
34 |
}
|
35 |
|
lynxkite-app/web/src/workspace/nodes/NodeWithTableView.tsx
CHANGED
@@ -1,15 +1,15 @@
|
|
1 |
-
import { useState } from
|
2 |
-
import
|
3 |
-
import
|
4 |
-
import
|
5 |
-
import
|
6 |
|
7 |
function toMD(v: any): string {
|
8 |
-
if (typeof v ===
|
9 |
return v;
|
10 |
}
|
11 |
if (Array.isArray(v)) {
|
12 |
-
return v.map(toMD).join(
|
13 |
}
|
14 |
return JSON.stringify(v);
|
15 |
}
|
@@ -17,33 +17,60 @@ function toMD(v: any): string {
|
|
17 |
export default function NodeWithTableView(props: any) {
|
18 |
const [open, setOpen] = useState({} as { [name: string]: boolean });
|
19 |
const display = props.data.display?.value;
|
20 |
-
const single =
|
|
|
21 |
return (
|
22 |
<LynxKiteNode {...props}>
|
23 |
{display && [
|
24 |
-
Object.entries(display.dataframes || {}).map(
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
48 |
);
|
49 |
}
|
|
|
1 |
+
import { useState } from "react";
|
2 |
+
import React from "react";
|
3 |
+
import Markdown from "react-markdown";
|
4 |
+
import LynxKiteNode from "./LynxKiteNode";
|
5 |
+
import Table from "./Table";
|
6 |
|
7 |
function toMD(v: any): string {
|
8 |
+
if (typeof v === "string") {
|
9 |
return v;
|
10 |
}
|
11 |
if (Array.isArray(v)) {
|
12 |
+
return v.map(toMD).join("\n\n");
|
13 |
}
|
14 |
return JSON.stringify(v);
|
15 |
}
|
|
|
17 |
export default function NodeWithTableView(props: any) {
|
18 |
const [open, setOpen] = useState({} as { [name: string]: boolean });
|
19 |
const display = props.data.display?.value;
|
20 |
+
const single =
|
21 |
+
display?.dataframes && Object.keys(display?.dataframes).length === 1;
|
22 |
return (
|
23 |
<LynxKiteNode {...props}>
|
24 |
{display && [
|
25 |
+
Object.entries(display.dataframes || {}).map(
|
26 |
+
([name, df]: [string, any]) => (
|
27 |
+
<React.Fragment key={name}>
|
28 |
+
{!single && (
|
29 |
+
<div
|
30 |
+
key={`${name}-header`}
|
31 |
+
className="df-head"
|
32 |
+
onClick={() => setOpen({ ...open, [name]: !open[name] })}
|
33 |
+
>
|
34 |
+
{name}
|
35 |
+
</div>
|
36 |
+
)}
|
37 |
+
{(single || open[name]) &&
|
38 |
+
(df.data.length > 1 ? (
|
39 |
+
<Table
|
40 |
+
key={`${name}-table`}
|
41 |
+
columns={df.columns}
|
42 |
+
data={df.data}
|
43 |
+
/>
|
44 |
+
) : df.data.length ? (
|
45 |
+
<dl key={`${name}-dl`}>
|
46 |
+
{df.columns.map((c: string, i: number) => (
|
47 |
+
<React.Fragment key={`${name}-${c}`}>
|
48 |
+
<dt>{c}</dt>
|
49 |
+
<dd>
|
50 |
+
<Markdown>{toMD(df.data[0][i])}</Markdown>
|
51 |
+
</dd>
|
52 |
+
</React.Fragment>
|
53 |
+
))}
|
54 |
+
</dl>
|
55 |
+
) : (
|
56 |
+
JSON.stringify(df.data)
|
57 |
+
))}
|
58 |
+
</React.Fragment>
|
59 |
+
),
|
60 |
+
),
|
61 |
+
Object.entries(display.others || {}).map(([name, o]) => (
|
62 |
+
<>
|
63 |
+
<div
|
64 |
+
key={`${name}-header`}
|
65 |
+
className="df-head"
|
66 |
+
onClick={() => setOpen({ ...open, [name]: !open[name] })}
|
67 |
+
>
|
68 |
+
{name}
|
69 |
+
</div>
|
70 |
+
{open[name] && <pre>{(o as any).toString()}</pre>}
|
71 |
+
</>
|
72 |
+
)),
|
73 |
+
]}
|
74 |
+
</LynxKiteNode>
|
75 |
);
|
76 |
}
|
lynxkite-app/web/src/workspace/nodes/NodeWithVisualization.tsx
CHANGED
@@ -1,6 +1,6 @@
|
|
1 |
-
import React, { useEffect } from
|
2 |
-
import NodeWithParams from
|
3 |
-
const echarts = await import(
|
4 |
|
5 |
const NodeWithVisualization = (props: any) => {
|
6 |
const chartsRef = React.useRef<HTMLDivElement>(null);
|
@@ -8,12 +8,16 @@ const NodeWithVisualization = (props: any) => {
|
|
8 |
useEffect(() => {
|
9 |
const opts = props.data?.display?.value;
|
10 |
if (!opts || !chartsRef.current) return;
|
11 |
-
chartsInstanceRef.current = echarts.init(chartsRef.current, null, {
|
|
|
|
|
|
|
|
|
12 |
chartsInstanceRef.current.setOption(opts);
|
13 |
const onResize = () => chartsInstanceRef.current?.resize();
|
14 |
-
window.addEventListener(
|
15 |
return () => {
|
16 |
-
window.removeEventListener(
|
17 |
chartsInstanceRef.current?.dispose();
|
18 |
};
|
19 |
}, [props.data?.display?.value]);
|
|
|
1 |
+
import React, { useEffect } from "react";
|
2 |
+
import NodeWithParams from "./NodeWithParams";
|
3 |
+
const echarts = await import("echarts");
|
4 |
|
5 |
const NodeWithVisualization = (props: any) => {
|
6 |
const chartsRef = React.useRef<HTMLDivElement>(null);
|
|
|
8 |
useEffect(() => {
|
9 |
const opts = props.data?.display?.value;
|
10 |
if (!opts || !chartsRef.current) return;
|
11 |
+
chartsInstanceRef.current = echarts.init(chartsRef.current, null, {
|
12 |
+
renderer: "canvas",
|
13 |
+
width: 250,
|
14 |
+
height: 250,
|
15 |
+
});
|
16 |
chartsInstanceRef.current.setOption(opts);
|
17 |
const onResize = () => chartsInstanceRef.current?.resize();
|
18 |
+
window.addEventListener("resize", onResize);
|
19 |
return () => {
|
20 |
+
window.removeEventListener("resize", onResize);
|
21 |
chartsInstanceRef.current?.dispose();
|
22 |
};
|
23 |
}, [props.data?.display?.value]);
|
lynxkite-app/web/src/workspace/nodes/Table.tsx
CHANGED
@@ -1,17 +1,22 @@
|
|
1 |
export default function Table(props: any) {
|
2 |
-
return (
|
3 |
-
<
|
4 |
-
<
|
5 |
-
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
|
|
|
|
|
|
|
|
|
|
|
17 |
}
|
|
|
1 |
export default function Table(props: any) {
|
2 |
+
return (
|
3 |
+
<table>
|
4 |
+
<thead>
|
5 |
+
<tr>
|
6 |
+
{props.columns.map((column: string) => (
|
7 |
+
<th key={column}>{column}</th>
|
8 |
+
))}
|
9 |
+
</tr>
|
10 |
+
</thead>
|
11 |
+
<tbody>
|
12 |
+
{props.data.map((row: { [column: string]: any }, i: number) => (
|
13 |
+
<tr key={`row-${i}`}>
|
14 |
+
{props.columns.map((_column: string, j: number) => (
|
15 |
+
<td key={`cell ${i}, ${j}`}>{JSON.stringify(row[j])}</td>
|
16 |
+
))}
|
17 |
+
</tr>
|
18 |
+
))}
|
19 |
+
</tbody>
|
20 |
+
</table>
|
21 |
+
);
|
22 |
}
|
lynxkite-app/web/tailwind.config.js
CHANGED
@@ -1,21 +1,21 @@
|
|
1 |
/** @type {import('tailwindcss').Config} */
|
2 |
export default {
|
3 |
-
darkMode:
|
4 |
-
content: [
|
5 |
theme: {
|
6 |
extend: {},
|
7 |
},
|
8 |
-
plugins: [require(
|
9 |
daisyui: {
|
10 |
logs: false,
|
11 |
themes: [
|
12 |
{
|
13 |
lynxkite: {
|
14 |
-
primary:
|
15 |
-
secondary:
|
16 |
-
accent:
|
17 |
-
neutral:
|
18 |
-
|
19 |
},
|
20 |
},
|
21 |
],
|
|
|
1 |
/** @type {import('tailwindcss').Config} */
|
2 |
export default {
|
3 |
+
darkMode: "selector",
|
4 |
+
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
5 |
theme: {
|
6 |
extend: {},
|
7 |
},
|
8 |
+
plugins: [require("daisyui")],
|
9 |
daisyui: {
|
10 |
logs: false,
|
11 |
themes: [
|
12 |
{
|
13 |
lynxkite: {
|
14 |
+
primary: "oklch(75% 0.2 55)",
|
15 |
+
secondary: "oklch(75% 0.13 230)",
|
16 |
+
accent: "oklch(55% 0.25 320)",
|
17 |
+
neutral: "oklch(35% 0.1 240)",
|
18 |
+
"base-100": "#ffffff",
|
19 |
},
|
20 |
},
|
21 |
],
|
lynxkite-app/web/tests/basic.spec.ts
CHANGED
@@ -1,25 +1,25 @@
|
|
1 |
// Tests some basic operations like box creation, deletion, and dragging.
|
2 |
-
import {
|
3 |
-
import { Splash, Workspace } from
|
4 |
-
|
5 |
|
6 |
let workspace: Workspace;
|
7 |
|
8 |
-
|
9 |
test.beforeEach(async ({ browser }) => {
|
10 |
-
workspace = await Workspace.empty(await browser.newPage(),
|
11 |
});
|
12 |
|
13 |
test.afterEach(async () => {
|
14 |
await workspace.close();
|
15 |
const splash = await new Splash(workspace.page);
|
16 |
-
splash.page.on(
|
17 |
-
|
|
|
|
|
18 |
});
|
19 |
|
20 |
-
test(
|
21 |
const envs = await workspace.getEnvs();
|
22 |
-
for(const env of envs) {
|
23 |
await workspace.setEnv(env);
|
24 |
const catalog = await workspace.getCatalog();
|
25 |
expect(catalog).not.toHaveLength(0);
|
@@ -31,23 +31,18 @@ test('Box creation & deletion per env', async () => {
|
|
31 |
}
|
32 |
});
|
33 |
|
34 |
-
|
35 |
-
|
36 |
-
await workspace.
|
37 |
-
await workspace.
|
38 |
-
await expect(workspace.getBox('Compute PageRank 1')).not.toBeVisible();
|
39 |
});
|
40 |
|
41 |
-
|
42 |
-
|
43 |
-
await workspace.
|
44 |
-
|
45 |
-
await workspace.
|
46 |
-
const newPos = await workspace.getBox('Import Parquet 1').boundingBox();
|
47 |
// Exact position is not guaranteed, but it should have moved
|
48 |
expect(newPos.x).toBeGreaterThan(originalPos.x);
|
49 |
expect(newPos.y).toBeGreaterThan(originalPos.y);
|
50 |
});
|
51 |
-
|
52 |
-
|
53 |
-
|
|
|
1 |
// Tests some basic operations like box creation, deletion, and dragging.
|
2 |
+
import { expect, test } from "@playwright/test";
|
3 |
+
import { Splash, Workspace } from "./lynxkite";
|
|
|
4 |
|
5 |
let workspace: Workspace;
|
6 |
|
|
|
7 |
test.beforeEach(async ({ browser }) => {
|
8 |
+
workspace = await Workspace.empty(await browser.newPage(), "basic_spec_test");
|
9 |
});
|
10 |
|
11 |
test.afterEach(async () => {
|
12 |
await workspace.close();
|
13 |
const splash = await new Splash(workspace.page);
|
14 |
+
splash.page.on("dialog", async (dialog) => {
|
15 |
+
await dialog.accept();
|
16 |
+
});
|
17 |
+
await splash.deleteEntry("basic_spec_test");
|
18 |
});
|
19 |
|
20 |
+
test("Box creation & deletion per env", async () => {
|
21 |
const envs = await workspace.getEnvs();
|
22 |
+
for (const env of envs) {
|
23 |
await workspace.setEnv(env);
|
24 |
const catalog = await workspace.getCatalog();
|
25 |
expect(catalog).not.toHaveLength(0);
|
|
|
31 |
}
|
32 |
});
|
33 |
|
34 |
+
test("Delete multi-handle boxes", async () => {
|
35 |
+
await workspace.addBox("Compute PageRank");
|
36 |
+
await workspace.deleteBoxes(["Compute PageRank 1"]);
|
37 |
+
await expect(workspace.getBox("Compute PageRank 1")).not.toBeVisible();
|
|
|
38 |
});
|
39 |
|
40 |
+
test("Drag box", async () => {
|
41 |
+
await workspace.addBox("Import Parquet");
|
42 |
+
const originalPos = await workspace.getBox("Import Parquet 1").boundingBox();
|
43 |
+
await workspace.moveBox("Import Parquet 1", { offsetX: 100, offsetY: 100 });
|
44 |
+
const newPos = await workspace.getBox("Import Parquet 1").boundingBox();
|
|
|
45 |
// Exact position is not guaranteed, but it should have moved
|
46 |
expect(newPos.x).toBeGreaterThan(originalPos.x);
|
47 |
expect(newPos.y).toBeGreaterThan(originalPos.y);
|
48 |
});
|
|
|
|
|
|
lynxkite-app/web/tests/directory.spec.ts
CHANGED
@@ -1,39 +1,38 @@
|
|
1 |
// Tests some basic operations like box creation, deletion, and dragging.
|
2 |
-
import {
|
3 |
-
import {
|
4 |
-
|
5 |
|
6 |
test.describe("Directory operations", () => {
|
7 |
-
|
8 |
let splash: Splash;
|
9 |
|
10 |
test.beforeAll(async ({ browser }) => {
|
11 |
-
const page = await browser.newPage()
|
12 |
// To make deletion confirmation dialog to be automatically accepted
|
13 |
-
page.on(
|
|
|
|
|
14 |
splash = await Splash.open(page);
|
15 |
});
|
16 |
|
17 |
-
|
18 |
-
test('Create workspace with default name', async () => {
|
19 |
const workspace = await Workspace.empty(splash.page);
|
20 |
// Not checking for exact match, since there may be pre-existing "Untitled" workspaces
|
21 |
-
expect(workspace.name).toContain(
|
22 |
await workspace.close();
|
23 |
});
|
24 |
|
25 |
-
test(
|
26 |
const workspaceName = `TestWorkspace-${Date.now()}`;
|
27 |
const workspace = await Workspace.empty(splash.page, workspaceName);
|
28 |
await workspace.expectCurrentWorkspaceIs(workspaceName);
|
29 |
// Add a box so the workspace is saved
|
30 |
-
await workspace.addBox(
|
31 |
await workspace.close();
|
32 |
await splash.deleteEntry(workspaceName);
|
33 |
await expect(splash.getEntry(workspaceName)).not.toBeVisible();
|
34 |
});
|
35 |
|
36 |
-
test(
|
37 |
const folderName = `TestFolder-${Date.now()}`;
|
38 |
await splash.createFolder(folderName);
|
39 |
await expect(splash.currentFolder()).toHaveText(folderName);
|
@@ -42,56 +41,57 @@ test.describe("Directory operations", () => {
|
|
42 |
await expect(splash.getEntry(folderName)).not.toBeVisible();
|
43 |
});
|
44 |
|
45 |
-
test(
|
46 |
await splash.createFolder();
|
47 |
-
await expect(splash.currentFolder()).toContainText(
|
48 |
});
|
49 |
});
|
50 |
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
85 |
});
|
86 |
-
|
87 |
-
test('Create nested workspace', async () => {
|
88 |
-
const workspace = splash.createWorkspace('TestWorkspace');
|
89 |
-
await workspace.expectCurrentWorkspaceIs('TestWorkspace');
|
90 |
-
await workspace.close();
|
91 |
-
});
|
92 |
-
|
93 |
-
test('Delete nested workspace', async () => {
|
94 |
-
await splash.deleteEntry('TestWorkspace');
|
95 |
-
await expect(splash.getEntry('TestWorkspace')).not.toBeVisible();
|
96 |
-
});
|
97 |
-
});
|
|
|
1 |
// Tests some basic operations like box creation, deletion, and dragging.
|
2 |
+
import { expect, test } from "@playwright/test";
|
3 |
+
import { Splash, Workspace } from "./lynxkite";
|
|
|
4 |
|
5 |
test.describe("Directory operations", () => {
|
|
|
6 |
let splash: Splash;
|
7 |
|
8 |
test.beforeAll(async ({ browser }) => {
|
9 |
+
const page = await browser.newPage();
|
10 |
// To make deletion confirmation dialog to be automatically accepted
|
11 |
+
page.on("dialog", async (dialog) => {
|
12 |
+
await dialog.accept();
|
13 |
+
});
|
14 |
splash = await Splash.open(page);
|
15 |
});
|
16 |
|
17 |
+
test("Create workspace with default name", async () => {
|
|
|
18 |
const workspace = await Workspace.empty(splash.page);
|
19 |
// Not checking for exact match, since there may be pre-existing "Untitled" workspaces
|
20 |
+
expect(workspace.name).toContain("Untitled");
|
21 |
await workspace.close();
|
22 |
});
|
23 |
|
24 |
+
test("Create & delete workspace", async () => {
|
25 |
const workspaceName = `TestWorkspace-${Date.now()}`;
|
26 |
const workspace = await Workspace.empty(splash.page, workspaceName);
|
27 |
await workspace.expectCurrentWorkspaceIs(workspaceName);
|
28 |
// Add a box so the workspace is saved
|
29 |
+
await workspace.addBox("Import Parquet");
|
30 |
await workspace.close();
|
31 |
await splash.deleteEntry(workspaceName);
|
32 |
await expect(splash.getEntry(workspaceName)).not.toBeVisible();
|
33 |
});
|
34 |
|
35 |
+
test("Create & delete folder", async () => {
|
36 |
const folderName = `TestFolder-${Date.now()}`;
|
37 |
await splash.createFolder(folderName);
|
38 |
await expect(splash.currentFolder()).toHaveText(folderName);
|
|
|
41 |
await expect(splash.getEntry(folderName)).not.toBeVisible();
|
42 |
});
|
43 |
|
44 |
+
test("Create folder with default name", async () => {
|
45 |
await splash.createFolder();
|
46 |
+
await expect(splash.currentFolder()).toContainText("Untitled");
|
47 |
});
|
48 |
});
|
49 |
|
50 |
+
test.describe
|
51 |
+
.serial("Nested folders & workspaces operations", () => {
|
52 |
+
let splash: Splash;
|
53 |
+
|
54 |
+
test.beforeEach(() => {
|
55 |
+
// Nested navigation doesn't work yet
|
56 |
+
test.skip();
|
57 |
+
});
|
58 |
+
|
59 |
+
test.beforeAll(async ({ browser }) => {
|
60 |
+
const page = await browser.newPage();
|
61 |
+
// To make deletion confirmation dialog to be automatically accepted
|
62 |
+
page.on("dialog", async (dialog) => {
|
63 |
+
await dialog.accept();
|
64 |
+
});
|
65 |
+
splash = await Splash.open(page);
|
66 |
+
await splash.createFolder("TestFolder");
|
67 |
+
});
|
68 |
+
|
69 |
+
test.afterAll(async () => {
|
70 |
+
//cleanup
|
71 |
+
test.skip();
|
72 |
+
await splash.goHome();
|
73 |
+
await splash.deleteEntry("TestFolder");
|
74 |
+
});
|
75 |
+
|
76 |
+
test("Create nested folder", async () => {
|
77 |
+
await splash.createFolder("TestFolder2");
|
78 |
+
await expect(splash.currentFolder()).toHaveText("TestFolder2");
|
79 |
+
await splash.toParent();
|
80 |
+
});
|
81 |
+
|
82 |
+
test("Delete nested folder", async () => {
|
83 |
+
await splash.deleteEntry("TestFolder2");
|
84 |
+
await expect(splash.getEntry("TestFolder2")).not.toBeVisible();
|
85 |
+
});
|
86 |
+
|
87 |
+
test("Create nested workspace", async () => {
|
88 |
+
const workspace = splash.createWorkspace("TestWorkspace");
|
89 |
+
await workspace.expectCurrentWorkspaceIs("TestWorkspace");
|
90 |
+
await workspace.close();
|
91 |
+
});
|
92 |
+
|
93 |
+
test("Delete nested workspace", async () => {
|
94 |
+
await splash.deleteEntry("TestWorkspace");
|
95 |
+
await expect(splash.getEntry("TestWorkspace")).not.toBeVisible();
|
96 |
+
});
|
97 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
lynxkite-app/web/tests/errors.spec.ts
CHANGED
@@ -1,43 +1,43 @@
|
|
1 |
// Tests error reporting.
|
2 |
-
import {
|
3 |
-
import { Splash, Workspace } from
|
4 |
-
|
5 |
|
6 |
let workspace: Workspace;
|
7 |
|
8 |
-
|
9 |
test.beforeEach(async ({ browser }) => {
|
10 |
-
workspace = await Workspace.empty(await browser.newPage(),
|
11 |
});
|
12 |
|
13 |
-
test.afterEach(async (
|
14 |
await workspace.close();
|
15 |
const splash = await new Splash(workspace.page);
|
16 |
-
splash.page.on(
|
17 |
-
|
|
|
|
|
18 |
});
|
19 |
|
20 |
-
|
21 |
-
test('missing parameter', async () => {
|
22 |
// Test the correct error message is displayed when a required parameter is missing,
|
23 |
// and that the error message is removed when the parameter is filled.
|
24 |
-
await workspace.addBox(
|
25 |
-
const graphBox = workspace.getBox(
|
26 |
-
await graphBox.locator(
|
27 |
-
expect(await graphBox.locator(
|
28 |
-
|
29 |
-
|
|
|
|
|
30 |
});
|
31 |
|
32 |
-
|
33 |
-
test('unknown operation', async () => {
|
34 |
// Test that the correct error is displayed when the operation does not belong to
|
35 |
// the current environment.
|
36 |
-
await workspace.addBox(
|
37 |
-
await workspace.setEnv(
|
38 |
-
const csvBox =
|
39 |
-
const errorText = await csvBox.locator(
|
40 |
expect(errorText).toBe('Operation "Create scale-free graph" not found.');
|
41 |
-
await workspace.setEnv(
|
42 |
-
await expect(csvBox.locator(
|
43 |
});
|
|
|
1 |
// Tests error reporting.
|
2 |
+
import { expect, test } from "@playwright/test";
|
3 |
+
import { Splash, Workspace } from "./lynxkite";
|
|
|
4 |
|
5 |
let workspace: Workspace;
|
6 |
|
|
|
7 |
test.beforeEach(async ({ browser }) => {
|
8 |
+
workspace = await Workspace.empty(await browser.newPage(), "error_spec_test");
|
9 |
});
|
10 |
|
11 |
+
test.afterEach(async () => {
|
12 |
await workspace.close();
|
13 |
const splash = await new Splash(workspace.page);
|
14 |
+
splash.page.on("dialog", async (dialog) => {
|
15 |
+
await dialog.accept();
|
16 |
+
});
|
17 |
+
await splash.deleteEntry("error_spec_test");
|
18 |
});
|
19 |
|
20 |
+
test("missing parameter", async () => {
|
|
|
21 |
// Test the correct error message is displayed when a required parameter is missing,
|
22 |
// and that the error message is removed when the parameter is filled.
|
23 |
+
await workspace.addBox("Create scale-free graph");
|
24 |
+
const graphBox = workspace.getBox("Create scale-free graph 1");
|
25 |
+
await graphBox.locator("input").fill("");
|
26 |
+
expect(await graphBox.locator(".error").innerText()).toBe(
|
27 |
+
"invalid literal for int() with base 10: ''",
|
28 |
+
);
|
29 |
+
await graphBox.locator("input").fill("10");
|
30 |
+
await expect(graphBox.locator(".error")).not.toBeVisible();
|
31 |
});
|
32 |
|
33 |
+
test("unknown operation", async () => {
|
|
|
34 |
// Test that the correct error is displayed when the operation does not belong to
|
35 |
// the current environment.
|
36 |
+
await workspace.addBox("Create scale-free graph");
|
37 |
+
await workspace.setEnv("LynxScribe");
|
38 |
+
const csvBox = workspace.getBox("Create scale-free graph 1");
|
39 |
+
const errorText = await csvBox.locator(".error").innerText();
|
40 |
expect(errorText).toBe('Operation "Create scale-free graph" not found.');
|
41 |
+
await workspace.setEnv("LynxKite Graph Analytics");
|
42 |
+
await expect(csvBox.locator(".error")).not.toBeVisible();
|
43 |
});
|
lynxkite-app/web/tests/examples.spec.ts
CHANGED
@@ -1,57 +1,49 @@
|
|
1 |
// Test the execution of the example workspaces
|
2 |
-
import {
|
3 |
-
import { Workspace } from
|
4 |
|
5 |
-
|
6 |
-
|
7 |
-
|
8 |
-
expect(await ws.isErrorFree(process.env.CI? 2000: 1000)).toBeTruthy();
|
9 |
});
|
10 |
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
expect(await ws.isErrorFree()).toBeTruthy();
|
15 |
});
|
16 |
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
expect(await ws.isErrorFree()).toBeTruthy();
|
22 |
});
|
23 |
|
24 |
-
test.fail(
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
});
|
29 |
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
expect(await ws.isErrorFree(process.env.CI? 2000: 500)).toBeTruthy();
|
35 |
});
|
36 |
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
expect(await ws.isErrorFree()).toBeTruthy();
|
42 |
});
|
43 |
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
expect(await ws.isErrorFree(process.env.CI? 10000: 500)).toBeTruthy();
|
50 |
});
|
51 |
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
expect(await ws.isErrorFree()).toBeTruthy();
|
56 |
});
|
57 |
-
|
|
|
1 |
// Test the execution of the example workspaces
|
2 |
+
import { expect, test } from "@playwright/test";
|
3 |
+
import { Workspace } from "./lynxkite";
|
4 |
|
5 |
+
test("LynxKite Graph Analytics example", async ({ page }) => {
|
6 |
+
const ws = await Workspace.open(page, "NetworkX demo");
|
7 |
+
expect(await ws.isErrorFree(process.env.CI ? 2000 : 1000)).toBeTruthy();
|
|
|
8 |
});
|
9 |
|
10 |
+
test("Pytorch example", async ({ page }) => {
|
11 |
+
const ws = await Workspace.open(page, "PyTorch demo");
|
12 |
+
expect(await ws.isErrorFree()).toBeTruthy();
|
|
|
13 |
});
|
14 |
|
15 |
+
test.fail("AIMO example", async ({ page }) => {
|
16 |
+
// Fails because of missing OPENAI_API_KEY
|
17 |
+
const ws = await Workspace.open(page, "AIMO");
|
18 |
+
expect(await ws.isErrorFree()).toBeTruthy();
|
|
|
19 |
});
|
20 |
|
21 |
+
test.fail("LynxScribe example", async ({ page }) => {
|
22 |
+
// Fails because of missing OPENAI_API_KEY
|
23 |
+
const ws = await Workspace.open(page, "LynxScribe demo");
|
24 |
+
expect(await ws.isErrorFree()).toBeTruthy();
|
25 |
});
|
26 |
|
27 |
+
test.fail("Graph RAG", async ({ page }) => {
|
28 |
+
// Fails due to some issue with ChromaDB
|
29 |
+
const ws = await Workspace.open(page, "Graph RAG");
|
30 |
+
expect(await ws.isErrorFree(process.env.CI ? 2000 : 500)).toBeTruthy();
|
|
|
31 |
});
|
32 |
|
33 |
+
test.fail("RAG chatbot app", async ({ page }) => {
|
34 |
+
// Fail due to all operation being unknown
|
35 |
+
const ws = await Workspace.open(page, "RAG chatbot app");
|
36 |
+
expect(await ws.isErrorFree()).toBeTruthy();
|
|
|
37 |
});
|
38 |
|
39 |
+
test.fail("night demo", async ({ page }) => {
|
40 |
+
// airlines.graphml file not found
|
41 |
+
// requires cugraph
|
42 |
+
const ws = await Workspace.open(page, "night demo");
|
43 |
+
expect(await ws.isErrorFree(process.env.CI ? 10000 : 500)).toBeTruthy();
|
|
|
44 |
});
|
45 |
|
46 |
+
test("Pillow example", async ({ page }) => {
|
47 |
+
const ws = await Workspace.open(page, "Image processing");
|
48 |
+
expect(await ws.isErrorFree()).toBeTruthy();
|
|
|
49 |
});
|
|
lynxkite-app/web/tests/lynxkite.ts
CHANGED
@@ -1,15 +1,12 @@
|
|
1 |
// Shared testing utilities.
|
2 |
-
import {
|
3 |
-
|
4 |
|
5 |
// Mirrors the "id" filter.
|
6 |
export function toId(x) {
|
7 |
-
return x.toLowerCase().replace(/[ !?,./]/g,
|
8 |
}
|
9 |
|
10 |
-
|
11 |
-
export const ROOT = 'automated-tests';
|
12 |
-
|
13 |
|
14 |
export class Workspace {
|
15 |
readonly page: Page;
|
@@ -31,12 +28,12 @@ export class Workspace {
|
|
31 |
const ws = await splash.openWorkspace(workspaceName);
|
32 |
await ws.waitForNodesToLoad();
|
33 |
await ws.expectCurrentWorkspaceIs(workspaceName);
|
34 |
-
return ws
|
35 |
}
|
36 |
|
37 |
async getEnvs() {
|
38 |
// Return all available workspace environments
|
39 |
-
const envs =
|
40 |
await expect(envs).not.toHaveCount(0);
|
41 |
return await envs.allInnerTexts();
|
42 |
}
|
@@ -46,13 +43,13 @@ export class Workspace {
|
|
46 |
}
|
47 |
|
48 |
async expectCurrentWorkspaceIs(name) {
|
49 |
-
await expect(this.page.locator(
|
50 |
}
|
51 |
|
52 |
async waitForNodesToLoad() {
|
53 |
// This method should be used only on non empty workspaces
|
54 |
-
await this.page.locator(
|
55 |
-
await this.page.locator(
|
56 |
}
|
57 |
|
58 |
async addBox(boxName) {
|
@@ -63,28 +60,32 @@ export class Workspace {
|
|
63 |
const numNodes = allBoxes.length;
|
64 |
await this.page.mouse.wheel(0, numNodes * 500);
|
65 |
}
|
66 |
-
|
67 |
// Some x,y offset, otherwise the box handle may fall outside the viewport.
|
68 |
-
await this.page
|
69 |
-
|
70 |
-
|
|
|
|
|
71 |
// Workaround to wait for the deselection animation after choosing a box. Otherwise, the next box will not be added.
|
72 |
-
await new Promise(resolve => setTimeout(resolve, 200));
|
73 |
}
|
74 |
|
75 |
async getCatalog() {
|
76 |
-
await this.page.locator(
|
77 |
-
const catalog =
|
|
|
|
|
78 |
// Dismiss the catalog menu
|
79 |
-
await this.page.keyboard.press(
|
80 |
-
await new Promise(resolve => setTimeout(resolve, 200));
|
81 |
-
return catalog
|
82 |
}
|
83 |
-
|
84 |
async deleteBoxes(boxIds: string[]) {
|
85 |
for (const boxId of boxIds) {
|
86 |
-
|
87 |
-
|
88 |
}
|
89 |
}
|
90 |
|
@@ -93,36 +94,44 @@ export class Workspace {
|
|
93 |
}
|
94 |
|
95 |
getBoxes() {
|
96 |
-
return this.page.locator(
|
97 |
}
|
98 |
|
99 |
getBoxHandle(boxId: string) {
|
100 |
return this.page.getByTestId(boxId);
|
101 |
}
|
102 |
|
103 |
-
async moveBox(
|
|
|
|
|
|
|
|
|
104 |
// Move a box around, it is a best effort operation, the exact target position may not be reached
|
105 |
const box = await this.getBox(boxId).boundingBox();
|
106 |
-
if (!box) {
|
107 |
-
return
|
108 |
}
|
109 |
const boxCenterX = box.x + box.width / 2;
|
110 |
const boxCenterY = box.y + box.height / 2;
|
111 |
-
await this.page.mouse.move(boxCenterX,boxCenterY);
|
112 |
await this.page.mouse.down();
|
113 |
if (targetPosition) {
|
114 |
await this.page.mouse.move(targetPosition.x, targetPosition.y);
|
115 |
} else if (offset) {
|
116 |
// Without steps the movement is too fast and the box is not dragged. The more steps,
|
117 |
// the better the movement is captured
|
118 |
-
await this.page.mouse.move(
|
|
|
|
|
|
|
|
|
119 |
}
|
120 |
await this.page.mouse.up();
|
121 |
}
|
122 |
|
123 |
async connectBoxes(sourceId: string, targetId: string) {
|
124 |
-
const sourceHandle = this.getBoxHandle(sourceId)
|
125 |
-
const targetHandle = this.getBoxHandle(targetId)
|
126 |
await sourceHandle.hover();
|
127 |
await this.page.mouse.down();
|
128 |
await targetHandle.hover();
|
@@ -132,34 +141,35 @@ export class Workspace {
|
|
132 |
async isErrorFree(executionWaitTime?): Promise<boolean> {
|
133 |
// TODO: Workaround, to account for workspace execution. Once
|
134 |
// we have a load indicator we can use that instead.
|
135 |
-
await new Promise(resolve =>
|
|
|
|
|
136 |
const boxes = await this.getBoxes();
|
137 |
for (const box of boxes) {
|
138 |
-
if (await box.locator(
|
139 |
return false;
|
140 |
}
|
141 |
}
|
142 |
return true;
|
143 |
}
|
144 |
|
145 |
-
async close() {
|
146 |
await this.page.locator('a[href="/dir/"]').click();
|
147 |
}
|
148 |
}
|
149 |
|
150 |
-
|
151 |
export class Splash {
|
152 |
page: Page;
|
153 |
root: Locator;
|
154 |
-
|
155 |
constructor(page) {
|
156 |
this.page = page;
|
157 |
-
this.root = page.locator(
|
158 |
}
|
159 |
|
160 |
// Opens the LynxKite directory browser in the root.
|
161 |
static async open(page: Page): Promise<Splash> {
|
162 |
-
await page.goto(
|
163 |
await page.evaluate(() => {
|
164 |
window.sessionStorage.clear();
|
165 |
window.localStorage.clear();
|
@@ -170,26 +180,28 @@ export class Splash {
|
|
170 |
}
|
171 |
|
172 |
workspace(name: string) {
|
173 |
-
return this.page.getByRole(
|
174 |
}
|
175 |
|
176 |
getEntry(name: string) {
|
177 |
-
return this.page.locator(
|
178 |
}
|
179 |
|
180 |
async createWorkspace(name?: string) {
|
181 |
-
await this.page.getByRole(
|
182 |
await this.page.locator('input[name="workspaceName"]').click();
|
183 |
let workspaceName: string;
|
184 |
if (name) {
|
185 |
workspaceName = name;
|
186 |
await this.page.locator('input[name="workspaceName"]').fill(name);
|
187 |
} else {
|
188 |
-
workspaceName = await this.page
|
|
|
|
|
189 |
}
|
190 |
-
await this.page.locator('input[name="workspaceName"]').press(
|
191 |
const ws = new Workspace(this.page, workspaceName);
|
192 |
-
await ws.setEnv(
|
193 |
return ws;
|
194 |
}
|
195 |
|
@@ -199,25 +211,24 @@ export class Splash {
|
|
199 |
}
|
200 |
|
201 |
async createFolder(folderName?: string) {
|
202 |
-
await this.page.getByRole(
|
203 |
await this.page.locator('input[name="folderName"]').click();
|
204 |
if (folderName) {
|
205 |
await this.page.locator('input[name="folderName"]').fill(folderName);
|
206 |
}
|
207 |
-
await this.page.locator('input[name="folderName"]').press(
|
208 |
}
|
209 |
|
210 |
async deleteEntry(entryName: string) {
|
211 |
-
await this.getEntry(entryName).locator(
|
212 |
await this.page.reload();
|
213 |
}
|
214 |
|
215 |
currentFolder() {
|
216 |
-
return this.page.locator(
|
217 |
}
|
218 |
|
219 |
async goHome() {
|
220 |
await this.page.locator('a[href="/dir/"]').click();
|
221 |
}
|
222 |
-
|
223 |
}
|
|
|
1 |
// Shared testing utilities.
|
2 |
+
import { type Locator, type Page, expect } from "@playwright/test";
|
|
|
3 |
|
4 |
// Mirrors the "id" filter.
|
5 |
export function toId(x) {
|
6 |
+
return x.toLowerCase().replace(/[ !?,./]/g, "-");
|
7 |
}
|
8 |
|
9 |
+
export const ROOT = "automated-tests";
|
|
|
|
|
10 |
|
11 |
export class Workspace {
|
12 |
readonly page: Page;
|
|
|
28 |
const ws = await splash.openWorkspace(workspaceName);
|
29 |
await ws.waitForNodesToLoad();
|
30 |
await ws.expectCurrentWorkspaceIs(workspaceName);
|
31 |
+
return ws;
|
32 |
}
|
33 |
|
34 |
async getEnvs() {
|
35 |
// Return all available workspace environments
|
36 |
+
const envs = this.page.locator('select[name="workspace-env"] option');
|
37 |
await expect(envs).not.toHaveCount(0);
|
38 |
return await envs.allInnerTexts();
|
39 |
}
|
|
|
43 |
}
|
44 |
|
45 |
async expectCurrentWorkspaceIs(name) {
|
46 |
+
await expect(this.page.locator(".ws-name")).toHaveText(name);
|
47 |
}
|
48 |
|
49 |
async waitForNodesToLoad() {
|
50 |
// This method should be used only on non empty workspaces
|
51 |
+
await this.page.locator(".react-flow__nodes").waitFor();
|
52 |
+
await this.page.locator(".react-flow__node").first().waitFor();
|
53 |
}
|
54 |
|
55 |
async addBox(boxName) {
|
|
|
60 |
const numNodes = allBoxes.length;
|
61 |
await this.page.mouse.wheel(0, numNodes * 500);
|
62 |
}
|
63 |
+
|
64 |
// Some x,y offset, otherwise the box handle may fall outside the viewport.
|
65 |
+
await this.page
|
66 |
+
.locator(".react-flow__pane")
|
67 |
+
.click({ position: { x: 20, y: 20 } });
|
68 |
+
await this.page.locator(".node-search").getByText(boxName).click();
|
69 |
+
await this.page.keyboard.press("Escape");
|
70 |
// Workaround to wait for the deselection animation after choosing a box. Otherwise, the next box will not be added.
|
71 |
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
72 |
}
|
73 |
|
74 |
async getCatalog() {
|
75 |
+
await this.page.locator(".react-flow__pane").click();
|
76 |
+
const catalog = await this.page
|
77 |
+
.locator(".node-search .matches .search-result")
|
78 |
+
.allInnerTexts();
|
79 |
// Dismiss the catalog menu
|
80 |
+
await this.page.keyboard.press("Escape");
|
81 |
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
82 |
+
return catalog;
|
83 |
}
|
84 |
+
|
85 |
async deleteBoxes(boxIds: string[]) {
|
86 |
for (const boxId of boxIds) {
|
87 |
+
await this.getBoxHandle(boxId).first().click();
|
88 |
+
await this.page.keyboard.press("Backspace");
|
89 |
}
|
90 |
}
|
91 |
|
|
|
94 |
}
|
95 |
|
96 |
getBoxes() {
|
97 |
+
return this.page.locator(".react-flow__node").all();
|
98 |
}
|
99 |
|
100 |
getBoxHandle(boxId: string) {
|
101 |
return this.page.getByTestId(boxId);
|
102 |
}
|
103 |
|
104 |
+
async moveBox(
|
105 |
+
boxId: string,
|
106 |
+
offset?: { offsetX: number; offsetY: number },
|
107 |
+
targetPosition?: { x: number; y: number },
|
108 |
+
) {
|
109 |
// Move a box around, it is a best effort operation, the exact target position may not be reached
|
110 |
const box = await this.getBox(boxId).boundingBox();
|
111 |
+
if (!box) {
|
112 |
+
return;
|
113 |
}
|
114 |
const boxCenterX = box.x + box.width / 2;
|
115 |
const boxCenterY = box.y + box.height / 2;
|
116 |
+
await this.page.mouse.move(boxCenterX, boxCenterY);
|
117 |
await this.page.mouse.down();
|
118 |
if (targetPosition) {
|
119 |
await this.page.mouse.move(targetPosition.x, targetPosition.y);
|
120 |
} else if (offset) {
|
121 |
// Without steps the movement is too fast and the box is not dragged. The more steps,
|
122 |
// the better the movement is captured
|
123 |
+
await this.page.mouse.move(
|
124 |
+
boxCenterX + offset.offsetX,
|
125 |
+
boxCenterY + offset.offsetY,
|
126 |
+
{ steps: 5 },
|
127 |
+
);
|
128 |
}
|
129 |
await this.page.mouse.up();
|
130 |
}
|
131 |
|
132 |
async connectBoxes(sourceId: string, targetId: string) {
|
133 |
+
const sourceHandle = this.getBoxHandle(sourceId);
|
134 |
+
const targetHandle = this.getBoxHandle(targetId);
|
135 |
await sourceHandle.hover();
|
136 |
await this.page.mouse.down();
|
137 |
await targetHandle.hover();
|
|
|
141 |
async isErrorFree(executionWaitTime?): Promise<boolean> {
|
142 |
// TODO: Workaround, to account for workspace execution. Once
|
143 |
// we have a load indicator we can use that instead.
|
144 |
+
await new Promise((resolve) =>
|
145 |
+
setTimeout(resolve, executionWaitTime ? executionWaitTime : 500),
|
146 |
+
);
|
147 |
const boxes = await this.getBoxes();
|
148 |
for (const box of boxes) {
|
149 |
+
if (await box.locator(".error").isVisible()) {
|
150 |
return false;
|
151 |
}
|
152 |
}
|
153 |
return true;
|
154 |
}
|
155 |
|
156 |
+
async close() {
|
157 |
await this.page.locator('a[href="/dir/"]').click();
|
158 |
}
|
159 |
}
|
160 |
|
|
|
161 |
export class Splash {
|
162 |
page: Page;
|
163 |
root: Locator;
|
164 |
+
|
165 |
constructor(page) {
|
166 |
this.page = page;
|
167 |
+
this.root = page.locator("#splash");
|
168 |
}
|
169 |
|
170 |
// Opens the LynxKite directory browser in the root.
|
171 |
static async open(page: Page): Promise<Splash> {
|
172 |
+
await page.goto("/");
|
173 |
await page.evaluate(() => {
|
174 |
window.sessionStorage.clear();
|
175 |
window.localStorage.clear();
|
|
|
180 |
}
|
181 |
|
182 |
workspace(name: string) {
|
183 |
+
return this.page.getByRole("link", { name: name });
|
184 |
}
|
185 |
|
186 |
getEntry(name: string) {
|
187 |
+
return this.page.locator(".entry").filter({ hasText: name }).first();
|
188 |
}
|
189 |
|
190 |
async createWorkspace(name?: string) {
|
191 |
+
await this.page.getByRole("button", { name: "New workspace" }).click();
|
192 |
await this.page.locator('input[name="workspaceName"]').click();
|
193 |
let workspaceName: string;
|
194 |
if (name) {
|
195 |
workspaceName = name;
|
196 |
await this.page.locator('input[name="workspaceName"]').fill(name);
|
197 |
} else {
|
198 |
+
workspaceName = await this.page
|
199 |
+
.locator('input[name="workspaceName"]')
|
200 |
+
.inputValue();
|
201 |
}
|
202 |
+
await this.page.locator('input[name="workspaceName"]').press("Enter");
|
203 |
const ws = new Workspace(this.page, workspaceName);
|
204 |
+
await ws.setEnv("LynxKite Graph Analytics");
|
205 |
return ws;
|
206 |
}
|
207 |
|
|
|
211 |
}
|
212 |
|
213 |
async createFolder(folderName?: string) {
|
214 |
+
await this.page.getByRole("button", { name: "New folder" }).click();
|
215 |
await this.page.locator('input[name="folderName"]').click();
|
216 |
if (folderName) {
|
217 |
await this.page.locator('input[name="folderName"]').fill(folderName);
|
218 |
}
|
219 |
+
await this.page.locator('input[name="folderName"]').press("Enter");
|
220 |
}
|
221 |
|
222 |
async deleteEntry(entryName: string) {
|
223 |
+
await this.getEntry(entryName).locator("button").click();
|
224 |
await this.page.reload();
|
225 |
}
|
226 |
|
227 |
currentFolder() {
|
228 |
+
return this.page.locator(".current-folder");
|
229 |
}
|
230 |
|
231 |
async goHome() {
|
232 |
await this.page.locator('a[href="/dir/"]').click();
|
233 |
}
|
|
|
234 |
}
|
lynxkite-app/web/tests/upload.spec.ts
CHANGED
@@ -1,41 +1,43 @@
|
|
|
|
|
|
1 |
// Test uploading a file in an import box.
|
2 |
-
import {
|
3 |
-
import { Splash, Workspace } from
|
4 |
-
import { join, dirname } from 'path';
|
5 |
-
import { fileURLToPath } from 'url';
|
6 |
-
|
7 |
|
8 |
let workspace: Workspace;
|
9 |
|
10 |
-
|
11 |
test.beforeEach(async ({ browser }) => {
|
12 |
-
workspace = await Workspace.empty(
|
|
|
|
|
|
|
13 |
});
|
14 |
|
15 |
-
test.afterEach(async (
|
16 |
await workspace.close();
|
17 |
const splash = await new Splash(workspace.page);
|
18 |
-
splash.page.on(
|
19 |
-
|
|
|
|
|
20 |
});
|
21 |
|
|
|
|
|
|
|
|
|
22 |
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
|
|
|
|
27 |
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
await filenameInput.click();
|
32 |
-
await filenameInput.fill(csvPath);
|
33 |
-
await filenameInput.press('Enter');
|
34 |
-
|
35 |
-
await workspace.addBox('View tables');
|
36 |
-
const tableBox = workspace.getBox('View tables 1');
|
37 |
-
await workspace.connectBoxes('Import CSV 1', 'View tables 1');
|
38 |
|
39 |
-
|
40 |
-
|
41 |
});
|
|
|
1 |
+
import { dirname, join } from "node:path";
|
2 |
+
import { fileURLToPath } from "node:url";
|
3 |
// Test uploading a file in an import box.
|
4 |
+
import { expect, test } from "@playwright/test";
|
5 |
+
import { Splash, Workspace } from "./lynxkite";
|
|
|
|
|
|
|
6 |
|
7 |
let workspace: Workspace;
|
8 |
|
|
|
9 |
test.beforeEach(async ({ browser }) => {
|
10 |
+
workspace = await Workspace.empty(
|
11 |
+
await browser.newPage(),
|
12 |
+
"upload_spec_test",
|
13 |
+
);
|
14 |
});
|
15 |
|
16 |
+
test.afterEach(async () => {
|
17 |
await workspace.close();
|
18 |
const splash = await new Splash(workspace.page);
|
19 |
+
splash.page.on("dialog", async (dialog) => {
|
20 |
+
await dialog.accept();
|
21 |
+
});
|
22 |
+
await splash.deleteEntry("upload_spec_test");
|
23 |
});
|
24 |
|
25 |
+
test("can upload and import a simple CSV", async () => {
|
26 |
+
const __filename = fileURLToPath(import.meta.url);
|
27 |
+
const __dirname = dirname(__filename);
|
28 |
+
const csvPath = join(__dirname, "data", "upload_test.csv");
|
29 |
|
30 |
+
await workspace.addBox("Import CSV");
|
31 |
+
const csvBox = workspace.getBox("Import CSV 1");
|
32 |
+
const filenameInput = csvBox.locator("input.input-bordered").nth(0);
|
33 |
+
await filenameInput.click();
|
34 |
+
await filenameInput.fill(csvPath);
|
35 |
+
await filenameInput.press("Enter");
|
36 |
|
37 |
+
await workspace.addBox("View tables");
|
38 |
+
const tableBox = workspace.getBox("View tables 1");
|
39 |
+
await workspace.connectBoxes("Import CSV 1", "View tables 1");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
40 |
|
41 |
+
const tableRows = tableBox.locator("table tbody tr");
|
42 |
+
await expect(tableRows).toHaveCount(4);
|
43 |
});
|
lynxkite-app/web/tsconfig.app.json
CHANGED
@@ -6,16 +6,12 @@
|
|
6 |
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
7 |
"module": "ESNext",
|
8 |
"skipLibCheck": true,
|
9 |
-
|
10 |
-
/* Bundler mode */
|
11 |
"moduleResolution": "bundler",
|
12 |
"allowImportingTsExtensions": true,
|
13 |
"isolatedModules": true,
|
14 |
"moduleDetection": "force",
|
15 |
"noEmit": true,
|
16 |
"jsx": "react-jsx",
|
17 |
-
|
18 |
-
/* Linting */
|
19 |
"strict": true,
|
20 |
"noUnusedLocals": true,
|
21 |
"noUnusedParameters": true,
|
|
|
6 |
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
7 |
"module": "ESNext",
|
8 |
"skipLibCheck": true,
|
|
|
|
|
9 |
"moduleResolution": "bundler",
|
10 |
"allowImportingTsExtensions": true,
|
11 |
"isolatedModules": true,
|
12 |
"moduleDetection": "force",
|
13 |
"noEmit": true,
|
14 |
"jsx": "react-jsx",
|
|
|
|
|
15 |
"strict": true,
|
16 |
"noUnusedLocals": true,
|
17 |
"noUnusedParameters": true,
|
lynxkite-app/web/tsconfig.node.json
CHANGED
@@ -5,15 +5,11 @@
|
|
5 |
"lib": ["ES2023"],
|
6 |
"module": "ESNext",
|
7 |
"skipLibCheck": true,
|
8 |
-
|
9 |
-
/* Bundler mode */
|
10 |
"moduleResolution": "bundler",
|
11 |
"allowImportingTsExtensions": true,
|
12 |
"isolatedModules": true,
|
13 |
"moduleDetection": "force",
|
14 |
"noEmit": true,
|
15 |
-
|
16 |
-
/* Linting */
|
17 |
"strict": true,
|
18 |
"noUnusedLocals": true,
|
19 |
"noUnusedParameters": true,
|
|
|
5 |
"lib": ["ES2023"],
|
6 |
"module": "ESNext",
|
7 |
"skipLibCheck": true,
|
|
|
|
|
8 |
"moduleResolution": "bundler",
|
9 |
"allowImportingTsExtensions": true,
|
10 |
"isolatedModules": true,
|
11 |
"moduleDetection": "force",
|
12 |
"noEmit": true,
|
|
|
|
|
13 |
"strict": true,
|
14 |
"noUnusedLocals": true,
|
15 |
"noUnusedParameters": true,
|
lynxkite-app/web/vite.config.ts
CHANGED
@@ -1,6 +1,6 @@
|
|
1 |
-
import
|
2 |
-
import
|
3 |
-
import
|
4 |
|
5 |
// https://vite.dev/config/
|
6 |
export default defineConfig({
|
@@ -10,21 +10,18 @@ export default defineConfig({
|
|
10 |
esbuild: {
|
11 |
supported: {
|
12 |
// For dynamic imports.
|
13 |
-
|
14 |
},
|
15 |
},
|
16 |
-
plugins: [
|
17 |
-
react(),
|
18 |
-
Icons({ compiler: 'jsx', jsx: 'react' }),
|
19 |
-
],
|
20 |
server: {
|
21 |
proxy: {
|
22 |
-
|
23 |
-
|
24 |
-
target:
|
25 |
ws: true,
|
26 |
changeOrigin: true,
|
27 |
},
|
28 |
},
|
29 |
},
|
30 |
-
})
|
|
|
1 |
+
import react from "@vitejs/plugin-react-swc";
|
2 |
+
import Icons from "unplugin-icons/vite";
|
3 |
+
import { defineConfig } from "vite";
|
4 |
|
5 |
// https://vite.dev/config/
|
6 |
export default defineConfig({
|
|
|
10 |
esbuild: {
|
11 |
supported: {
|
12 |
// For dynamic imports.
|
13 |
+
"top-level-await": true,
|
14 |
},
|
15 |
},
|
16 |
+
plugins: [react(), Icons({ compiler: "jsx", jsx: "react" })],
|
|
|
|
|
|
|
17 |
server: {
|
18 |
proxy: {
|
19 |
+
"/api": "http://127.0.0.1:8000",
|
20 |
+
"/ws": {
|
21 |
+
target: "ws://127.0.0.1:8000",
|
22 |
ws: true,
|
23 |
changeOrigin: true,
|
24 |
},
|
25 |
},
|
26 |
},
|
27 |
+
});
|