Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
add tests
  • Loading branch information
mjren23 committed Oct 11, 2023
commit 20e74ef7e691a17fc1e1c8cc5353f17fc21beff9
1,954 changes: 1,877 additions & 77 deletions polynote-frontend/polynote/ui/component/__snapshots__/notebooklist.test.ts.snap

Large diffs are not rendered by default.

140 changes: 98 additions & 42 deletions polynote-frontend/polynote/ui/component/notebooklist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const socketHandler = SocketStateHandler.create(mockSocket);
const dispatcher = new ServerMessageDispatcher(socketHandler);
const receiver = new ServerMessageReceiver();


let currentNotebookPath = "";

test('A LeafComponent should dispatch a LoadNotebook when clicked', done => {
const leaf = {
Expand Down Expand Up @@ -58,6 +58,8 @@ describe("BranchComponent", () => {
isOrHasCurrentNotebook: false,
children: {}
});
currentNotebookPath = "nonsense.ipynb";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this necessary?


const branch = new BranchEl(dispatcher, branchState);
expect(branch.childrenEl).toBeEmptyDOMElement();

Expand Down Expand Up @@ -144,28 +146,29 @@ test("A BranchHandler should build a tree out of paths", () => {

// first add some notebooks at root, easy peasy.
const simpleNBs = ["foo.ipynb", "bar.ipynb", "baz.ipynb"];
currentNotebookPath = "foo.ipynb";
simpleNBs.forEach(nb => branchHandler.addPath(nb, 0, currentNotebookPath));
expect(Object.values(branchHandler.state.children)).toEqual([
{fullPath: "foo.ipynb", lastSaved: 0, value: "foo.ipynb"},
{fullPath: "bar.ipynb", lastSaved: 0, value: "bar.ipynb"},
{fullPath: "baz.ipynb", lastSaved: 0, value: "baz.ipynb"},
{fullPath: "foo.ipynb", lastSaved: 0, value: "foo.ipynb", isOrHasCurrentNotebook: true},
{fullPath: "bar.ipynb", lastSaved: 0, value: "bar.ipynb", isOrHasCurrentNotebook: false},
{fullPath: "baz.ipynb", lastSaved: 0, value: "baz.ipynb", isOrHasCurrentNotebook: false},
]);
expect(tree.el.children).toHaveLength(3);

// next we will add a few directories
const dirNBs = ["dir/one.ipynb", "dir/two.ipynb", "dir2/three.ipynb", "dir/four.ipynb"];
dirNBs.forEach(nb => branchHandler.addPath(nb, 0, currentNotebookPath));
expect(Object.values(branchHandler.state.children)).toEqual([
{fullPath: "foo.ipynb", lastSaved: 0, value: "foo.ipynb"},
{fullPath: "bar.ipynb", lastSaved: 0, value: "bar.ipynb"},
{fullPath: "baz.ipynb", lastSaved: 0, value: "baz.ipynb"},
{fullPath: "dir", value: "dir", children: {
"dir/one.ipynb": {fullPath: "dir/one.ipynb", lastSaved: 0, value: "one.ipynb"},
"dir/two.ipynb": {fullPath: "dir/two.ipynb", lastSaved: 0, value: "two.ipynb"},
"dir/four.ipynb": {fullPath: "dir/four.ipynb", lastSaved: 0, value: "four.ipynb"},
{fullPath: "foo.ipynb", lastSaved: 0, value: "foo.ipynb", isOrHasCurrentNotebook: true},
{fullPath: "bar.ipynb", lastSaved: 0, value: "bar.ipynb", isOrHasCurrentNotebook: false},
{fullPath: "baz.ipynb", lastSaved: 0, value: "baz.ipynb", isOrHasCurrentNotebook: false},
{fullPath: "dir", value: "dir", isOrHasCurrentNotebook: false, children: {
"dir/one.ipynb": {fullPath: "dir/one.ipynb", lastSaved: 0, value: "one.ipynb", isOrHasCurrentNotebook: false},
"dir/two.ipynb": {fullPath: "dir/two.ipynb", lastSaved: 0, value: "two.ipynb", isOrHasCurrentNotebook: false},
"dir/four.ipynb": {fullPath: "dir/four.ipynb", lastSaved: 0, value: "four.ipynb", isOrHasCurrentNotebook: false},
}},
{fullPath: "dir2", value: "dir2", children: {
"dir2/three.ipynb": {fullPath: "dir2/three.ipynb", lastSaved: 0, value: "three.ipynb"},
{fullPath: "dir2", value: "dir2", isOrHasCurrentNotebook: false, children: {
"dir2/three.ipynb": {fullPath: "dir2/three.ipynb", lastSaved: 0, value: "three.ipynb", isOrHasCurrentNotebook: false},
}}
]);
expect(tree.el.children).toHaveLength(5);
Expand All @@ -176,9 +179,6 @@ test("A BranchHandler should build a tree out of paths", () => {
const dir2 = [...branches].find((b: HTMLElement) => queryByText(b, "dir2"))!.querySelector("ul")!;
expect(dir.children).toHaveLength(3);
expect(dir2.children).toHaveLength(1);

const currentNotebookPath = "dir/1/2/3/4/surprisinglydeep.ipynb";

// next let's go nuts with some nested notebooks!
branchHandler.addPath("dir/another.ipynb", 0, currentNotebookPath);
branchHandler.addPath("dir/newdir/more.ipynb", 0, currentNotebookPath);
Expand All @@ -187,38 +187,38 @@ test("A BranchHandler should build a tree out of paths", () => {
branchHandler.addPath("dir/1/2/oh_my.ipynb", 0, currentNotebookPath);
branchHandler.addPath("path/to/my/notebook.ipynb", 0, currentNotebookPath);
expect(branchHandler.state.children).toEqual({
"foo.ipynb": {fullPath: "foo.ipynb", lastSaved: 0, value: "foo.ipynb"},
"bar.ipynb": {fullPath: "bar.ipynb", lastSaved: 0, value: "bar.ipynb"},
"baz.ipynb": {fullPath: "baz.ipynb", lastSaved: 0, value: "baz.ipynb"},
"dir": {fullPath: "dir", value: "dir", children: {
"dir/one.ipynb": {fullPath: "dir/one.ipynb", lastSaved: 0, value: "one.ipynb"},
"dir/two.ipynb": {fullPath: "dir/two.ipynb", lastSaved: 0, value: "two.ipynb"},
"dir/four.ipynb": {fullPath: "dir/four.ipynb", lastSaved: 0, value: "four.ipynb"},
"dir/another.ipynb": {fullPath: "dir/another.ipynb", lastSaved: 0, value: "another.ipynb"},
"dir/newdir": {fullPath: "dir/newdir", value: "newdir", children: {
"dir/newdir/more.ipynb": {fullPath: "dir/newdir/more.ipynb", lastSaved: 0, value: "more.ipynb"},
"dir/newdir/newer": {fullPath: "dir/newdir/newer", value: "newer", children: {
"dir/newdir/newer/even_more.ipynb": {fullPath: "dir/newdir/newer/even_more.ipynb", lastSaved: 0, value: "even_more.ipynb"},
"foo.ipynb": {fullPath: "foo.ipynb", lastSaved: 0, value: "foo.ipynb", isOrHasCurrentNotebook: true},
"bar.ipynb": {fullPath: "bar.ipynb", lastSaved: 0, value: "bar.ipynb", isOrHasCurrentNotebook: false},
"baz.ipynb": {fullPath: "baz.ipynb", lastSaved: 0, value: "baz.ipynb", isOrHasCurrentNotebook: false},
"dir": {fullPath: "dir", value: "dir", isOrHasCurrentNotebook: false, children: {
"dir/one.ipynb": {fullPath: "dir/one.ipynb", lastSaved: 0, value: "one.ipynb", isOrHasCurrentNotebook: false},
"dir/two.ipynb": {fullPath: "dir/two.ipynb", lastSaved: 0, value: "two.ipynb", isOrHasCurrentNotebook: false},
"dir/four.ipynb": {fullPath: "dir/four.ipynb", lastSaved: 0, value: "four.ipynb", isOrHasCurrentNotebook: false},
"dir/another.ipynb": {fullPath: "dir/another.ipynb", lastSaved: 0, value: "another.ipynb", isOrHasCurrentNotebook: false},
"dir/newdir": {fullPath: "dir/newdir", value: "newdir", isOrHasCurrentNotebook: false, children: {
"dir/newdir/more.ipynb": {fullPath: "dir/newdir/more.ipynb", lastSaved: 0, value: "more.ipynb", isOrHasCurrentNotebook: false},
"dir/newdir/newer": {fullPath: "dir/newdir/newer", value: "newer", isOrHasCurrentNotebook: false, children: {
"dir/newdir/newer/even_more.ipynb": {fullPath: "dir/newdir/newer/even_more.ipynb", lastSaved: 0, value: "even_more.ipynb", isOrHasCurrentNotebook: false},
}},
}},
"dir/1": {fullPath: "dir/1", value: "1", children: {
"dir/1/2": {fullPath: "dir/1/2", value: "2", children: {
"dir/1/2/3": {fullPath: "dir/1/2/3", value: "3", children: {
"dir/1/2/3/4": {fullPath: "dir/1/2/3/4", value: "4", children: {
"dir/1/2/3/4/surprisinglydeep.ipynb": {fullPath: "dir/1/2/3/4/surprisinglydeep.ipynb", lastSaved: 0, value: "surprisinglydeep.ipynb"},
"dir/1": {fullPath: "dir/1", value: "1", isOrHasCurrentNotebook: false, children: {
"dir/1/2": {fullPath: "dir/1/2", value: "2", isOrHasCurrentNotebook: false, children: {
"dir/1/2/3": {fullPath: "dir/1/2/3", value: "3", isOrHasCurrentNotebook: false, children: {
"dir/1/2/3/4": {fullPath: "dir/1/2/3/4", value: "4", isOrHasCurrentNotebook: false, children: {
"dir/1/2/3/4/surprisinglydeep.ipynb": {fullPath: "dir/1/2/3/4/surprisinglydeep.ipynb", lastSaved: 0, value: "surprisinglydeep.ipynb", isOrHasCurrentNotebook: false},
}},
}},
"dir/1/2/oh_my.ipynb": {fullPath: "dir/1/2/oh_my.ipynb", lastSaved: 0, value: "oh_my.ipynb"},
"dir/1/2/oh_my.ipynb": {fullPath: "dir/1/2/oh_my.ipynb", lastSaved: 0, value: "oh_my.ipynb", isOrHasCurrentNotebook: false},
}},
}},
}},
"dir2": {fullPath: "dir2", value: "dir2", children: {
"dir2/three.ipynb": {fullPath: "dir2/three.ipynb", lastSaved: 0, value: "three.ipynb"},
"dir2": {fullPath: "dir2", value: "dir2", isOrHasCurrentNotebook: false, children: {
"dir2/three.ipynb": {fullPath: "dir2/three.ipynb", lastSaved: 0, value: "three.ipynb", isOrHasCurrentNotebook: false},
}},
"path": {fullPath: "path", value: "path", children: {
"path/to": {fullPath: "path/to", value: "to", children: {
"path/to/my": {fullPath: "path/to/my", value: "my", children: {
"path/to/my/notebook.ipynb": {fullPath: "path/to/my/notebook.ipynb", lastSaved: 0, value: "notebook.ipynb"},
"path": {fullPath: "path", value: "path", isOrHasCurrentNotebook: false, children: {
"path/to": {fullPath: "path/to", value: "to", isOrHasCurrentNotebook: false, children: {
"path/to/my": {fullPath: "path/to/my", value: "my", isOrHasCurrentNotebook: false, children: {
"path/to/my/notebook.ipynb": {fullPath: "path/to/my/notebook.ipynb", lastSaved: 0, value: "notebook.ipynb", isOrHasCurrentNotebook: false},
}},
}},
}},
Expand All @@ -227,6 +227,62 @@ test("A BranchHandler should build a tree out of paths", () => {
expect(tree.el.outerHTML).toMatchSnapshot()
});

describe("highlighting the current notebook and folder", () => {
const root = {
fullPath: "",
value: "",
lastSaved: 0,
isOrHasCurrentNotebook: true,
children: {}
};
const branchHandler = new BranchHandler(root);
const tree = new BranchEl(dispatcher, branchHandler);
currentNotebookPath = "foo.ipynb";

branchHandler.addPath(currentNotebookPath, 0, currentNotebookPath);
branchHandler.addPath("folder/bar.ipynb", 0, currentNotebookPath);

test("Current notebook is highlighted correctly", () => {
const notebookListEl = tree.el.querySelector(`a[href="notebooks/${currentNotebookPath}"]`);
expect(notebookListEl).not.toBeNull();
expect(notebookListEl?.classList).toContain("current-notebook");

// now switch to a nested notebook
currentNotebookPath = "folder/bar.ipynb";
branchHandler.updateCurrentNotebook(`${currentNotebookPath}`);
const nestedNotebookListEl = tree.el.querySelector(`a[href="notebooks/${currentNotebookPath}"]`);
if (nestedNotebookListEl == null) {
fail("the highlighted notebook was not found");
}

waitFor(() => {
// now, this element should be highlighted
expect(nestedNotebookListEl.classList).toContain("current-notebook")
})
.then(() => {
// the folder should also be highlighted because it is not expanded
const folderEl = tree.el.getElementsByClassName("has-current-notebook")[0];
if (folderEl == null) {
fail("the highlighting class was not set correctly");
}
// this folder should be the one that holds our current notebook
expect(folderEl.innerHTML).toContain("notebooks/folder/bar.ipynb");
});
});

test("Highlighting the path expands recursively to current notebook", () => {
branchHandler.addPath("folder/nested/baz.ipynb", 0, currentNotebookPath);
tree.highlightPath("folder/nested/baz.ipynb",0);

const expandedEls = tree.el.getElementsByClassName("expanded");
expect(expandedEls.length).toBe(2);
const outerFolderEl = expandedEls[0];
const nestedFolderEl = expandedEls[1];
expect(outerFolderEl).toContainHTML("<span class=\"name\">folder</span>");
expect(nestedFolderEl).toContainHTML("<span class=\"name\">nested</span>");
});
});

test("stress test", () => {
const root = {
fullPath: "",
Expand All @@ -237,10 +293,10 @@ test("stress test", () => {
};
const branchHandler = new BranchHandler(root);
const comp = new BranchEl(dispatcher, branchHandler);
const currentNotebookPath = "root/foo.ipynb";
const currentNotebookPath = "nonsensenonexistent.ipynb";
expect(branchHandler.state).toMatchSnapshot();

const max = 300;
const max = 600;
[...Array(max).keys()].map(x => {
let path = `root/${x}`;
if (x % 10) {
Expand Down
27 changes: 15 additions & 12 deletions polynote-frontend/polynote/ui/component/notebooklist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,14 +238,14 @@ export class NotebookList extends Disposable {
helpIconButton([], "https://polynote.org/latest/docs/notebooks-list/"),
]),
span(['right-buttons'], [
iconButton(['create-notebook'], 'Create new notebook', 'plus-circle', 'New').click(evt => {
evt.stopPropagation();
dispatcher.createNotebook()
}),
iconButton(['highlight-notebook'], 'Highlight opened notebook', 'plus-circle', 'Highlight').click(evt => {
iconButton(['highlight-notebook'], 'Find opened notebook', 'crosshairs', 'Highlight').click(evt => {
evt.stopPropagation();
this.tree.highlightPath(this.currentNotebook, 0);
}),
iconButton(['create-notebook'], 'Create new notebook', 'plus-circle', 'New').click(evt => {
evt.stopPropagation();
dispatcher.createNotebook()
})
])
]);

Expand All @@ -254,7 +254,7 @@ export class NotebookList extends Disposable {
value: "",
lastSaved: 0,
children: {},
isOrHasCurrentNotebook: false
isOrHasCurrentNotebook: true
});
this.tree = new BranchEl(dispatcher, treeState);

Expand Down Expand Up @@ -408,6 +408,7 @@ export class BranchHandler extends ObjectStateHandler<Branch> {
if (!currentState || !isBranch(currentState) || !currentState.children[piece]) {
currentUpdate.fullPath = currentPath;
currentUpdate.value = piece;
currentUpdate.isOrHasCurrentNotebook = currentNotebook === path;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does currentNotebook have to be updated here? I wonder whether it'd be updated anyways after the path is added (doesn't the state update selecting the current notebook happen after the state update adding a new path? Or am I misremembering?)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just stepping through in devtools, I'm seeing some instances where branchHandler.addPath gets called after branchHandler.updateCurrentNotebook - not sure where the logic for ordering the state updates lives but it doesn't seem like the behavior is always "add path and then update current notebook."

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Under what situation do you see that addPath gets called after updateCurrentNotebook? I wasn't able to reproduce.

The last step of the process to create a new notebook is to select it so I think things should happen in the order I mentioned earlier: https://github.com/polynote/polynote/blob/megan/highlight-current-notebook/polynote-frontend/polynote/messaging/dispatcher.ts#L379

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was able to kind of repro when renaming a notebook, I think. But it's hard to tell because we have a bug with that 😅 #1422

If that's the only way to repro I think we should fix that problem: in general, selecting the current notebook should be the very last thing that happens; and notebook shouldn't be selected before it "exists" if that makes sense.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like when reopening the current notebooks (e.g. create a notebook and refresh the page with the notebook still open), the frontend is loading the notebooks and then also getting a message from the server (of type ListNotebooks), triggering another state update and causing addPath to get called again.

currentState = undefined;
} else {
currentState = currentState.children[piece];
Expand Down Expand Up @@ -449,16 +450,18 @@ export class BranchHandler extends ObjectStateHandler<Branch> {
this.update(state => go(path, state))
}

// traverses the tree to update isOrHasCurrentNotebook for each Node so that elements
// can display the proper styling to highlight themselves
updateCurrentNotebook(currentNotebookPath: string) {
function go(fullPath: string, sliceIndex: number, parent: Branch): UpdatePartial<Branch> {
return {
children: Object.keys(parent.children).reduce((acc, key) => {
const branchOrLeaf = parent.children[key];
const currPath = getUpToNthOccurrence(fullPath, sliceIndex, "/");
const currPath = getUpToNthOccurrence(fullPath, sliceIndex + 1, "/");
if ("children" in branchOrLeaf) {
acc[key] = {
...go(fullPath, sliceIndex + 1, branchOrLeaf),
isOrHasCurrentNotebook: setValue(branchOrLeaf["fullPath"] === currPath)
isOrHasCurrentNotebook: setValue(branchOrLeaf["fullPath"] === currPath) // is this branch part of the path to the current notebook?
} // 'tis a branch
} else {
acc[key] = {
Expand Down Expand Up @@ -655,17 +658,17 @@ export class BranchEl extends Disposable {
* Recursively expand folders until the leaf node at the desired path is reached
*/
highlightPath(path: string, index: number) {
const currPath = getUpToNthOccurrence(path, index, "/");
const currPath = getUpToNthOccurrence(path, index + 1, "/");
const child = this.children.find(c => c.path === currPath);
if (child === null) {
return
}
this.expanded = true; // expand the current folder
this.expanded = index != 0; // expand the current folder, but not at the root level
if (child instanceof BranchEl) {
child.highlightPath(path, index + 1);
}
else if (child instanceof LeafEl) { // scroll to this notebook
child.focus();
else if (child instanceof LeafEl) {
child.focus(); // scroll to this notebook
}
}

Expand Down
16 changes: 16 additions & 0 deletions polynote-frontend/polynote/util/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Deferred,
diffArray,
equalsByKey,
getUpToNthOccurrence,
isEmpty,
isObject,
linePosAt,
Expand Down Expand Up @@ -475,6 +476,21 @@ describe("arrayStartsWith", () => {
})
})

describe("getUpToNthOccurrence", () => {
it("returns a substring up to the nth occurrence of a pattern", () => {
expect(getUpToNthOccurrence("hello!world!extra", 1, "!")).toEqual("hello");
expect(getUpToNthOccurrence("hello!world!extra", 2, "!")).toEqual("hello!world");
});
it("returns the correct substring for longer patterns", () => {
expect(getUpToNthOccurrence("hello##world##extra", 2, "##")).toEqual("hello##world");
});
it("handles edge case parameters gracefully", () => {
expect(getUpToNthOccurrence("string", 1, "nonexistent")).toEqual("string");
expect(getUpToNthOccurrence("string", -1, "nonexistent")).toEqual("");
expect(getUpToNthOccurrence("string", 100, "nonexistent")).toEqual("string");
});
});

describe("parseQuotedArgs", () => {
it ("parses simple space-separated arguments", () => {
expect(parseQuotedArgs(`first second third`)).toEqual(['first', 'second', 'third'])
Expand Down
29 changes: 13 additions & 16 deletions polynote-frontend/polynote/util/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,26 +354,23 @@ export function positionIn(str: string, line: number, column: number): number {
}

/**
* Helper method for grabbing everything in a string up to the (n + 1)th occurrence of the pattern.
* Zero-indexed - n = 0 will return everything up to the first occurrence, n = 1 will return everything up to
* the second, including the first occurrence in the return string
* Helper method for grabbing everything in a string up to the nth occurrence of the pattern.
* Will only return a nonempty string for n >= 1.
* E.g. if called with "hello/world" where n = 1 and pattern = "/", will return "hello"
*/
export function getUpToNthOccurrence(str: string, n: number, pattern: string): string {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's different? What I was specifically looking for (and which I didn't make very clear from the comment) is to also return the pattern in the string. Traversing the file tree, I needed to get everything up to a certain slash, so for example, wanting to get the first few folders in a path like root/folder/inner-folder/file.ipynb, we might want root/folder or root/folder/inner-folder.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I'm missing something. I think this does what you want?
image

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.....oops, yeah, that does exactly what I want. Will refactor, thank you!

let count = 0;
const pieces = str.split(pattern);
if (n > pieces.length) {
return str;
}
let i = 0;
while (i < str.length) {
if (str[i] === pattern) {
count++;
i += pattern.length;
}
else {
i++;
}
if (n < count) {
return str.slice(0, i - 1);
}
let res = "";
while (i < pieces.length && i < n) {
res += pieces[i];
res += i < n - 1 ? pattern : "";
i++;
}
return str;
return res;
}

//****************
Expand Down
Loading