Skip to content

Commit 953f5e8

Browse files
andrewfulton9peytondmurraykrassowski
authored
Save as improvements (#267)
* infers save path and creates new directories * update html body attributes * cleanup * remove whitespace * error handling and testing * update save_as_notebook test * fix formatting * formatting * update test * Fix lint errors * Fix usage of test function; reduce timeout from 15min -> 2min; remove problematic arrow function * Fix playwright tests * Cleanup `show_screenshot` and `breakpoint`s * Add a few waits to allow the frontend to respond in end-to-end tests * Skip test_save_as_notebook on darwin * Bump the e2e post-click sleep to 3s --------- Co-authored-by: pdmurray <[email protected]> Co-authored-by: Michał Krassowski <[email protected]>
1 parent f7249ac commit 953f5e8

File tree

5 files changed

+211
-23
lines changed

5 files changed

+211
-23
lines changed

nbclassic/static/base/js/utils.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1063,13 +1063,16 @@ define([
10631063
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
10641064
THE SOFTWARE.
10651065
**/
1066+
var log10 = function(x) {
1067+
return Math.log(x) / Math.LN10;
1068+
};
10661069
var format_filesize = function(num) {
10671070
if (num === undefined || num === null)
10681071
return;
10691072

10701073
var UNITS = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
10711074

1072-
if (!Number.isFinite(num)) {
1075+
if (!(typeof num === 'number' && isFinite(num))) {
10731076
console.error("Expected finite number, got ", typeof(num) + ": " + num);
10741077
}
10751078

@@ -1083,12 +1086,12 @@ define([
10831086
return (neg ? '-' : '') + num + ' B';
10841087
}
10851088

1086-
var exponent = Math.min(Math.floor(Math.log10(num) / 3), UNITS.length - 1);
1089+
var exponent = Math.min(Math.floor(log10(num) / 3), UNITS.length - 1);
10871090
var numStr = Number((num / Math.pow(1000, exponent)).toPrecision(3));
10881091
var unit = UNITS[exponent];
10891092

10901093
return (neg ? '-' : '') + numStr + ' ' + unit;
1091-
}
1094+
};
10921095

10931096
// javascript stores text as utf16 and string indices use "code units",
10941097
// which stores high-codepoint characters as "surrogate pairs",

nbclassic/static/notebook/js/notebook.js

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2866,6 +2866,7 @@ define([
28662866

28672867
Notebook.prototype.save_notebook_as = function() {
28682868
var that = this;
2869+
var current_notebook_name = $('body').attr('data-notebook-name');
28692870
var current_dir = $('body').attr('data-notebook-path').split('/').slice(0, -1).join("/");
28702871
current_dir = current_dir? current_dir + "/": "";
28712872
current_dir = decodeURIComponent(current_dir);
@@ -2893,12 +2894,11 @@ define([
28932894
var nb_path = d.find('input').val();
28942895
var nb_name = nb_path.split('/').slice(-1).pop();
28952896
if (!nb_name) {
2896-
$(".save-message").html(
2897-
$("<span>")
2898-
.attr("style", "color:red;")
2899-
.text($(".save-message").text())
2900-
);
2901-
return false;
2897+
// infer notebook name from current file
2898+
nb_name = current_notebook_name;
2899+
var path = nb_path.split('/').slice(0, -1);
2900+
path.push(current_notebook_name);
2901+
var nb_path = path.join('/');
29022902
}
29032903
// If notebook name does not contain extension '.ipynb' add it
29042904
var ext = utils.splitext(nb_name)[1];
@@ -2919,6 +2919,8 @@ define([
29192919
that.writable = true;
29202920
that.notebook_name = data.name;
29212921
that.notebook_path = data.path;
2922+
document.body.setAttribute('data-notebook-path', that.notebook_path);
2923+
document.body.setAttribute('data-notebook-name', that.notebook_name);
29222924
that.session.rename_notebook(data.path);
29232925
that.events.trigger('notebook_renamed.Notebook', data);
29242926
that.save_notebook_success(start, data);
@@ -2931,6 +2933,29 @@ define([
29312933
);
29322934
});
29332935
};
2936+
var getParentPath = function(path) {
2937+
return path.split('/').slice(0, -1).join('/');
2938+
};
2939+
function makeParentDirectory(path) {
2940+
var parent_path = getParentPath(path);
2941+
var check_parent = that.contents.get(parent_path, {type: 'directory', content: false});
2942+
var recursed = check_parent.catch(
2943+
function() {
2944+
return makeParentDirectory(parent_path);
2945+
}
2946+
);
2947+
return Promise.allSettled([check_parent, recursed]).then(
2948+
function() {
2949+
model = {
2950+
'type': 'directory',
2951+
'name': '',
2952+
'path': utils.url_path_split(path)[0]
2953+
};
2954+
return that.contents.save(path, model).catch();
2955+
}
2956+
);
2957+
};
2958+
29342959
that.contents.get(nb_path, {type: 'notebook', content: false}).then(function(data) {
29352960
var warning_body = $('<div/>').append(
29362961
$("<p/>").text(i18n.msg._('Notebook with that name exists.')));
@@ -2946,8 +2971,41 @@ define([
29462971
}
29472972
}
29482973
});
2949-
}, function(err) {
2950-
return save_thunk();
2974+
}, function() {
2975+
var nb_path_parent = getParentPath(nb_path);
2976+
that.contents.get(nb_path_parent, {type: 'directory', content: false}).then(
2977+
save_thunk,
2978+
function() {
2979+
var warning_body = $('<div/>').append(
2980+
$("<p/>").text(i18n.msg._('Create missing directory?'))
2981+
);
2982+
dialog.modal({
2983+
title: 'Directory does not exist',
2984+
body: warning_body,
2985+
buttons: {
2986+
Cancel: {},
2987+
Create: {
2988+
class: 'btn-warning',
2989+
click: function() {
2990+
makeParentDirectory(nb_path_parent).then(
2991+
save_thunk
2992+
).catch(
2993+
function(error) {
2994+
var msg = i18n.msg._(error.message || 'Unknown error creating directory.');
2995+
$(".save-message").html(
2996+
$("<span>")
2997+
.attr("style", "color:red;")
2998+
.text(msg)
2999+
);
3000+
}
3001+
);
3002+
}
3003+
}
3004+
}
3005+
});
3006+
}
3007+
);
3008+
29513009
});
29523010
return false;
29533011
}
@@ -2961,7 +3019,7 @@ define([
29613019
}
29623020
});
29633021
d.find('input[type="text"]').val(current_dir).focus();
2964-
}
3022+
}
29653023
});
29663024
};
29673025

