JMLizano JMLizano commited on
Commit
58bf1b1
·
unverified ·
1 Parent(s): 1cbdba3

Add deletion & naming for folders and workspaces (#67)

Browse files

* Add naming on creation & deletion for workspaces & folders

* Update tests
---------

Co-authored-by: JMLizano <[email protected]>

lynxkite-app/src/lynxkite/app/main.py CHANGED
@@ -1,5 +1,5 @@
1
  import os
2
-
3
  if os.environ.get("NX_CUGRAPH_AUTOCONFIG", "").strip().lower() == "true":
4
  import cudf.pandas
5
 
@@ -66,6 +66,16 @@ async def save_and_execute(req: SaveRequest):
66
  return req.ws
67
 
68
 
 
 
 
 
 
 
 
 
 
 
69
  @app.get("/api/load")
70
  def load(path: str):
71
  path = DATA_PATH / path
@@ -76,7 +86,7 @@ def load(path: str):
76
 
77
 
78
  DATA_PATH = pathlib.Path.cwd() / "data"
79
-
80
 
81
  @dataclasses.dataclass(order=True)
82
  class DirectoryEntry:
@@ -107,6 +117,18 @@ def make_dir(req: dict):
107
  return list_dir(path.parent)
108
 
109
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  @app.get("/api/service/{module_path:path}")
111
  async def service_get(req: fastapi.Request, module_path: str):
112
  """Executors can provide extra HTTP APIs through the /api/service endpoint."""
 
1
  import os
2
+ import shutil
3
  if os.environ.get("NX_CUGRAPH_AUTOCONFIG", "").strip().lower() == "true":
4
  import cudf.pandas
5
 
 
66
  return req.ws
67
 
68
 
69
+ @app.post("/api/delete")
70
+ async def delete_workspace(req: dict):
71
+ json_path: pathlib.Path = DATA_PATH / req["path"]
72
+ crdt_path: pathlib.Path = CRDT_PATH / f"{req["path"]}.crdt"
73
+ assert json_path.is_relative_to(DATA_PATH)
74
+ assert crdt_path.is_relative_to(CRDT_PATH)
75
+ json_path.unlink()
76
+ crdt_path.unlink()
77
+
78
+
79
  @app.get("/api/load")
80
  def load(path: str):
81
  path = DATA_PATH / path
 
86
 
87
 
88
  DATA_PATH = pathlib.Path.cwd() / "data"
89
+ CRDT_PATH = pathlib.Path.cwd() / "crdt_data"
90
 
91
  @dataclasses.dataclass(order=True)
92
  class DirectoryEntry:
 
117
  return list_dir(path.parent)
118
 
119
 
120
+ @app.post("/api/dir/delete")
121
+ def delete_dir(req: dict):
122
+ path: pathlib.Path = DATA_PATH / req["path"]
123
+ assert all([
124
+ path.is_relative_to(DATA_PATH),
125
+ path.exists(),
126
+ path.is_dir()
127
+ ])
128
+ shutil.rmtree(path)
129
+ return list_dir(path.parent)
130
+
131
+
132
  @app.get("/api/service/{module_path:path}")
133
  async def service_get(req: fastapi.Request, module_path: str):
134
  """Executors can provide extra HTTP APIs through the /api/service endpoint."""
lynxkite-app/web/src/Directory.tsx CHANGED
@@ -1,7 +1,9 @@
1
  // The directory browser.
2
- import { useParams } from "react-router";
 
3
  import useSWR from 'swr'
4
 
 
5
  import logo from './assets/logo.png';
6
  // @ts-ignore
7
  import Home from '~icons/tabler/home'
@@ -13,13 +15,22 @@ import FolderPlus from '~icons/tabler/folder-plus'
13
  import File from '~icons/tabler/file'
14
  // @ts-ignore
15
  import FilePlus from '~icons/tabler/file-plus'
 
 
 
 
16
 
17
  const fetcher = (url: string) => fetch(url).then((res) => res.json());
18
 
19
  export default function () {
20
  const { path } = useParams();
21
  const encodedPath = encodeURIComponent(path || '');
22
- const list = useSWR(`/api/dir/list?path=${encodedPath}`, fetcher)
 
 
 
 
 
23
  function link(item: any) {
24
  if (item.type === 'directory') {
25
  return `/dir/${item.name}`;
@@ -27,61 +38,136 @@ export default function () {
27
  return `/edit/${item.name}`;
28
  }
29
  }
 
30
  function shortName(item: any) {
31
  return item.name.split('/').pop();
32
  }
33
- function newName(list: any[]) {
 
34
  let i = 0;
35
  while (true) {
36
- const name = `Untitled${i ? ` ${i}` : ''}`;
37
  if (!list.find(item => item.name === name)) {
38
  return name;
39
  }
40
  i++;
41
  }
42
  }
43
- function newWorkspaceIn(path: string, list: any[]) {
44
- const pathSlash = path ? `${path}/` : '';
45
- return `/edit/${pathSlash}${newName(list)}`;
 
 
46
  }
47
- async function newFolderIn(path: string, list: any[]) {
48
- const pathSlash = path ? `${path}/` : '';
49
- const name = newName(list);
 
 
 
50
  const res = await fetch(`/api/dir/mkdir`, {
51
  method: 'POST',
52
  headers: { 'Content-Type': 'application/json' },
53
  body: JSON.stringify({ path: pathSlash + name }),
54
  });
55
  list = await res.json();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  }
57
 
58
  return (
59
  <div className="directory">
60
  <div className="logo">
61
- <a href="https://lynxkite.com/"><img src={logo} className="logo-image" alt="LynxKite logo" /></a>
 
 
62
  <div className="tagline">The Complete Graph Data Science Platform</div>
63
  </div>
 
64
  <div className="entry-list">
65
  {list.error && <p className="error">{list.error.message}</p>}
66
- {list.isLoading &&
67
  <div className="loading spinner-border" role="status">
68
  <span className="visually-hidden">Loading...</span>
69
- </div>}
70
- {list.data &&
 
 
71
  <>
72
  <div className="actions">
73
- <a href={newWorkspaceIn(path || "", list.data)}><FilePlus /> New workspace</a>
74
- <a href="" onClick={() => newFolderIn(path || "", list.data)}><FolderPlus /> New folder</a>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  </div>
76
- {path && <div className="breadcrumbs"><a href="/dir/"><Home /></a> {path} </div>}
77
- {list.data.map((item: any) =>
78
- <a key={link(item)} className="entry" href={link(item)}>
79
- {item.type === 'directory' ? <Folder /> : <File />}
80
- {shortName(item)}
81
- </a>
 
 
82
  )}
 
 
 
 
 
 
 
 
 
 
 
 
83
  </>
84
- }
85
  </div>
86
  </div>
87
  );
 
1
  // The directory browser.
2
+ import { useParams, useNavigate } from "react-router";
3
+ import { useState } from "react";
4
  import useSWR from 'swr'
5
 
6
+
7
  import logo from './assets/logo.png';
8
  // @ts-ignore
9
  import Home from '~icons/tabler/home'
 
15
  import File from '~icons/tabler/file'
16
  // @ts-ignore
17
  import FilePlus from '~icons/tabler/file-plus'
18
+ // @ts-ignore
19
+ import Trash from '~icons/tabler/trash';
20
+
21
+
22
 
23
  const fetcher = (url: string) => fetch(url).then((res) => res.json());
24
 
25
  export default function () {
26
  const { path } = useParams();
27
  const encodedPath = encodeURIComponent(path || '');
28
+ let list = useSWR(`/api/dir/list?path=${encodedPath}`, fetcher);
29
+ const navigate = useNavigate();
30
+ const [isCreatingDir, setIsCreatingDir] = useState(false);
31
+ const [isCreatingWorkspace, setIsCreatingWorkspace] = useState(false);
32
+
33
+
34
  function link(item: any) {
35
  if (item.type === 'directory') {
36
  return `/dir/${item.name}`;
 
38
  return `/edit/${item.name}`;
39
  }
40
  }
41
+
42
  function shortName(item: any) {
43
  return item.name.split('/').pop();
44
  }
45
+
46
+ function newName(list: any[], baseName: string = "Untitled") {
47
  let i = 0;
48
  while (true) {
49
+ const name = `${baseName}${i ? ` ${i}` : ''}`;
50
  if (!list.find(item => item.name === name)) {
51
  return name;
52
  }
53
  i++;
54
  }
55
  }
56
+
57
+ function newWorkspaceIn(path: string, list: any[], workspaceName?: string) {
58
+ const pathSlash = path ? `${path}/` : "";
59
+ const name = workspaceName || newName(list);
60
+ navigate(`/edit/${pathSlash}${name}`, {replace: true});
61
  }
62
+
63
+
64
+ async function newFolderIn(path: string, list: any[], folderName?: string) {
65
+ const name = folderName || newName(list, "New Folder");
66
+ const pathSlash = path ? `${path}/` : "";
67
+
68
  const res = await fetch(`/api/dir/mkdir`, {
69
  method: 'POST',
70
  headers: { 'Content-Type': 'application/json' },
71
  body: JSON.stringify({ path: pathSlash + name }),
72
  });
73
  list = await res.json();
74
+ if (res.ok) {
75
+ navigate(`/dir/${pathSlash}${name}`);
76
+ } else {
77
+ alert("Failed to create folder.");
78
+ }
79
+ }
80
+
81
+ async function deleteItem(item: any) {
82
+ if (!window.confirm(`Are you sure you want to delete "${item.name}"?`)) return;
83
+ const pathSlash = path ? `${path}/` : "";
84
+
85
+ const apiPath = item.type === "directory" ? `/api/dir/delete`: `/api/delete`;
86
+ const res = await fetch(apiPath, {
87
+ method: "POST",
88
+ headers: { "Content-Type": "application/json" },
89
+ body: JSON.stringify({ path: pathSlash + item.name }),
90
+ });
91
+ list = await res.json();
92
  }
93
 
94
  return (
95
  <div className="directory">
96
  <div className="logo">
97
+ <a href="https://lynxkite.com/">
98
+ <img src={logo} className="logo-image" alt="LynxKite logo" />
99
+ </a>
100
  <div className="tagline">The Complete Graph Data Science Platform</div>
101
  </div>
102
+
103
  <div className="entry-list">
104
  {list.error && <p className="error">{list.error.message}</p>}
105
+ {list.isLoading && (
106
  <div className="loading spinner-border" role="status">
107
  <span className="visually-hidden">Loading...</span>
108
+ </div>
109
+ )}
110
+
111
+ {list.data && (
112
  <>
113
  <div className="actions">
114
+ <div className="new-workspace">
115
+ {isCreatingWorkspace &&
116
+ // @ts-ignore
117
+ <form onSubmit={(e) => {e.preventDefault(); newWorkspaceIn(path || "", list.data, e.target.workspaceName.value.trim())}}>
118
+ <input
119
+ type="text"
120
+ name="workspaceName"
121
+ defaultValue={newName(list.data)}
122
+ placeholder={newName(list.data)}
123
+ />
124
+ </form>
125
+ }
126
+ <button type="button" onClick={() => setIsCreatingWorkspace(true)}>
127
+ <FolderPlus /> New workspace
128
+ </button>
129
+ </div>
130
+
131
+ <div className="new-folder">
132
+ {isCreatingDir &&
133
+ // @ts-ignore
134
+ <form onSubmit={(e) =>{e.preventDefault(); newFolderIn(path || "", list.data, e.target.folderName.value.trim())}}>
135
+ <input
136
+ type="text"
137
+ name="folderName"
138
+ defaultValue={newName(list.data)}
139
+ placeholder={newName(list.data)}
140
+ />
141
+ </form>
142
+ }
143
+ <button type="button" onClick={() => setIsCreatingDir(true)}>
144
+ <FolderPlus /> New folder
145
+ </button>
146
+ </div>
147
  </div>
148
+
149
+ {path && (
150
+ <div className="breadcrumbs">
151
+ <a href="/dir/">
152
+ <Home />
153
+ </a>{" "}
154
+ <span className="current-folder">{path}</span>
155
+ </div>
156
  )}
157
+
158
+ {list.data.map((item: any) => (
159
+ <div key={item.name} className="entry">
160
+ <a key={link(item)} className="entry" href={link(item)}>
161
+ {item.type === 'directory' ? <Folder /> : <File />}
162
+ {shortName(item)}
163
+ </a>
164
+ <button onClick={() => { deleteItem(item) }}>
165
+ <Trash />
166
+ </button>
167
+ </div>
168
+ ))}
169
  </>
170
+ )}
171
  </div>
172
  </div>
173
  );
lynxkite-app/web/src/index.css CHANGED
@@ -285,13 +285,15 @@ body {
285
  }
286
 
287
  .entry-list .entry {
288
- display: block;
289
  border-bottom: 1px solid whitesmoke;
290
  padding-left: 10px;
291
  color: #004165;
292
  cursor: pointer;
293
  user-select: none;
294
  text-decoration: none;
 
 
295
  }
296
 
297
  .entry-list .open .entry,
 
285
  }
286
 
287
  .entry-list .entry {
288
+ display: flex;
289
  border-bottom: 1px solid whitesmoke;
290
  padding-left: 10px;
291
  color: #004165;
292
  cursor: pointer;
293
  user-select: none;
294
  text-decoration: none;
295
+ justify-content: space-between;
296
+ padding-right: 10px;
297
  }
298
 
299
  .entry-list .open .entry,
lynxkite-app/web/tests/basic.spec.ts CHANGED
@@ -1,17 +1,21 @@
1
  // Tests some basic operations like box creation, deletion, and dragging.
2
  import { test, expect } from '@playwright/test';
3
- import { Workspace } from './lynxkite';
4
 
5
 
6
  let workspace: Workspace;
7
 
8
 
9
  test.beforeEach(async ({ browser }) => {
10
- workspace = await Workspace.empty(await browser.newPage());
11
- await workspace.setEnv('PyTorch model'); // Workaround until we fix the default environment
12
- await workspace.setEnv('LynxKite Graph Analytics');
13
  });
14
 
 
 
 
 
 
 
15
 
16
  test('Box creation & deletion per env', async () => {
17
  const envs = await workspace.getEnvs();
 
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();
lynxkite-app/web/tests/directory.spec.ts ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ await splash.deleteEntry(workspace.name);
24
+ });
25
+
26
+ test('Create & delete workspace', async () => {
27
+ const workspaceName = `TestWorkspace-${Date.now()}`;
28
+ const workspace = await Workspace.empty(splash.page, workspaceName);
29
+ await workspace.expectCurrentWorkspaceIs(workspaceName);
30
+ // Add a box so the workspace is saved
31
+ await workspace.addBox('Import Parquet');
32
+ await workspace.close();
33
+ await splash.deleteEntry(workspaceName);
34
+ await expect(splash.getEntry(workspaceName)).not.toBeVisible();
35
+ });
36
+
37
+ test('Create & delete folder', async () => {
38
+ const folderName = `TestFolder-${Date.now()}`;
39
+ await splash.createFolder(folderName);
40
+ await expect(splash.currentFolder()).toHaveText(folderName);
41
+ await splash.goHome();
42
+ await splash.deleteEntry(folderName);
43
+ await expect(splash.getEntry(folderName)).not.toBeVisible();
44
+ });
45
+
46
+ test('Create folder with default name', async () => {
47
+ await splash.createFolder();
48
+ await expect(splash.currentFolder()).toContainText('Untitled');
49
+ });
50
+ });
51
+
52
+
53
+ test.describe.serial('Nested folders & workspaces operations', () => {
54
+
55
+ let splash;
56
+
57
+ test.beforeEach(() => {
58
+ // Nested navigation doesn't work yet
59
+ test.skip();
60
+ });
61
+
62
+ test.beforeAll(async ({ browser }) => {
63
+ const page = await browser.newPage()
64
+ // To make deletion confirmation dialog to be automatically accepted
65
+ page.on('dialog', async dialog => { await dialog.accept(); });
66
+ splash = await Splash.open(page);
67
+ await splash.createFolder('TestFolder');
68
+ });
69
+
70
+ test.afterAll(async () => {
71
+ //cleanup
72
+ test.skip();
73
+ await splash.goHome();
74
+ await splash.deleteEntry('TestFolder');
75
+ });
76
+
77
+ test('Create nested folder', async () => {
78
+ await splash.createFolder('TestFolder2');
79
+ await expect(splash.currentFolder()).toHaveText('TestFolder2');
80
+ await splash.toParent();
81
+ });
82
+
83
+ test('Delete nested folder', async () => {
84
+ await splash.deleteEntry('TestFolder2');
85
+ await expect(splash.getEntry('TestFolder2')).not.toBeVisible();
86
+ });
87
+
88
+ test('Create nested workspace', async () => {
89
+ const workspace = splash.createWorkspace('TestWorkspace');
90
+ await workspace.expectCurrentWorkspaceIs('TestWorkspace');
91
+ await workspace.close();
92
+ });
93
+
94
+ test('Delete nested workspace', async () => {
95
+ await splash.deleteEntry('TestWorkspace');
96
+ await expect(splash.getEntry('TestWorkspace')).not.toBeVisible();
97
+ });
98
+ });
lynxkite-app/web/tests/errors.spec.ts CHANGED
@@ -1,15 +1,20 @@
1
  // Tests error reporting.
2
  import { test, expect } from '@playwright/test';
3
- import { Workspace } from './lynxkite';
4
 
5
 
6
  let workspace: Workspace;
7
 
8
 
9
  test.beforeEach(async ({ browser }) => {
10
- workspace = await Workspace.empty(await browser.newPage());
11
- await workspace.setEnv('PyTorch model'); // Workaround until we fix the default environment
12
- await workspace.setEnv('LynxKite Graph Analytics');
 
 
 
 
 
13
  });
14
 
15
 
 
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
 
lynxkite-app/web/tests/examples.spec.ts CHANGED
@@ -42,6 +42,14 @@ test.fail('RAG chatbot app', async ({ page }) => {
42
  });
43
 
44
 
 
 
 
 
 
 
 
 
45
  test('Pillow example', async ({ page }) => {
46
  const ws = await Workspace.open(page, "Image processing");
47
  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()).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();
lynxkite-app/web/tests/lynxkite.ts CHANGED
@@ -13,15 +13,17 @@ export const ROOT = 'automated-tests';
13
 
14
  export class Workspace {
15
  readonly page: Page;
 
16
 
17
- constructor(page: Page) {
18
  this.page = page;
 
19
  }
20
 
21
  // Starts with a brand new workspace.
22
  static async empty(page: Page, workspaceName?: string): Promise<Workspace> {
23
  const splash = await Splash.open(page);
24
- return await splash.openNewWorkspace(workspaceName ?? 'test-example');
25
  }
26
 
27
  static async open(page: Page, workspaceName: string): Promise<Workspace> {
@@ -31,7 +33,7 @@ export class Workspace {
31
  await ws.expectCurrentWorkspaceIs(workspaceName);
32
  return ws
33
  }
34
-
35
  async getEnvs() {
36
  // Return all available workspace environments
37
  return await this.page.locator('select[name="workspace-env"] option').allInnerTexts();
@@ -66,7 +68,7 @@ export class Workspace {
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.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));
@@ -138,6 +140,10 @@ export class Workspace {
138
  }
139
  return true;
140
  }
 
 
 
 
141
  }
142
 
143
 
@@ -166,15 +172,53 @@ export class Splash {
166
  return this.page.getByRole('link', { name: name });
167
  }
168
 
169
- async openNewWorkspace(name: string) {
170
- // TODO: Support workspace naming
171
- await this.page.getByRole('link', { name: 'New workspace' }).click();
172
- const ws = new Workspace(this.page);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  return ws;
174
  }
175
 
176
  async openWorkspace(name: string) {
177
  await this.workspace(name).click();
178
- return new Workspace(this.page);
179
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  }
 
13
 
14
  export class Workspace {
15
  readonly page: Page;
16
+ name: string;
17
 
18
+ constructor(page: Page, workspaceName: string) {
19
  this.page = page;
20
+ this.name = workspaceName;
21
  }
22
 
23
  // Starts with a brand new workspace.
24
  static async empty(page: Page, workspaceName?: string): Promise<Workspace> {
25
  const splash = await Splash.open(page);
26
+ return await splash.createWorkspace(workspaceName);
27
  }
28
 
29
  static async open(page: Page, workspaceName: string): Promise<Workspace> {
 
33
  await ws.expectCurrentWorkspaceIs(workspaceName);
34
  return ws
35
  }
36
+
37
  async getEnvs() {
38
  // Return all available workspace environments
39
  return await this.page.locator('select[name="workspace-env"] option').allInnerTexts();
 
68
 
69
  // Some x,y offset, otherwise the box handle may fall outside the viewport.
70
  await this.page.locator('.react-flow__pane').click({ position: { x: 20, y: 20 }});
71
+ await this.page.locator('.node-search').getByText(boxName).click();
72
  await this.page.keyboard.press('Escape');
73
  // Workaround to wait for the deselection animation after choosing a box. Otherwise, the next box will not be added.
74
  await new Promise(resolve => setTimeout(resolve, 200));
 
140
  }
141
  return true;
142
  }
143
+
144
+ async close() {
145
+ await this.page.locator('a[href="/dir/"]').click();
146
+ }
147
  }
148
 
149
 
 
172
  return this.page.getByRole('link', { name: name });
173
  }
