darabos commited on
Commit
8fe4e41
·
1 Parent(s): d161f2f

Set up Biome for JS linting/formatting.

Browse files
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 '@eslint/js'
2
- import globals from 'globals'
3
- import reactHooks from 'eslint-plugin-react-hooks'
4
- import reactRefresh from 'eslint-plugin-react-refresh'
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
- )
 
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 '@playwright/test';
2
-
3
 
4
  export default defineConfig({
5
- testDir: './tests',
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 ? [['github'], ['html']] : 'html',
13
  use: {
14
  /* Base URL to use in actions like `await page.goto('/')`. */
15
- baseURL: 'http://127.0.0.1:8000',
16
  /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
17
- trace: 'on-first-retry',
18
- testIdAttribute: 'data-nodeid' // Useful for easily selecting nodes using getByTestId
19
  },
20
  projects: [
21
  {
22
- name: 'chromium',
23
- use: { ...devices['Desktop Chrome'] },
24
  },
25
  ],
26
  webServer: {
27
- command: 'cd .. && lynxkite',
28
- url: 'http://127.0.0.1:8000',
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
- 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
  }
 
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
- 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,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
- 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
  }
 
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 'react'
2
- import { createRoot } from 'react-dom/client'
3
- import '@xyflow/react/dist/style.css';
4
- import './index.css'
5
- import Directory from './Directory.tsx'
6
- import Workspace from './workspace/Workspace.tsx'
7
- import { BrowserRouter, Routes, Route } from "react-router";
8
 
9
- createRoot(document.getElementById('root')!).render(
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: { options: string[], value: string, onChange: (val: string) => void }) {
 
 
 
 
2
  return (
3
  <>
4
- <select className="select w-full max-w-xs"
 
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}>{option}</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 '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: { boxes: Catalog, onCancel: any, onAdd: any, pos: { x: number, y: number } }) {
 
 
 
 
 
14
  const searchBox = useRef(null as unknown as HTMLInputElement);
15
- const [searchText, setSearchText] = useState('');
16
- const fuse = useMemo(() => new Fuse(Object.values(props.boxes), {
17
- keys: ['name']
18
- }), [props.boxes]);
19
- const hits: { item: OpsOp }[] = searchText ? fuse.search<OpsOp>(searchText) : Object.values(props.boxes).map(box => ({ item: box }));
 
 
 
 
 
 
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 === 'ArrowDown') {
28
  e.preventDefault();
29
  setSelectedIndex(Math.min(selectedIndex + 1, hits.length - 1));
30
- } else if (e.key === 'ArrowUp') {
31
  e.preventDefault();
32
  setSelectedIndex(Math.max(selectedIndex - 1, 0));
33
- } else if (e.key === 'Enter') {
34
  addSelected();
35
- } else if (e.key === 'Escape') {
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 && e.relatedTarget.closest('.node-search')) return;
47
  props.onCancel();
48
  }
49
 
50
-
51
  return (
52
- <div className="node-search" style={{ top: props.pos.y, left: props.pos.x }}>
 
 
 
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 ? 'selected' : ''}`}>
 
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
- // The LynxKite workspace editor.
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
- ReactFlow,
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
- MiniMap,
20
- } from '@xyflow/react';
 
 
 
 
 
 
 
 
 
 
 
21
  // @ts-ignore
22
- import ArrowBack from '~icons/tabler/arrow-back.jsx';
23
  // @ts-ignore
24
- import Backspace from '~icons/tabler/backspace.jsx';
25
  // @ts-ignore
26
- import Atom from '~icons/tabler/atom.jsx';
27
- import { syncedStore, getYjsDoc } from "@syncedstore/core";
28
- import { WebsocketProvider } from "y-websocket";
29
- import NodeWithParams from './nodes/NodeWithParams';
30
  // import NodeWithTableView from './NodeWithTableView';
31
- import EnvironmentSelector from './EnvironmentSelector';
32
- import { LynxKiteState } from './LynxKiteState';
33
- import { Workspace, WorkspaceNode } from "../apiTypes.ts";
34
- import NodeSearch, { OpsOp, Catalog, Catalogs } from "./NodeSearch.tsx";
35
- import NodeWithVisualization from "./nodes/NodeWithVisualization.tsx";
 
 
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(`ws://${location.host}/ws/crdt`, path!, doc);
 
 
 
 
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('update', onChange);
76
  return () => {
77
  doc.destroy();
78
  wsProvider.destroy();
79
- }
80
- }, [path]);
81
 