@@ -3099,6 +3157,9 @@ define([
30993157
function (json) {
31003158
that.notebook_name = json.name;
31013159
that.notebook_path = json.path;
3160+
document.body.setAttribute('data-notebook-path', that.notebook_path);
3161+
document.body.setAttribute('data-notebook-name', that.notebook_name);
3162+
31023163
that.last_modified = new Date(json.last_modified);
31033164
// debug 484
31043165
that._last_modified = json.last_modified;

nbclassic/tests/end_to_end/test_save_as_notebook.py

Lines changed: 122 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
"""Test save-as functionality"""
22

3-
3+
import sys
4+
import time
45
import traceback
6+
from functools import partial
7+
8+
import pytest
59

610
from .utils import EDITOR_PAGE, EndToEndTimeout
711

@@ -21,8 +25,9 @@ def set_notebook_name(nb, name):
2125
nb.evaluate(JS, page=EDITOR_PAGE)
2226

2327

28+
@pytest.mark.skipif(sys.platform == 'darwin', reason="fails randomly on osx")
2429
def test_save_as_nb(notebook_frontend):
25-
print('[Test] [test_save_readonly_as]')
30+
print('[Test] [test_save_as_notebook]')
2631
notebook_frontend.wait_for_kernel_ready()
2732
notebook_frontend.wait_for_selector(".input", page=EDITOR_PAGE)
2833

@@ -50,17 +55,15 @@ def test_save_as_nb(notebook_frontend):
5055
name_input_element = notebook_frontend.locate('.modal-body .form-control', page=EDITOR_PAGE)
5156
name_input_element.focus()
5257
name_input_element.click()
53-
notebook_name = 'new_notebook.ipynb'
5458

55-
print('[Test] Begin attempts to fill the save dialog input and save the notebook')
5659
fill_attempts = 0
5760

58-
def attempt_form_fill_and_save():
61+
def attempt_form_fill_and_save(notebook_path):
5962
# Application behavior here is HIGHLY variable, we use this for repeated attempts
6063
# ....................
6164
# This may be a retry, check if the application state reflects a successful save operation
6265
nonlocal fill_attempts
63-
if fill_attempts and get_notebook_name(notebook_frontend) == "new_notebook.ipynb":
66+
if fill_attempts and get_notebook_name(notebook_frontend) == notebook_path.split("/")[-1]:
6467
print('[Test] Success from previous save attempt!')
6568
return True
6669
fill_attempts += 1
@@ -92,6 +95,19 @@ def attempt_form_fill_and_save():
9295
save_element.wait_for('visible')
9396
save_element.focus()
9497
save_element.click()
98+
time.sleep(3)
99+
print('[Test] Save button clicked')
100+
101+
# If the notebook already exists from previous attempts or other tests,
102+
# just overwrite it
103+
if notebook_frontend.locate('text=Overwrite', page=EDITOR_PAGE).is_visible():
104+
overwrite_element = notebook_frontend.locate('text=Overwrite', page=EDITOR_PAGE)
105+
overwrite_element.focus()
106+
overwrite_element.click()
107+
time.sleep(3)
108+
109+
save_element.wait_for('detached')
110+
print('[Test] Save element detached')
95111

96112
# Application lag may cause the save dialog to linger,
97113
# if it's visible wait for it to disappear before proceeding
@@ -112,11 +128,109 @@ def attempt_form_fill_and_save():
112128
return True
113129

114130
# Retry until timeout (wait_for_condition retries upon func exception)
115-
notebook_frontend.wait_for_condition(attempt_form_fill_and_save, timeout=900, period=1)
131+
notebook_frontend.wait_for_condition(
132+
partial(attempt_form_fill_and_save, "new_notebook.ipynb"),
133+
timeout=120,
134+
period=1,
135+
)
116136

117137
print('[Test] Check notebook name in URL')
118138
notebook_frontend.wait_for_condition(
119-
lambda: notebook_name in notebook_frontend.get_page_url(page=EDITOR_PAGE),
139+
lambda: get_notebook_name(notebook_frontend) in notebook_frontend.get_page_url(page=EDITOR_PAGE),
120140
timeout=120,
121141
period=5
122142
)
143+
144+
print('[Test] Begin attempts to fill the save dialog input and save the notebook with a new directory')
145+
fill_attempts=0
146+
147+
def attempt_form_fill_w_dir_and_save(notebook_path):
148+
# Application behavior here is HIGHLY variable, we use this for repeated attempts
149+
# ....................
150+
# This may be a retry, check if the application state reflects a successful save operation
151+
nonlocal fill_attempts
152+
if fill_attempts and get_notebook_name(notebook_frontend) == notebook_path.split("/")[-1]:
153+
print('[Test] Success from previous save attempt!')
154+
return True
155+
fill_attempts += 1
156+
print(f'[Test] Attempt form fill with directory and save #{fill_attempts}')
157+
158+
# Make sure the save prompt is visible
159+
if not name_input_element.is_visible():
160+
save_as(notebook_frontend)
161+
name_input_element.wait_for('visible')
162+
163+
# Set the notebook name field in the save dialog
164+
print('[Test] Fill the input field')
165+
name_input_element.evaluate(f'(elem) => {{ elem.value = "{notebook_path}"; return elem.value; }}')
166+
notebook_frontend.wait_for_condition(
167+
lambda: name_input_element.evaluate(
168+
f'(elem) => {{ elem.value = "{notebook_path}"; return elem.value; }}') == notebook_path,
169+
timeout=120,
170+
period=.25
171+
)
172+
# Show the input field value
173+
print('[Test] Name input field contents:')
174+
field_value = name_input_element.evaluate('(elem) => {{ return elem.value; }}')
175+
print('[Test] ' + field_value)
176+
if field_value != notebook_path:
177+
return False
178+
179+
print('[Test] Locate and click the save button')
180+
save_element = dialog_element.locate('text=Save')
181+
save_element.wait_for('visible')
182+
save_element.focus()
183+
save_element.click()
184+
time.sleep(3)
185+
print('[Test] Save button clicked')
186+
187+
# If the notebook path contains a directory, click the create button
188+
if "/" in notebook_path:
189+
print('[Test] Locate and click the create button')
190+
create_element = dialog_element.locate('text=Create')
191+
create_element.wait_for('visible')
192+
create_element.focus()
193+
create_element.click()
194+
time.sleep(3)
195+
196+
# If the notebook already exists from previous attempts or other tests,
197+
# just overwrite it
198+
if notebook_frontend.locate('text=Overwrite', page=EDITOR_PAGE).is_visible():
199+
overwrite_element = notebook_frontend.locate('text=Overwrite', page=EDITOR_PAGE)
200+
overwrite_element.focus()
201+
overwrite_element.click()
202+
time.sleep(3)
203+
204+
# Application lag may cause the save dialog to linger,
205+
# if it's visible wait for it to disappear before proceeding
206+
if save_element.is_visible():
207+
print('[Test] Save element still visible after save, wait for hidden')
208+
try:
209+
save_element.expect_not_to_be_visible(timeout=120)
210+
except EndToEndTimeout as err:
211+
traceback.print_exc()
212+
print('[Test] Save button failed to hide...')
213+
214+
# Check if the save operation succeeded (by checking notebook name change)
215+
notebook_frontend.wait_for_condition(
216+
lambda: get_notebook_name(notebook_frontend) == notebook_path.split('/')[-1], timeout=120, period=5
217+
)
218+
print(f'[Test] Notebook name: {get_notebook_name(notebook_frontend)}')
219+
print('[Test] Notebook name was changed!')
220+
return True
221+
222+
for notebook_path in ["new_notebook.ipynb", "new_folder/another_new_notebook.ipynb"]:
223+
print(f'[Test] Begin attempts to fill the save dialog input with {notebook_path} and save the notebook')
224+
fill_attempts = 0
225+
check_func = partial(attempt_form_fill_w_dir_and_save, notebook_path)
226+
notebook_frontend.wait_for_condition(check_func, timeout=900, period=1)
227+
228+
print('[Test] Check notebook name in URL')
229+
try:
230+
notebook_frontend.wait_for_condition(
231+
lambda: notebook_path.split("/")[-1] in notebook_frontend.get_page_url(page=EDITOR_PAGE),
232+
timeout=120,
233+
period=5
234+
)
235+
except:
236+
print(notebook_frontend.get_page_url(page=EDITOR_PAGE))

nbclassic/tests/end_to_end/test_save_readonly_as.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
"""Test readonly notebook saved and renamed"""
22

3-
43
import sys
5-
import traceback
64

75
import pytest
86

9-
from .utils import EDITOR_PAGE, EndToEndTimeout
7+
from .utils import EDITOR_PAGE
108

119

1210
def save_as(nb):
@@ -116,8 +114,17 @@ def attempt_form_fill_and_save():
116114
save_element.wait_for('visible')
117115
save_element.focus()
118116
save_element.click()
117+
print('[Test] Save button clicked')
118+
119+
# If the notebook already exists from previous attempts or other tests,
120+
# just overwrite it
121+
if notebook_frontend.locate('text=Overwrite', page=EDITOR_PAGE).is_visible():
122+
overwrite_element = notebook_frontend.locate('text=Overwrite', page=EDITOR_PAGE)
123+
overwrite_element.focus()
124+
overwrite_element.click()
119125

120126
save_element.wait_for('detached')
127+
print('[Test] Save element detached')
121128

122129
# Check if the save operation succeeded (by checking notebook name change)
123130
notebook_frontend.wait_for_condition(

nbclassic/tests/end_to_end/utils.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ def __init__(self, item, user_data=None):
9595
self._user_data = copy.deepcopy(item._user_data)
9696
self._user_data.update(user_data)
9797

98+
def __repr__(self) -> str:
99+
return str(self._raw)
100+
98101
def __bool__(self):
99102
"""Returns True if construction succeeded"""
100103
# (Quick/dirty )We can debug on failures by deferring bad inits and testing for them here

0 commit comments

Comments
 (0)