174
 
175
+ getEntry(name: string) {
176
+ return this.page.locator('.entry').filter({ hasText: name }).first();
177
+ }
178
+
179
+ async createWorkspace(name?: string) {
180
+ await this.page.getByRole('button', { name: 'New workspace' }).click();
181
+ await this.page.locator('input[name="workspaceName"]').click();
182
+ let workspaceName: string;
183
+ if (name) {
184
+ workspaceName = name;
185
+ await this.page.locator('input[name="workspaceName"]').fill(name);
186
+ } else {
187
+ workspaceName = await this.page.locator('input[name="workspaceName"]').inputValue();
188
+ }
189
+ await this.page.locator('input[name="workspaceName"]').press('Enter');
190
+ const ws = new Workspace(this.page, workspaceName);
191
+ // Workaround until we fix the default environment
192
+ await ws.setEnv('PyTorch model');
193
+ await ws.setEnv('LynxKite Graph Analytics');
194
  return ws;
195
  }
196
 
197
  async openWorkspace(name: string) {
198
  await this.workspace(name).click();
199
+ return new Workspace(this.page, name);
200
  }
201
+
202
+ async createFolder(folderName?: string) {
203
+ await this.page.getByRole('button', { name: 'New folder' }).click();
204
+ await this.page.locator('input[name="folderName"]').click();
205
+ if (folderName) {
206
+ await this.page.locator('input[name="folderName"]').fill(folderName);
207
+ }
208
+ await this.page.locator('input[name="folderName"]').press('Enter');
209
+ }
210
+
211
+ async deleteEntry(entryName: string) {
212
+ await this.getEntry(entryName).locator('button').click();
213
+ await this.page.reload();
214
+ }
215
+
216
+ currentFolder() {
217
+ return this.page.locator('.current-folder');
218
+ }
219
+
220
+ async goHome() {
221
+ await this.page.locator('a[href="/dir/"]').click();
222
+ }
223
+
224
  }
lynxkite-app/web/tests/upload.spec.ts CHANGED
@@ -1,6 +1,6 @@
1
  // Test uploading a file in an import box.
2
  import { test, expect } from '@playwright/test';
3
- import { Workspace } from './lynxkite';
4
  import { join, dirname } from 'path';
5
  import { fileURLToPath } from 'url';
6
 
@@ -9,9 +9,14 @@ let workspace: Workspace;
9
 
10
 
11
  test.beforeEach(async ({ browser }) => {
12
- workspace = await Workspace.empty(await browser.newPage());
13
- await workspace.setEnv('PyTorch model'); // Workaround until we fix the default environment
14
- await workspace.setEnv('LynxKite Graph Analytics');
 
 
 
 
 
15
  });
16
 
17
 
 
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
 
 
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