82
- const onNodesChange = useCallback((changes: any[]) => {
83
- // An update from the UI. Apply it to the local state...
84
- setNodes((nds) => applyNodeChanges(changes, nds));
85
- // ...and to the CRDT state. (Which could be the same, except for ReactFlow's internal copies.)
86
- const wnodes = state.workspace?.nodes;
87
- if (!wnodes) return;
88
- for (const ch of changes) {
89
- const nodeIndex = wnodes.findIndex((n) => n.id === ch.id);
90
- if (nodeIndex === -1) continue;
91
- const node = wnodes[nodeIndex];
92
- if (!node) continue;
93
- // Position events sometimes come with NaN values. Ignore them.
94
- if (ch.type === 'position' && !isNaN(ch.position.x) && !isNaN(ch.position.y)) {
95
- getYjsDoc(state).transact(() => {
96
- Object.assign(node.position, ch.position);
97
- });
98
- } else if (ch.type === 'select') {
99
- } else if (ch.type === 'dimensions') {
100
- getYjsDoc(state).transact(() => Object.assign(node, ch.dimensions));
101
- } else if (ch.type === 'remove') {
102
- wnodes.splice(nodeIndex, 1);
103
- } else if (ch.type === 'replace') {
104
- // Ideally we would only update the parameter that changed. But ReactFlow does not give us that detail.
105
- const u = {
106
- collapsed: ch.item.data.collapsed,
107
- // The "..." expansion on a Y.map returns an empty object. Copying with fromEntries/entries instead.
108
- params: { ...Object.fromEntries(Object.entries(ch.item.data.params)) },
109
- __execution_delay: ch.item.data.__execution_delay,
110
- };
111
- getYjsDoc(state).transact(() => Object.assign(node.data, u));
112
- } else {
113
- console.log('Unknown node change', ch);
 
 
 
 
 
 
 
 
114
  }
115
- }
116
- }, [state]);
117
- const onEdgesChange = useCallback((changes: any[]) => {
118
- setEdges((eds) => applyEdgeChanges(changes, eds));
119
- const wedges = state.workspace?.edges;
120
- if (!wedges) return;
121
- for (const ch of changes) {
122
- const edgeIndex = wedges.findIndex((e) => e.id === ch.id);
123
- if (ch.type === 'remove') {
124
- wedges.splice(edgeIndex, 1);
125
- } else if (ch.type === 'select') {
126
- } else {
127
- console.log('Unknown edge change', ch);
 
 
 
128
  }
129
- }
130
- }, [state]);
 
131
 
132
- const fetcher: Fetcher<Catalogs> = (resource: string, init?: RequestInit) => fetch(resource, init).then(res => res.json());
133
- const catalog = useSWR('/api/catalog', fetcher);
 
134
  const [suppressSearchUntil, setSuppressSearchUntil] = useState(0);
135
- const [nodeSearchSettings, setNodeSearchSettings] = useState(undefined as {
136
- pos: XYPosition,
137
- boxes: Catalog,
138
- } | undefined);
139
- const nodeTypes = useMemo(() => ({
140
- basic: NodeWithParams,
141
- visualization: NodeWithVisualization,
142
- image: NodeWithImage,
143
- table_view: NodeWithTableView,
144
- }), []);
 
 
 
 
 
 
 
