diff --git a/app/client/components/AceEditor.js b/app/client/components/AceEditor.js index 72d59576..8001fc08 100644 --- a/app/client/components/AceEditor.js +++ b/app/client/components/AceEditor.js @@ -20,12 +20,14 @@ var modelUtil = require('../models/modelUtil'); * element. Both desiredSize and the return value are objects with 'width' and 'height' members. */ function AceEditor(options) { + options = options || {}; // Observable subscription is not created until the dom is built - this.observable = (options && options.observable) || null; - this.saveValueOnBlurEvent = !(options && (options.saveValueOnBlurEvent === false)); - this.calcSize = (options && options.calcSize) || ((elem, size) => size); - this.gristDoc = (options && options.gristDoc) || null; - this.editorState = (options && options.editorState) || null; + this.observable = options.observable || null; + this.saveValueOnBlurEvent = !(options.saveValueOnBlurEvent === false); + this.calcSize = options.calcSize || ((_elem, size) => size); + this.gristDoc = options.gristDoc || null; + this.field = options.field || null; + this.editorState = options.editorState || null; this._readonly = options.readonly || false; this.editor = null; @@ -183,10 +185,11 @@ AceEditor.prototype.setFontSize = function(pxVal) { AceEditor.prototype._setup = function() { // Standard editor setup this.editor = this.autoDisposeWith('destroy', ace.edit(this.editorDom)); - if (this.gristDoc) { + if (this.gristDoc && this.field) { const getSuggestions = (prefix) => { const tableId = this.gristDoc.viewModel.activeSection().table().tableId(); - return this.gristDoc.docComm.autocomplete(prefix, tableId); + const columnId = this.field.column().colId(); + return this.gristDoc.docComm.autocomplete(prefix, tableId, columnId); }; setupAceEditorCompletions(this.editor, {getSuggestions}); } diff --git a/app/client/widgets/FormulaEditor.ts b/app/client/widgets/FormulaEditor.ts index 2bd8b65e..8dd39398 100644 --- a/app/client/widgets/FormulaEditor.ts +++ b/app/client/widgets/FormulaEditor.ts @@ -45,6 +45,7 @@ export class FormulaEditor extends NewBaseEditor { this._formulaEditor = AceEditor.create({ // A bit awkward, but we need to assume calcSize is not used until attach() has been called // and _editorPlacement created. + field: options.field, calcSize: this._calcSize.bind(this), gristDoc: options.gristDoc, saveValueOnBlurEvent: !options.readonly, diff --git a/app/common/ActiveDocAPI.ts b/app/common/ActiveDocAPI.ts index e3444ba7..b395bced 100644 --- a/app/common/ActiveDocAPI.ts +++ b/app/common/ActiveDocAPI.ts @@ -174,9 +174,9 @@ export interface ActiveDocAPI { /** * Find and return a list of auto-complete suggestions that start with `txt`, when editing a - * formula in table `tableId`. + * formula in table `tableId` and column `columnId`. */ - autocomplete(txt: string, tableId: string): Promise; + autocomplete(txt: string, tableId: string, columnId: string): Promise; /** * Removes the current instance from the doc. diff --git a/app/server/lib/ActiveDoc.ts b/app/server/lib/ActiveDoc.ts index ec8aedfd..84547d5b 100644 --- a/app/server/lib/ActiveDoc.ts +++ b/app/server/lib/ActiveDoc.ts @@ -836,11 +836,11 @@ export class ActiveDoc extends EventEmitter { docSession.linkId = 0; } - public async autocomplete(docSession: DocSession, txt: string, tableId: string): Promise { + public async autocomplete(docSession: DocSession, txt: string, tableId: string, columnId: string): Promise { // Autocompletion can leak names of tables and columns. if (!await this._granularAccess.canScanData(docSession)) { return []; } await this.waitForInitialization(); - return this._pyCall('autocomplete', txt, tableId); + return this._pyCall('autocomplete', txt, tableId, columnId); } public fetchURL(docSession: DocSession, url: string): Promise { diff --git a/sandbox/grist/autocomplete_context.py b/sandbox/grist/autocomplete_context.py index f6c3c3b8..1f28614a 100644 --- a/sandbox/grist/autocomplete_context.py +++ b/sandbox/grist/autocomplete_context.py @@ -60,6 +60,9 @@ class AutocompleteContext(object): lower += '*' self._lowercase[lower] = key + # Lowercase 'value' is used in trigger formulas, and is not the same as 'VALUE'. + self._lowercase.pop('value', None) + # Add the lowercase names to the context, and to the detailed completions in _functions. for lower, key in six.iteritems(self._lowercase): self._context[lower] = self._context[key] diff --git a/sandbox/grist/engine.py b/sandbox/grist/engine.py index cbf9143d..4f4abc4a 100644 --- a/sandbox/grist/engine.py +++ b/sandbox/grist/engine.py @@ -1281,7 +1281,7 @@ class Engine(object): if not self._compute_stack: self._bring_lookups_up_to_date(doc_action) - def autocomplete(self, txt, table_id): + def autocomplete(self, txt, table_id, column_id): """ Return a list of suggested completions of the python fragment supplied. """ @@ -1295,6 +1295,13 @@ class Engine(object): context = self._autocomplete_context.get_context() context['rec'] = table.sample_record + # Remove values from the context that need to be recomputed. + context.pop('value', None) + + column = table.get_column(column_id) if table.has_column(column_id) else None + if column and not column.is_formula(): + context['value'] = column.sample_value() + completer = rlcompleter.Completer(context) results = [] at = 0 diff --git a/sandbox/grist/main.py b/sandbox/grist/main.py index 1c4b16ca..ee37cdb0 100644 --- a/sandbox/grist/main.py +++ b/sandbox/grist/main.py @@ -73,8 +73,8 @@ def run(sandbox): return eng.acl_split(action_group).to_json_obj() @export - def autocomplete(txt, table_id): - return eng.autocomplete(txt, table_id) + def autocomplete(txt, table_id, column_id): + return eng.autocomplete(txt, table_id, column_id) @export def find_col_from_values(values, n, opt_table_id): diff --git a/sandbox/grist/test_completion.py b/sandbox/grist/test_completion.py index 0bee5b23..96698a8f 100644 --- a/sandbox/grist/test_completion.py +++ b/sandbox/grist/test_completion.py @@ -1,5 +1,6 @@ import testsamples import test_engine +from schema import RecalcWhen class TestCompletion(test_engine.EngineTestCase): def setUp(self): @@ -12,19 +13,45 @@ class TestCompletion(test_engine.EngineTestCase): self.add_column('Students', 'lastVisit', type='DateTime:America/New_York') self.add_column('Schools', 'yearFounded', type='Int') self.add_column('Schools', 'budget', type='Numeric') + self.add_column('Schools', 'lastModified', + type="DateTime:America/Los_Angeles", isFormula=False, formula="NOW()", + recalcWhen=RecalcWhen.MANUAL_UPDATES + ) + self.add_column('Schools', 'lastModifier', + type="Text", isFormula=False, formula="foo@getgrist.com", + recalcWhen=RecalcWhen.MANUAL_UPDATES + ) def test_keyword(self): - self.assertEqual(self.engine.autocomplete("for", "Address"), + self.assertEqual(self.engine.autocomplete("for", "Address", "city"), ["for", "format("]) def test_grist(self): - self.assertEqual(self.engine.autocomplete("gri", "Address"), + self.assertEqual(self.engine.autocomplete("gri", "Address", "city"), ["grist"]) + def test_value(self): + # Should only appear if column exists and is a trigger formula. + self.assertEqual(self.engine.autocomplete("val", "Schools", "lastModified"), + ["value"]) + self.assertEqual(self.engine.autocomplete("val", "Students", "schoolCities"), + []) + self.assertEqual(self.engine.autocomplete("val", "Students", "nonexistentColumn"), + []) + self.assertEqual(self.engine.autocomplete("valu", "Schools", "lastModifier"), + ["value"]) + # Should have same type as column. + self.assertGreaterEqual(set(self.engine.autocomplete("value.", "Schools", "lastModifier")), + {'value.startswith(', 'value.replace(', 'value.title('}) + self.assertGreaterEqual(set(self.engine.autocomplete("value.", "Schools", "lastModified")), + {'value.month', 'value.strftime(', 'value.replace('}) + self.assertGreaterEqual(set(self.engine.autocomplete("value.m", "Schools", "lastModified")), + {'value.month', 'value.minute'}) + def test_function(self): - self.assertEqual(self.engine.autocomplete("MEDI", "Address"), + self.assertEqual(self.engine.autocomplete("MEDI", "Address", "city"), [('MEDIAN', '(value, *more_values)', True)]) - self.assertEqual(self.engine.autocomplete("ma", "Address"), [ + self.assertEqual(self.engine.autocomplete("ma", "Address", "city"), [ ('MAX', '(value, *more_values)', True), ('MAXA', '(value, *more_values)', True), 'map(', @@ -33,30 +60,30 @@ class TestCompletion(test_engine.EngineTestCase): ]) def test_member(self): - self.assertEqual(self.engine.autocomplete("datetime.tz", "Address"), + self.assertEqual(self.engine.autocomplete("datetime.tz", "Address", "city"), ["datetime.tzinfo("]) def test_case_insensitive(self): - self.assertEqual(self.engine.autocomplete("medi", "Address"), + self.assertEqual(self.engine.autocomplete("medi", "Address", "city"), [('MEDIAN', '(value, *more_values)', True)]) - self.assertEqual(self.engine.autocomplete("std", "Address"), [ + self.assertEqual(self.engine.autocomplete("std", "Address", "city"), [ ('STDEV', '(value, *more_values)', True), ('STDEVA', '(value, *more_values)', True), ('STDEVP', '(value, *more_values)', True), ('STDEVPA', '(value, *more_values)', True) ]) - self.assertEqual(self.engine.autocomplete("stu", "Address"), + self.assertEqual(self.engine.autocomplete("stu", "Address", "city"), ["Students"]) # Add a table name whose lowercase version conflicts with a builtin. self.apply_user_action(['AddTable', 'Max', []]) - self.assertEqual(self.engine.autocomplete("max", "Address"), [ + self.assertEqual(self.engine.autocomplete("max", "Address", "city"), [ ('MAX', '(value, *more_values)', True), ('MAXA', '(value, *more_values)', True), 'Max', 'max(', ]) - self.assertEqual(self.engine.autocomplete("MAX", "Address"), [ + self.assertEqual(self.engine.autocomplete("MAX", "Address", "city"), [ ('MAX', '(value, *more_values)', True), ('MAXA', '(value, *more_values)', True), ]) @@ -64,89 +91,93 @@ class TestCompletion(test_engine.EngineTestCase): def test_suggest_globals_and_tables(self): # Should suggest globals and table names. - self.assertEqual(self.engine.autocomplete("ME", "Address"), + self.assertEqual(self.engine.autocomplete("ME", "Address", "city"), [('MEDIAN', '(value, *more_values)', True)]) - self.assertEqual(self.engine.autocomplete("Ad", "Address"), ['Address']) - self.assertGreaterEqual(set(self.engine.autocomplete("S", "Address")), { + self.assertEqual(self.engine.autocomplete("Ad", "Address", "city"), ['Address']) + self.assertGreaterEqual(set(self.engine.autocomplete("S", "Address", "city")), { 'Schools', 'Students', ('SUM', '(value1, *more_values)', True), ('STDEV', '(value, *more_values)', True), }) - self.assertGreaterEqual(set(self.engine.autocomplete("s", "Address")), { + self.assertGreaterEqual(set(self.engine.autocomplete("s", "Address", "city")), { 'Schools', 'Students', 'sum(', ('SUM', '(value1, *more_values)', True), ('STDEV', '(value, *more_values)', True), }) - self.assertEqual(self.engine.autocomplete("Addr", "Schools"), ['Address']) + self.assertEqual(self.engine.autocomplete("Addr", "Schools", "budget"), ['Address']) def test_suggest_columns(self): - self.assertEqual(self.engine.autocomplete("$ci", "Address"), + self.assertEqual(self.engine.autocomplete("$ci", "Address", "city"), ["$city"]) - self.assertEqual(self.engine.autocomplete("rec.i", "Address"), + self.assertEqual(self.engine.autocomplete("rec.i", "Address", "city"), ["rec.id"]) - self.assertEqual(len(self.engine.autocomplete("$", "Address")), + self.assertEqual(len(self.engine.autocomplete("$", "Address", "city")), 2) # A few more detailed examples. - self.assertEqual(self.engine.autocomplete("$", "Students"), + self.assertEqual(self.engine.autocomplete("$", "Students", "school"), ['$birthDate', '$firstName', '$id', '$lastName', '$lastVisit', '$school', '$schoolCities', '$schoolIds', '$schoolName']) - self.assertEqual(self.engine.autocomplete("$fi", "Students"), ['$firstName']) - self.assertEqual(self.engine.autocomplete("$school", "Students"), + self.assertEqual(self.engine.autocomplete("$fi", "Students", "birthDate"), ['$firstName']) + self.assertEqual(self.engine.autocomplete("$school", "Students", "lastVisit"), ['$school', '$schoolCities', '$schoolIds', '$schoolName']) def test_suggest_lookup_methods(self): # Should suggest lookup formulas for tables. - self.assertEqual(self.engine.autocomplete("Address.", "Students"), [ + self.assertEqual(self.engine.autocomplete("Address.", "Students", "firstName"), [ 'Address.all', ('Address.lookupOne', '(colName=, ...)', True), ('Address.lookupRecords', '(colName=, ...)', True), ]) - self.assertEqual(self.engine.autocomplete("Address.lookup", "Students"), [ + self.assertEqual(self.engine.autocomplete("Address.lookup", "Students", "lastName"), [ ('Address.lookupOne', '(colName=, ...)', True), ('Address.lookupRecords', '(colName=, ...)', True), ]) - self.assertEqual(self.engine.autocomplete("address.look", "Students"), [ + self.assertEqual(self.engine.autocomplete("address.look", "Students", "schoolName"), [ ('Address.lookupOne', '(colName=, ...)', True), ('Address.lookupRecords', '(colName=, ...)', True), ]) def test_suggest_column_type_methods(self): # Should treat columns as correct types. - self.assertGreaterEqual(set(self.engine.autocomplete("$firstName.", "Students")), + self.assertGreaterEqual(set(self.engine.autocomplete("$firstName.", "Students", "firstName")), {'$firstName.startswith(', '$firstName.replace(', '$firstName.title('}) - self.assertGreaterEqual(set(self.engine.autocomplete("$birthDate.", "Students")), + self.assertGreaterEqual(set(self.engine.autocomplete("$birthDate.", "Students", "lastName")), {'$birthDate.month', '$birthDate.strftime(', '$birthDate.replace('}) - self.assertGreaterEqual(set(self.engine.autocomplete("$lastVisit.m", "Students")), + self.assertGreaterEqual(set(self.engine.autocomplete("$lastVisit.m", "Students", "firstName")), {'$lastVisit.month', '$lastVisit.minute'}) - self.assertGreaterEqual(set(self.engine.autocomplete("$school.", "Students")), + self.assertGreaterEqual(set(self.engine.autocomplete("$school.", "Students", "firstName")), {'$school.address', '$school.name', '$school.yearFounded', '$school.budget'}) - self.assertEqual(self.engine.autocomplete("$school.year", "Students"), + self.assertEqual(self.engine.autocomplete("$school.year", "Students", "lastName"), ['$school.yearFounded']) - self.assertGreaterEqual(set(self.engine.autocomplete("$yearFounded.", "Schools")), + self.assertGreaterEqual(set(self.engine.autocomplete("$yearFounded.", "Schools", "budget")), {'$yearFounded.denominator', # Only integers have this '$yearFounded.bit_length(', # and this '$yearFounded.real'}) - self.assertGreaterEqual(set(self.engine.autocomplete("$budget.", "Schools")), + self.assertGreaterEqual(set(self.engine.autocomplete("$budget.", "Schools", "budget")), {'$budget.is_integer(', # Only floats have this '$budget.real'}) def test_suggest_follows_references(self): # Should follow references and autocomplete those types. - self.assertEqual(self.engine.autocomplete("$school.name.st", "Students"), + self.assertEqual(self.engine.autocomplete("$school.name.st", "Students", "firstName"), ['$school.name.startswith(', '$school.name.strip(']) - self.assertGreaterEqual(set(self.engine.autocomplete("$school.yearFounded.", "Students")), - {'$school.yearFounded.denominator', - '$school.yearFounded.bit_length(', - '$school.yearFounded.real'}) + self.assertGreaterEqual( + set(self.engine.autocomplete("$school.yearFounded.","Students", "firstName")), + { + '$school.yearFounded.denominator', + '$school.yearFounded.bit_length(', + '$school.yearFounded.real' + } + ) - self.assertEqual(self.engine.autocomplete("$school.address.", "Students"), + self.assertEqual(self.engine.autocomplete("$school.address.", "Students", "lastName"), ['$school.address.city', '$school.address.id']) - self.assertEqual(self.engine.autocomplete("$school.address.city.st", "Students"), + self.assertEqual(self.engine.autocomplete("$school.address.city.st", "Students", "lastName"), ['$school.address.city.startswith(', '$school.address.city.strip(']) diff --git a/sandbox/grist/test_renames.py b/sandbox/grist/test_renames.py index a2aca08d..fbd3bfea 100644 --- a/sandbox/grist/test_renames.py +++ b/sandbox/grist/test_renames.py @@ -312,11 +312,17 @@ class TestRenames(test_engine.EngineTestCase): # Renaming a table should not leave the old name available for auto-complete. self.load_sample(self.sample) names = {"People", "Persons"} - self.assertEqual(names.intersection(self.engine.autocomplete("Pe", "Address")), {"People"}) + self.assertEqual( + names.intersection(self.engine.autocomplete("Pe", "Address", "city")), + {"People"} + ) # Rename the table and ensure that "People" is no longer present among top-level names. out_actions = self.apply_user_action(["RenameTable", "People", "Persons"]) - self.assertEqual(names.intersection(self.engine.autocomplete("Pe", "Address")), {"Persons"}) + self.assertEqual( + names.intersection(self.engine.autocomplete("Pe", "Address", "city")), + {"Persons"} + ) def test_rename_to_id(self): # Check that we renaming a column to "Id" disambiguates it with a suffix.