145
  const closeNodeSearch = useCallback(() => {
146
  setNodeSearchSettings(undefined);
147
  setSuppressSearchUntil(Date.now() + 200);
148
- }, [setNodeSearchSettings, setSuppressSearchUntil]);
149
- const toggleNodeSearch = useCallback((event: MouseEvent) => {
150
- if (suppressSearchUntil > Date.now()) return;
151
- if (nodeSearchSettings) {
152
- closeNodeSearch();
153
- return;
154
- }
155
- event.preventDefault();
156
- setNodeSearchSettings({
157
- pos: { x: event.clientX, y: event.clientY },
158
- boxes: catalog.data![state.workspace.env!],
159
- });
160
- }, [catalog, state, setNodeSearchSettings, suppressSearchUntil]);
161
- const addNode = useCallback((meta: OpsOp) => {
162
- const node: Partial<WorkspaceNode> = {
163
- type: meta.type,
164
- data: {
165
- meta: meta,
166
- title: meta.name,
167
- params: Object.fromEntries(
168
- Object.values(meta.params).map((p) => [p.name, p.default])),
169
- },
170
- };
171
- const nss = nodeSearchSettings!;
172
- node.position = reactFlow.screenToFlowPosition({ x: nss.pos.x, y: nss.pos.y });
173
- const title = meta.name;
174
- let i = 1;
175
- node.id = `${title} ${i}`;
176
- const wnodes = state.workspace.nodes!;
177
- while (wnodes.find((x) => x.id === node.id)) {
178
- i += 1;
 
 
 
 
179
  node.id = `${title} ${i}`;
180
- }
181
- wnodes.push(node as WorkspaceNode);
182
- setNodes([...nodes, node as WorkspaceNode]);
183
- closeNodeSearch();
184
- }, [nodeSearchSettings, state, reactFlow, setNodes]);
 
 
 
 
 
 
185
 
186
- const onConnect = useCallback((connection: Connection) => {
187
- setSuppressSearchUntil(Date.now() + 200);
188
- const edge = {
189
- id: `${connection.source} ${connection.target}`,
190
- source: connection.source,
191
- sourceHandle: connection.sourceHandle!,
192
- target: connection.target,
193
- targetHandle: connection.targetHandle!,
194
- };
195
- state.workspace.edges!.push(edge);
196
- setEdges((oldEdges) => [...oldEdges, edge]);
197
- }, [state, setEdges]);
198
- const parentDir = path!.split('/').slice(0, -1).join('/');
 
 
 
199
  return (
200
  <div className="workspace">
201
  <div className="top-bar bg-neutral">
202
- <a className="logo" href=""><img src={favicon} /></a>
203
- <div className="ws-name">
204
- {path}
205
- </div>
206
  <EnvironmentSelector
207
  options={Object.keys(catalog.data || {})}
208
  value={state.workspace.env!}
209
- onChange={(env) => { state.workspace.env = env; }}
 
 
210
  />
211
  <div className="tools text-secondary">
212
- <a href=""><Atom /></a>
213
- <a href=""><Backspace /></a>
214
- <a href={'/dir/' + parentDir}><ArrowBack /></a>
 
 
 
 
 
 
215
  </div>
216
  </div>
217
- <div style={{ height: "100%", width: '100vw' }}>
218
  <LynxKiteState.Provider value={state}>
219
  <ReactFlow
220
  nodes={nodes}
221
  edges={edges}
222
- nodeTypes={nodeTypes} fitView
 
223
  onNodesChange={onNodesChange}
224
  onEdgesChange={onEdgesChange}
225
  onPaneClick={toggleNodeSearch}
@@ -231,13 +286,17 @@ function LynxKiteFlow() {
231
  >
232
  <Controls />
233
  <MiniMap />
234
- {nodeSearchSettings &&
235
- <NodeSearch pos={nodeSearchSettings.pos} boxes={nodeSearchSettings.boxes} onCancel={closeNodeSearch} onAdd={addNode} />
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 { useReactFlow, Handle, NodeResizeControl, Position } from '@xyflow/react';
 
 
 
 
 
2
  // @ts-ignore
3
- import ChevronDownRight from '~icons/tabler/chevron-down-right.jsx';
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: 'top' | 'bottom' | 'left' | 'right',
17
- name: string,
18
- index: number,
19
- offsetPercentage: number,
20
- showLabel: boolean,
21
- type: 'source' | 'target',
22
  }[] = [];
23
  for (const e of Object.values(inputs)) {
24
- handles.push({ ...e, type: 'target' });
25
  }
26
  for (const e of Object.values(outputs)) {
27
- handles.push({ ...e, type: 'source' });
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 = counts.top === 0 && counts.bottom === 0 && handles.length <= 2;
37
- const simpleVertical = counts.left === 0 && counts.right === 0 && handles.length <= 2;
 
 
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 = { top: 'left', bottom: 'left', left: 'top', right: 'top' };
 
 
 
 
 
52
 
53
  return (
54
- <div className={'node-container ' + (expanded ? 'expanded' : 'collapsed')}
55
- style={{ width: props.width || 200, height: expanded ? props.height || 200 : undefined }}>
 
 
 
 
 
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
- {data.error &&
64
- <div className="error">{data.error}</div>
65
- }
66
- {props.children}
67
- <NodeResizeControl
68
- minWidth={100}
69
- minHeight={50}
70
- style={{ 'background': 'transparent', 'border': 'none' }}
71
- >
72
- <ChevronDownRight className="node-resizer" />
73
- </NodeResizeControl>
74
- </>}
75
- {handles.map(handle => (
76
  <Handle
77
  key={handle.name}
78
- id={handle.name} type={handle.type} position={handle.position as Position}
79
- style={{ [handleOffsetDirection[handle.position]]: handle.offsetPercentage + '%' }}>
80
- {handle.showLabel && <span className="handle-name">{handle.name.replace(/_/g, " ")}</span>}
81
- </Handle >
 
 
 
 
 
 
 
 
 
 
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 <span className="param-name bg-base-200">{name.replace(/_/g, ' ')}</span>;
 
 
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({ name, value, meta, onChange }: NodeParameterProps) {
 
 
 
 
 
15
  return (
 
16
  <label className="param">
17
- {meta?.type?.format === 'collapsed' ? <>
18
- <ParamName name={name} />
19
- <button className="collapsed-param">
20
-
21
- </button>
22
- </> : meta?.type?.format === 'textarea' ? <>
23
- <ParamName name={name} />
24
- <textarea className="textarea textarea-bordered w-full max-w-xs"
25
- rows={6}
26
- value={value}
27
- onChange={(evt) => onChange(evt.currentTarget.value, { delay: 2 })}
28
- onBlur={(evt) => onChange(evt.currentTarget.value, { delay: 0 })}
29
- />
30
- </> : meta?.type?.enum ? <>
31
- <ParamName name={name} />
32
- <select className="select select-bordered w-full max-w-xs"
33
- value={value || meta.type.enum[0]}
34
- onChange={(evt) => onChange(evt.currentTarget.value)}
35
- >
36
- {meta.type.enum.map((option: string) =>
37
- <option key={option} value={option}>{option}</option>
38
- )}
39
- </select>
40
- </> : meta?.type?.type === BOOLEAN ? <div className="form-control">
41
- <label className="label cursor-pointer">
42
- <input className="checkbox"
43
- type="checkbox"
44
- checked={value}
45
- onChange={(evt) => onChange(evt.currentTarget.checked)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  />
47
- {name.replace(/_/g, ' ')}
48
- </label>
49
- </div> : <>
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 './NodeWithParams';
2
 
3
  const NodeWithImage = (props: any) => {
4
  return (
5
  <NodeWithParams {...props}>
6
- {props.data.display && <img src={props.data.display} alt="Node 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 LynxKiteNode from './LynxKiteNode';
2
- import { useReactFlow } from '@xyflow/react';
3
- import NodeParameter from './NodeParameter';
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, { params: { ...props.data.params, [name]: newValue }, __execution_delay: opts.delay || 0 });
 
 
 
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) => setParam(name, value, opts || {})}
 
 
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 'react';
2
- import Markdown from 'react-markdown'
3
- import LynxKiteNode from './LynxKiteNode';
4
- import Table from './Table';
5
- import React from 'react';
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,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 = display?.dataframes && Object.keys(display?.dataframes).length === 1;
 
21
  return (
22
  <LynxKiteNode {...props}>
23
  {display && [
24
- Object.entries(display.dataframes || {}).map(([name, df]: [string, any]) => <React.Fragment key={name}>
25
- {!single && <div key={name + '-header'} className="df-head" onClick={() => setOpen({ ...open, [name]: !open[name] })}>{name}</div>}
26
- {(single || open[name]) &&
27
- (df.data.length > 1 ?
28
- <Table key={name + '-table'} columns={df.columns} data={df.data} />
29
- :
30
- df.data.length ?
31
- <dl key={name + '-dl'}>
32
- {df.columns.map((c: string, i: number) =>
33
- <React.Fragment key={name + '-' + c}>
34
- <dt>{c}</dt>
35
- <dd><Markdown>{toMD(df.data[0][i])}</Markdown></dd>
36
- </React.Fragment>)
37
- }
38
- </dl>
39
- :
40
- JSON.stringify(df.data))}
41
- </React.Fragment>),
42
- Object.entries(display.others || {}).map(([name, o]) => <>
43
- <div className="df-head" onClick={() => setOpen({ ...open, [name]: !open[name] })}>{name}</div>
44
- {open[name] && <pre>{(o as any).toString()}</pre>}
45
- </>
46
- )]}
47
- </LynxKiteNode >
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 '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,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, { renderer: 'canvas', width: 250, height: 250 });
 
 
 
 
12
  chartsInstanceRef.current.setOption(opts);
13
  const onResize = () => chartsInstanceRef.current?.resize();
14
- window.addEventListener('resize', onResize);
15
  return () => {
16
- window.removeEventListener('resize', onResize);
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 (<table>
3
- <thead>
4
- <tr>
5
- {props.columns.map((column: string) =>
6
- <th key={column}>{column}</th>)}
7
- </tr>
8
- </thead>
9
- <tbody>
10
- {props.data.map((row: { [column: string]: any }, i: number) =>
11
- <tr key={`row-${i}`}>
12
- {props.columns.map((_column: string, j: number) =>
13
- <td key={`cell ${i}, ${j}`}>{JSON.stringify(row[j])}</td>)}
14
- </tr>)}
15
- </tbody>
16
- </table>);
 
 
 
 
 
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: '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
  ],
 
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 { test, expect } from '@playwright/test';
3
- import { Splash, Workspace } from './lynxkite';
4
-
5
 
6
  let workspace: Workspace;
7
 
8
-
9
  test.beforeEach(async ({ browser }) => {
10
- workspace = await Workspace.empty(await browser.newPage(), 'basic_spec_test');
11
  });
12
 
13
  test.afterEach(async () => {
14
  await workspace.close();
15
  const splash = await new Splash(workspace.page);
16
- splash.page.on('dialog', async dialog => { await dialog.accept(); });
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,23 +31,18 @@ test('Box creation & deletion per env', async () => {
31
  }
32
  });
33
 
34
-
35
- test('Delete multi-handle boxes', async () => {
36
- await workspace.addBox('Compute PageRank');
37
- await workspace.deleteBoxes(['Compute PageRank 1']);
38
- await expect(workspace.getBox('Compute PageRank 1')).not.toBeVisible();
39
  });
40
 
41
-
42
- test ('Drag box', async () => {
43
- await workspace.addBox('Import Parquet');
44
- const originalPos = await workspace.getBox('Import Parquet 1').boundingBox();
45
- await workspace.moveBox('Import Parquet 1', {offsetX: 100, offsetY: 100});
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 { test, expect } from '@playwright/test';
3
- import { Workspace, Splash } from './lynxkite';
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('dialog', async dialog => { await dialog.accept(); });
 
 
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('Untitled');
22
  await workspace.close();
23
  });
24
 
25
- test('Create & delete workspace', async () => {
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('Import Parquet');
31
  await workspace.close();
32
  await splash.deleteEntry(workspaceName);
33
  await expect(splash.getEntry(workspaceName)).not.toBeVisible();
34
  });
35
 
36
- test('Create & delete folder', async () => {
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('Create folder with default name', async () => {
46
  await splash.createFolder();
47
- await expect(splash.currentFolder()).toContainText('Untitled');
48
  });
49
  });
50
 
51
-
52
- test.describe.serial('Nested folders & workspaces operations', () => {
53
-
54
- let splash;
55
-
56
- test.beforeEach(() => {
57
- // Nested navigation doesn't work yet
58
- test.skip();
59
- });
60
-
61
- test.beforeAll(async ({ browser }) => {
62
- const page = await browser.newPage()
63
- // To make deletion confirmation dialog to be automatically accepted
64
- page.on('dialog', async dialog => { await dialog.accept(); });
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
- });
 
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 { test, expect } from '@playwright/test';
3
- import { Splash, Workspace } from './lynxkite';
4
-
5
 
6
  let workspace: Workspace;
7
 
8
-
9
  test.beforeEach(async ({ browser }) => {
10
- workspace = await Workspace.empty(await browser.newPage(), 'error_spec_test');
11
  });
12
 
13
- test.afterEach(async ({ }) => {
14
  await workspace.close();
15
  const splash = await new Splash(workspace.page);
16
- splash.page.on('dialog', async dialog => { await dialog.accept(); });
17
- await splash.deleteEntry('error_spec_test');
 
 
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('Create scale-free graph');
25
- const graphBox = workspace.getBox('Create scale-free graph 1');
26
- await graphBox.locator('input').fill('');
27
- expect(await graphBox.locator('.error').innerText()).toBe("invalid literal for int() with base 10: ''");
28
- await graphBox.locator('input').fill('10');
29
- await expect(graphBox.locator('.error')).not.toBeVisible();
 
 
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('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
  });
 
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 { test, expect } from '@playwright/test';
3
- import { Workspace } from './lynxkite';
4
 
5
-
6
- test('LynxKite Graph Analytics example', async ({ page }) => {
7
- const ws = await Workspace.open(page, "NetworkX demo");
8
- expect(await ws.isErrorFree(process.env.CI? 2000: 1000)).toBeTruthy();
9
  });
10
 
11
-
12
- test('Pytorch example', async ({ page }) => {
13
- const ws = await Workspace.open(page, "PyTorch demo");
14
- expect(await ws.isErrorFree()).toBeTruthy();
15
  });
16
 
17
-
18
- test.fail('AIMO example', async ({ page }) => {
19
- // Fails because of missing OPENAI_API_KEY
20
- const ws = await Workspace.open(page, "AIMO");
21
- expect(await ws.isErrorFree()).toBeTruthy();
22
  });
23
 
24
- test.fail('LynxScribe example', async ({ page }) => {
25
- // Fails because of missing OPENAI_API_KEY
26
- const ws = await Workspace.open(page, "LynxScribe demo");
27
- expect(await ws.isErrorFree()).toBeTruthy();
28
  });
29
 
30
-
31
- test.fail('Graph RAG', async ({ page }) => {
32
- // Fails due to some issue with ChromaDB
33
- const ws = await Workspace.open(page, "Graph RAG");
34
- expect(await ws.isErrorFree(process.env.CI? 2000: 500)).toBeTruthy();
35
  });
36
 
37
-
38
- test.fail('RAG chatbot app', async ({ page }) => {
39
- // Fail due to all operation being unknown
40
- const ws = await Workspace.open(page, "RAG chatbot app");
41
- expect(await ws.isErrorFree()).toBeTruthy();
42
  });
43
 
44
-
45
- test.fail('night demo', async ({ page }) => {
46
- // airlines.graphml file not found
47
- // requires cugraph
48
- const ws = await Workspace.open(page, "night demo");
49
- expect(await ws.isErrorFree(process.env.CI? 10000: 500)).toBeTruthy();
50
  });
51
 
52
-
53
- test('Pillow example', async ({ page }) => {
54
- const ws = await Workspace.open(page, "Image processing");
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 { expect, Locator, Page } from '@playwright/test';
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 = this.page.locator('select[name="workspace-env"] option');
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('.ws-name')).toHaveText(name);
50
  }
51
 
52
  async waitForNodesToLoad() {
53
  // This method should be used only on non empty workspaces
54
- await this.page.locator('.react-flow__nodes').waitFor();
55
- await this.page.locator('.react-flow__node').first().waitFor();
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.locator('.react-flow__pane').click({ position: { x: 20, y: 20 }});
69
- await this.page.locator('.node-search').getByText(boxName).click();
70
- await this.page.keyboard.press('Escape');
 
 
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('.react-flow__pane').click();
77
- const catalog = await this.page.locator('.node-search .matches .search-result').allInnerTexts();
 
 
78
  // Dismiss the catalog menu
79
- await this.page.keyboard.press('Escape');
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
- await this.getBoxHandle(boxId).first().click();
87
- await this.page.keyboard.press('Backspace');
88
  }
89
  }
90
 
@@ -93,36 +94,44 @@ export class Workspace {
93
  }
94
 
95
  getBoxes() {
96
- return this.page.locator('.react-flow__node').all();
97
  }
98
 
99
  getBoxHandle(boxId: string) {
100
  return this.page.getByTestId(boxId);
101
  }
102
 
103
- async moveBox(boxId: string, offset? : {offsetX: number, offsetY:number}, targetPosition?: {x: number, y: number}) {
 
 
 
 
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(boxCenterX + offset.offsetX, boxCenterY + offset.offsetY, {steps: 5});
 
 
 
 
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 => setTimeout(resolve, executionWaitTime? executionWaitTime : 500));
 
 
136
  const boxes = await this.getBoxes();
137
  for (const box of boxes) {
138
- if (await box.locator('.error').isVisible()) {
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('#splash');
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('link', { name: name });
174
  }
175
 
176
  getEntry(name: string) {
177
- return this.page.locator('.entry').filter({ hasText: name }).first();
178
  }
179
 
180
  async createWorkspace(name?: string) {
181
- await this.page.getByRole('button', { name: 'New workspace' }).click();
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.locator('input[name="workspaceName"]').inputValue();
 
 
189
  }
190
- await this.page.locator('input[name="workspaceName"]').press('Enter');
191
  const ws = new Workspace(this.page, workspaceName);
192
- await ws.setEnv('LynxKite Graph Analytics');
193
  return ws;
194
  }
195
 
@@ -199,25 +211,24 @@ export class Splash {
199
  }
200
 
201
  async createFolder(folderName?: string) {
202
- await this.page.getByRole('button', { name: 'New folder' }).click();
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('Enter');
208
  }
209
 
210
  async deleteEntry(entryName: string) {
211
- await this.getEntry(entryName).locator('button').click();
212
  await this.page.reload();
213
  }
214
 
215
  currentFolder() {
216
- return this.page.locator('.current-folder');
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 { test, expect } from '@playwright/test';
3
- import { Splash, Workspace } from './lynxkite';
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(await browser.newPage(), 'upload_spec_test');
 
 
 
13
  });
14
 
15
- test.afterEach(async ({ }) => {
16
  await workspace.close();
17
  const splash = await new Splash(workspace.page);
18
- splash.page.on('dialog', async dialog => { await dialog.accept(); });
19
- await splash.deleteEntry('upload_spec_test');
 
 
20
  });
21
 
 
 
 
 
22
 
23
- test('can upload and import a simple CSV', async () => {
24
- const __filename = fileURLToPath(import.meta.url);
25
- const __dirname = dirname(__filename);
26
- const csvPath = join(__dirname, 'data', 'upload_test.csv');
 
 
27
 
28
- await workspace.addBox('Import CSV');
29
- const csvBox = workspace.getBox('Import CSV 1');
30
- const filenameInput = csvBox.locator('input.input-bordered').nth(0);
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
- const tableRows = tableBox.locator('table tbody tr');
40
- await expect(tableRows).toHaveCount(4);
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 { defineConfig } from 'vite'
2
- import react from '@vitejs/plugin-react-swc'
3
- import Icons from 'unplugin-icons/vite'
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
- 'top-level-await': true,
14
  },
15
  },
16
- plugins: [
17
- react(),
18
- Icons({ compiler: 'jsx', jsx: 'react' }),
19
- ],
20
  server: {
21
  proxy: {
22
- '/api': 'http://127.0.0.1:8000',
23
- '/ws': {
24
- target: 'ws://127.0.0.1:8000',
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
+ });