mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
f2141851be
Summary: When fields or sections were being removed from Grist documents, any layout specs that referred to them weren't being updated to no longer do so. This mismatch was causing various buggy scenarios to manifest in cases where the stale ids were being reused. We now automatically update any affected layout specs whenever fields or sections are removed. Test Plan: Python tests. Reviewers: jarek Reviewed By: jarek Differential Revision: https://phab.getgrist.com/D4302
1830 lines
68 KiB
Python
1830 lines
68 KiB
Python
# pylint:disable=too-many-lines
|
|
import json
|
|
import types
|
|
import logging
|
|
import useractions
|
|
|
|
import testutil
|
|
import test_engine
|
|
from test_engine import Table, Column, View, Section, Field
|
|
from schema import RecalcWhen
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
class TestUserActions(test_engine.EngineTestCase):
|
|
sample = testutil.parse_test_sample({
|
|
"SCHEMA": [
|
|
[1, "Address", [
|
|
[21, "city", "Text", False, "", "", ""],
|
|
]]
|
|
],
|
|
"DATA": {
|
|
"Address": [
|
|
["id", "city" ],
|
|
[11, "New York" ],
|
|
[12, "Colombia" ],
|
|
[13, "New Haven" ],
|
|
[14, "West Haven" ]],
|
|
}
|
|
})
|
|
|
|
starting_table = Table(1, "Address", primaryViewId=0, summarySourceTable=0, columns=[
|
|
Column(21, "city", "Text", isFormula=False, formula="", summarySourceCol=0)
|
|
])
|
|
|
|
#----------------------------------------------------------------------
|
|
def test_conversions(self):
|
|
# Test the sequence of user actions as used for transform-based conversions. This is actually
|
|
# not exactly what the client emits, but more like what the client should ideally emit.
|
|
|
|
# Our sample has a Schools.city text column; we'll convert it to Ref:Address.
|
|
self.load_sample(self.sample)
|
|
|
|
# Add a new table for Schools so that we get the associated views and fields.
|
|
self.apply_user_action(['AddTable', 'Schools', [{'id': 'city', 'type': 'Text'}]])
|
|
self.apply_user_action(['BulkAddRecord', 'Schools', [1,2,3,4], {
|
|
'city': ['New York', 'Colombia', 'New York', '']
|
|
}])
|
|
self.assertPartialData("_grist_Tables", ["id", "tableId"], [
|
|
[1, "Address"],
|
|
[2, "Schools"],
|
|
])
|
|
self.assertPartialData("_grist_Tables_column",
|
|
["id", "colId", "parentId", "parentPos", "widgetOptions"], [
|
|
[21, "city", 1, 1.0, ""],
|
|
[22, "manualSort", 2, 2.0, ""],
|
|
[23, "city", 2, 3.0, ""],
|
|
])
|
|
self.assertPartialData("_grist_Views_section_field", ["id", "colRef", "widgetOptions"], [
|
|
[1, 23, ""],
|
|
[2, 23, ""],
|
|
[3, 23, ""],
|
|
])
|
|
self.assertPartialData("Schools", ["id", "city"], [
|
|
[1, "New York" ],
|
|
[2, "Colombia" ],
|
|
[3, "New York" ],
|
|
[4, "" ],
|
|
])
|
|
|
|
# Our sample has a text column city.
|
|
out_actions = self.add_column('Schools', 'grist_Transform',
|
|
isFormula=True, formula='return $city', type='Text')
|
|
self.assertPartialOutActions(out_actions, { "stored": [
|
|
['AddColumn', 'Schools', 'grist_Transform', {
|
|
'type': 'Text', 'isFormula': True, 'formula': 'return $city',
|
|
}],
|
|
['AddRecord', '_grist_Tables_column', 24, {
|
|
'widgetOptions': '', 'parentPos': 4.0, 'isFormula': True, 'parentId': 2, 'colId':
|
|
'grist_Transform', 'formula': 'return $city', 'label': 'grist_Transform',
|
|
'type': 'Text'
|
|
}],
|
|
["AddRecord", "_grist_Views_section_field", 4, {
|
|
"colRef": 24, "parentId": 2, "parentPos": 4.0
|
|
}],
|
|
["AddRecord", "_grist_Views_section_field", 5, {
|
|
"colRef": 24, "parentId": 3, "parentPos": 5.0
|
|
}],
|
|
["BulkUpdateRecord", "Schools", [1, 2, 3],
|
|
{"grist_Transform": ["New York", "Colombia", "New York"]}],
|
|
]})
|
|
|
|
out_actions = self.update_record('_grist_Tables_column', 24,
|
|
type='Ref:Address',
|
|
formula='return Address.lookupOne(city=$city).id')
|
|
self.assertPartialOutActions(out_actions, { "stored": [
|
|
['ModifyColumn', 'Schools', 'grist_Transform', {
|
|
'formula': 'return Address.lookupOne(city=$city).id', 'type': 'Ref:Address'}],
|
|
['UpdateRecord', '_grist_Tables_column', 24, {
|
|
'formula': 'return Address.lookupOne(city=$city).id', 'type': 'Ref:Address'}],
|
|
["BulkUpdateRecord", "Schools", [1, 2, 3, 4], {"grist_Transform": [11, 12, 11, 0]}],
|
|
]})
|
|
|
|
# It seems best if TypeTransform sets widgetOptions on grist_Transform column, so that they
|
|
# can be copied in CopyFromColumn; rather than updating them after the copy is done.
|
|
self.update_record('_grist_Views_section_field', 1, widgetOptions="hello")
|
|
self.update_record('_grist_Tables_column', 24, widgetOptions="world")
|
|
|
|
out_actions = self.apply_user_action(
|
|
['CopyFromColumn', 'Schools', 'grist_Transform', 'city', None])
|
|
self.assertPartialOutActions(out_actions, { "stored": [
|
|
['ModifyColumn', 'Schools', 'city', {'type': 'Ref:Address'}],
|
|
['UpdateRecord', 'Schools', 4, {'city': 0}],
|
|
['UpdateRecord', '_grist_Tables_column', 23, {
|
|
'type': 'Ref:Address', 'widgetOptions': 'world'
|
|
}],
|
|
['BulkUpdateRecord', 'Schools', [1, 2, 3], {'city': [11, 12, 11]}],
|
|
["BulkUpdateRecord", "Schools", [1, 2, 3], {"grist_Transform": [0, 0, 0]}],
|
|
]})
|
|
|
|
out_actions = self.update_record('_grist_Tables_column', 23,
|
|
widgetOptions='{"widget":"Reference","visibleCol":"city"}')
|
|
self.assertPartialOutActions(out_actions, { "stored": [
|
|
['UpdateRecord', '_grist_Tables_column', 23, {
|
|
'widgetOptions': '{"widget":"Reference","visibleCol":"city"}'}],
|
|
]})
|
|
|
|
out_actions = self.remove_column('Schools', 'grist_Transform')
|
|
self.assertPartialOutActions(out_actions, { "stored": [
|
|
["BulkRemoveRecord", "_grist_Views_section_field", [4, 5]],
|
|
['RemoveRecord', '_grist_Tables_column', 24],
|
|
['RemoveColumn', 'Schools', 'grist_Transform'],
|
|
]})
|
|
|
|
#----------------------------------------------------------------------
|
|
def test_create_section_existing_view(self):
|
|
# Test that CreateViewSection works for an existing view.
|
|
|
|
self.load_sample(self.sample)
|
|
self.assertTables([self.starting_table])
|
|
|
|
# Create a view + section for the initial table.
|
|
self.apply_user_action(["CreateViewSection", 1, 0, "record", None, None])
|
|
|
|
# Verify that we got a new view, with one section, and three fields.
|
|
self.assertViews([View(1, sections=[
|
|
Section(1, parentKey="record", tableRef=1, fields=[
|
|
Field(1, colRef=21),
|
|
])
|
|
]) ])
|
|
|
|
# Create a new section for the same view, check that only a section is added.
|
|
self.apply_user_action(["CreateViewSection", 1, 1, "record", None, None])
|
|
self.assertTables([self.starting_table])
|
|
self.assertViews([View(1, sections=[
|
|
Section(1, parentKey="record", tableRef=1, fields=[
|
|
Field(1, colRef=21),
|
|
]),
|
|
Section(2, parentKey="record", tableRef=1, fields=[
|
|
Field(2, colRef=21),
|
|
])
|
|
]) ])
|
|
|
|
# Create another section for the same view, this time summarized.
|
|
self.apply_user_action(["CreateViewSection", 1, 1, "record", [21], None])
|
|
summary_table = Table(2, "Address_summary_city", 0, summarySourceTable=1, columns=[
|
|
Column(22, "city", "Text", isFormula=False, formula="", summarySourceCol=21),
|
|
Column(23, "group", "RefList:Address", isFormula=True,
|
|
formula="table.getSummarySourceGroup(rec)", summarySourceCol=0),
|
|
Column(24, "count", "Int", isFormula=True, formula="len($group)", summarySourceCol=0),
|
|
])
|
|
self.assertTables([self.starting_table, summary_table])
|
|
# Check that we still have one view, with sections for different tables.
|
|
view = View(1, sections=[
|
|
Section(1, parentKey="record", tableRef=1, fields=[
|
|
Field(1, colRef=21),
|
|
]),
|
|
Section(2, parentKey="record", tableRef=1, fields=[
|
|
Field(2, colRef=21),
|
|
]),
|
|
Section(4, parentKey="record", tableRef=2, fields=[
|
|
Field(5, colRef=22),
|
|
Field(6, colRef=24),
|
|
]),
|
|
])
|
|
self.assertTables([self.starting_table, summary_table])
|
|
self.assertViews([view])
|
|
|
|
# Try to create a summary table for an invalid column, and check that it fails.
|
|
with self.assertRaises(ValueError):
|
|
self.apply_user_action(["CreateViewSection", 1, 1, "record", [23], None])
|
|
self.assertTables([self.starting_table, summary_table])
|
|
self.assertViews([view])
|
|
|
|
#----------------------------------------------------------------------
|
|
def test_creates_section_new_table(self):
|
|
# Test that CreateViewSection works for adding a new table.
|
|
|
|
self.load_sample(self.sample)
|
|
self.assertTables([self.starting_table])
|
|
self.assertViews([])
|
|
|
|
# When we create a section/view for new table, we got the new view we are creating,
|
|
# without primary view.
|
|
self.apply_user_action(["CreateViewSection", 0, 0, "record", None, None])
|
|
new_table = Table(2, "Table1", primaryViewId=0, summarySourceTable=0, columns=[
|
|
Column(22, "manualSort", "ManualSortPos", isFormula=False, formula="", summarySourceCol=0),
|
|
Column(23, "A", "Any", isFormula=True, formula="", summarySourceCol=0),
|
|
Column(24, "B", "Any", isFormula=True, formula="", summarySourceCol=0),
|
|
Column(25, "C", "Any", isFormula=True, formula="", summarySourceCol=0),
|
|
])
|
|
new_view = View(1, sections=[
|
|
Section(3, parentKey="record", tableRef=2, fields=[
|
|
Field(7, colRef=23),
|
|
Field(8, colRef=24),
|
|
Field(9, colRef=25),
|
|
])
|
|
])
|
|
self.assertTables([self.starting_table, new_table])
|
|
self.assertViews([new_view])
|
|
|
|
# Create another section in an existing view for a new table.
|
|
self.apply_user_action(["CreateViewSection", 0, 1, "record", None, None])
|
|
new_table2 = Table(3, "Table2", primaryViewId=0, summarySourceTable=0, columns=[
|
|
Column(26, "manualSort", "ManualSortPos", isFormula=False, formula="", summarySourceCol=0),
|
|
Column(27, "A", "Any", isFormula=True, formula="", summarySourceCol=0),
|
|
Column(28, "B", "Any", isFormula=True, formula="", summarySourceCol=0),
|
|
Column(29, "C", "Any", isFormula=True, formula="", summarySourceCol=0),
|
|
])
|
|
new_view.sections.append(
|
|
Section(6, parentKey="record", tableRef=3, fields=[
|
|
Field(16, colRef=27),
|
|
Field(17, colRef=28),
|
|
Field(18, colRef=29),
|
|
])
|
|
)
|
|
# Check that we have a new table, only the new view; and a new section.
|
|
self.assertTables([self.starting_table, new_table, new_table2])
|
|
self.assertViews([new_view])
|
|
|
|
# Check that we can't create a summary of a table grouped by a column that doesn't exist yet.
|
|
with self.assertRaises(ValueError):
|
|
self.apply_user_action(["CreateViewSection", 0, 1, "record", [31], None])
|
|
self.assertTables([self.starting_table, new_table, new_table2])
|
|
self.assertViews([new_view])
|
|
|
|
# But creating a new table and showing totals for it is possible though dumb.
|
|
self.apply_user_action(["CreateViewSection", 0, 1, "record", [], None])
|
|
|
|
# We expect a new table.
|
|
new_table3 = Table(4, "Table3", primaryViewId=0, summarySourceTable=0, columns=[
|
|
Column(30, "manualSort", "ManualSortPos", isFormula=False, formula="", summarySourceCol=0),
|
|
Column(31, "A", "Any", isFormula=True, formula="", summarySourceCol=0),
|
|
Column(32, "B", "Any", isFormula=True, formula="", summarySourceCol=0),
|
|
Column(33, "C", "Any", isFormula=True, formula="", summarySourceCol=0),
|
|
])
|
|
# A summary of it.
|
|
summary_table = Table(5, "Table3_summary", 0, summarySourceTable=4, columns=[
|
|
Column(34, "group", "RefList:Table3", isFormula=True,
|
|
formula="table.getSummarySourceGroup(rec)", summarySourceCol=0),
|
|
Column(35, "count", "Int", isFormula=True, formula="len($group)", summarySourceCol=0),
|
|
])
|
|
self.assertTables([self.starting_table, new_table, new_table2, new_table3, summary_table])
|
|
new_view.sections.append(Section(10, parentKey="record", tableRef=5, fields=[
|
|
Field(26, colRef=35)
|
|
]))
|
|
self.assertViews([new_view])
|
|
|
|
#----------------------------------------------------------------------
|
|
|
|
def init_views_sample(self):
|
|
# Add a new table and a view, to get some Views/Sections/Fields, and TabBar items.
|
|
self.apply_user_action(['AddTable', 'Schools', [
|
|
{'id': 'city', 'type': 'Text'},
|
|
{'id': 'state', 'type': 'Text'},
|
|
{'id': 'size', 'type': 'Numeric'},
|
|
]])
|
|
self.apply_user_action(['BulkAddRecord', 'Schools', [1,2,3,4], {
|
|
'city': ['New York', 'Colombia', 'New York', ''],
|
|
'state': ['NY', 'NY', 'NY', ''],
|
|
'size': [1000, 2000, 3000, 4000],
|
|
}])
|
|
# Add a new view; a second section (summary) to it; and a third view.
|
|
self.apply_user_action(['CreateViewSection', 1, 0, 'detail', None, None])
|
|
self.apply_user_action(['CreateViewSection', 1, 2, 'record', [3], None])
|
|
self.apply_user_action(['CreateViewSection', 1, 0, 'chart', None, None])
|
|
self.apply_user_action(['CreateViewSection', 0, 2, 'record', None, None])
|
|
|
|
# Verify the new structure of tables and views.
|
|
self.assertTables([
|
|
Table(1, "Schools", 1, 0, columns=[
|
|
Column(1, "manualSort", "ManualSortPos", False, "", 0),
|
|
Column(2, "city", "Text", False, "", 0),
|
|
Column(3, "state", "Text", False, "", 0),
|
|
Column(4, "size", "Numeric", False, "", 0),
|
|
]),
|
|
Table(2, "Schools_summary_state", 0, 1, columns=[
|
|
Column(5, "state", "Text", False, "", 3),
|
|
Column(6, "group", "RefList:Schools", True, "table.getSummarySourceGroup(rec)", 0),
|
|
Column(7, "count", "Int", True, "len($group)", 0),
|
|
Column(8, "size", "Numeric", True, "SUM($group.size)", 0),
|
|
]),
|
|
Table(3, 'Table1', 0, 0, columns=[
|
|
Column(9, "manualSort", "ManualSortPos", False, "", 0),
|
|
Column(10, "A", "Any", True, "", 0),
|
|
Column(11, "B", "Any", True, "", 0),
|
|
Column(12, "C", "Any", True, "", 0),
|
|
]),
|
|
])
|
|
self.assertViews([
|
|
View(1, sections=[
|
|
Section(1, parentKey="record", tableRef=1, fields=[
|
|
Field(1, colRef=2),
|
|
Field(2, colRef=3),
|
|
Field(3, colRef=4),
|
|
]),
|
|
]),
|
|
View(2, sections=[
|
|
Section(4, parentKey="detail", tableRef=1, fields=[
|
|
Field(10, colRef=2),
|
|
Field(11, colRef=3),
|
|
Field(12, colRef=4),
|
|
]),
|
|
Section(6, parentKey="record", tableRef=2, fields=[
|
|
Field(16, colRef=5),
|
|
Field(17, colRef=7),
|
|
Field(18, colRef=8),
|
|
]),
|
|
Section(10, parentKey='record', tableRef=3, fields=[
|
|
Field(27, colRef=10),
|
|
Field(28, colRef=11),
|
|
Field(29, colRef=12),
|
|
]),
|
|
]),
|
|
View(3, sections=[
|
|
Section(7, parentKey="chart", tableRef=1, fields=[
|
|
Field(19, colRef=2),
|
|
Field(20, colRef=3),
|
|
]),
|
|
])
|
|
])
|
|
self.assertTableData('_grist_TabBar', cols="subset", data=[
|
|
["id", "viewRef"],
|
|
[1, 1],
|
|
[2, 2],
|
|
[3, 3],
|
|
])
|
|
self.assertTableData('_grist_Pages', cols="subset", data=[
|
|
["id", "viewRef"],
|
|
[1, 1],
|
|
[2, 2],
|
|
[3, 3]
|
|
])
|
|
|
|
#----------------------------------------------------------------------
|
|
|
|
def test_view_remove(self):
|
|
# Add a couple of tables and views, to trigger creation of some related items.
|
|
self.init_views_sample()
|
|
|
|
# Remove a view. Ensure related items, sections, fields get removed.
|
|
self.apply_user_action(["BulkRemoveRecord", "_grist_Views", [2, 3]])
|
|
|
|
# Verify the new structure of tables and views.
|
|
self.assertTables([
|
|
Table(1, "Schools", 1, 0, columns=[
|
|
Column(1, "manualSort", "ManualSortPos", False, "", 0),
|
|
Column(2, "city", "Text", False, "", 0),
|
|
Column(3, "state", "Text", False, "", 0),
|
|
Column(4, "size", "Numeric", False, "", 0),
|
|
]),
|
|
# Note that the summary table is gone.
|
|
Table(3, 'Table1', 0, 0, columns=[
|
|
Column(9, "manualSort", "ManualSortPos", False, "", 0),
|
|
Column(10, "A", "Any", True, "", 0),
|
|
Column(11, "B", "Any", True, "", 0),
|
|
Column(12, "C", "Any", True, "", 0),
|
|
]),
|
|
])
|
|
self.assertViews([
|
|
View(1, sections=[
|
|
Section(1, parentKey="record", tableRef=1, fields=[
|
|
Field(1, colRef=2),
|
|
Field(2, colRef=3),
|
|
Field(3, colRef=4),
|
|
]),
|
|
])
|
|
])
|
|
self.assertTableData('_grist_TabBar', cols="subset", data=[
|
|
["id", "viewRef"],
|
|
[1, 1],
|
|
])
|
|
self.assertTableData('_grist_Pages', cols="subset", data=[
|
|
["id", "viewRef"],
|
|
[1, 1],
|
|
])
|
|
|
|
#----------------------------------------------------------------------
|
|
|
|
def test_view_rename(self):
|
|
# Add a couple of tables and views, to trigger creation of some related items.
|
|
self.init_views_sample()
|
|
|
|
# Verify the new structure of tables and views.
|
|
self.assertTableData('_grist_Tables', cols="subset", data=[
|
|
[ 'id', 'tableId', 'primaryViewId' ],
|
|
[ 1, 'Schools', 1],
|
|
[ 2, 'Schools_summary_state', 0],
|
|
[ 3, 'Table1', 0],
|
|
])
|
|
self.assertTableData('_grist_Views', cols="subset", data=[
|
|
[ 'id', 'name', 'primaryViewTable' ],
|
|
[ 1, 'Schools', 1],
|
|
[ 2, 'New page', 0],
|
|
[ 3, 'New page', 0],
|
|
])
|
|
|
|
# Update the names in a few views, and ensure that primary ones won't cause tables to
|
|
# get renamed.
|
|
self.apply_user_action(['BulkUpdateRecord', '_grist_Views', [2, 3],
|
|
{'name': ['A', 'B']}])
|
|
|
|
self.assertTableData('_grist_Tables', cols="subset", data=[
|
|
[ 'id', 'tableId', 'primaryViewId' ],
|
|
[ 1, 'Schools', 1],
|
|
[ 2, 'Schools_summary_state', 0],
|
|
[ 3, 'Table1', 0],
|
|
])
|
|
self.assertTableData('_grist_Views', cols="subset", data=[
|
|
[ 'id', 'name', 'primaryViewTable' ],
|
|
[ 1, 'Schools', 1],
|
|
[ 2, 'A', 0],
|
|
[ 3, 'B', 0]
|
|
])
|
|
|
|
# Now rename a table (by raw view section) and make sure that a view with the same name
|
|
# was renamed
|
|
self.apply_user_action(['UpdateRecord', '_grist_Views_section', 2,
|
|
{'title': 'Bars'}])
|
|
|
|
self.assertTableData('_grist_Tables', cols="subset", data=[
|
|
['id', 'tableId'],
|
|
[1, 'Bars', 1],
|
|
[2, 'Bars_summary_state', 0],
|
|
[3, 'Table1', 0],
|
|
])
|
|
self.assertTableData('_grist_Views', cols="subset", data=[
|
|
['id', 'name'],
|
|
[1, 'Bars'],
|
|
[2, 'A'],
|
|
[3, 'B']
|
|
])
|
|
|
|
# Now rename tables so that two tables will have same names, to test if only the view
|
|
# with a page will be renamed.
|
|
self.apply_user_action(['UpdateRecord', '_grist_Views_section', 2,
|
|
{'title': 'A'}])
|
|
|
|
self.assertTableData('_grist_Tables', cols="subset", data=[
|
|
['id', 'tableId'],
|
|
[1, 'A', 1],
|
|
[2, 'A_summary_state', 0],
|
|
[3, 'Table1', 0],
|
|
])
|
|
self.assertTableData('_grist_Views', cols="subset", data=[
|
|
['id', 'name'],
|
|
[1, 'A'],
|
|
[2, 'A'],
|
|
[3, 'B'],
|
|
])
|
|
|
|
self.apply_user_action(['UpdateRecord', '_grist_Views_section', 2,
|
|
{'title': 'Z'}])
|
|
|
|
self.assertTableData('_grist_Tables', cols="subset", data=[
|
|
['id', 'tableId', 'primaryViewId', 'rawViewSectionRef', 'recordCardViewSectionRef'],
|
|
[1, 'Z', 1, 2, 3],
|
|
[2, 'Z_summary_state', 0, 5, 0],
|
|
[3, 'Table1', 0, 8, 9],
|
|
])
|
|
self.assertTableData('_grist_Views', cols="subset", data=[
|
|
['id', 'name'],
|
|
[1, 'Z'],
|
|
[2, 'Z'],
|
|
[3, 'B'],
|
|
])
|
|
|
|
# Add new table, with a view with the same name (Z) and make sure it won't be renamed
|
|
self.apply_user_action(['AddTable', 'Stations', [
|
|
{'id': 'city', 'type': 'Text'},
|
|
]])
|
|
self.assertTableData('_grist_Tables', cols="subset", data=[
|
|
['id', 'tableId', 'primaryViewId', 'rawViewSectionRef', 'recordCardViewSectionRef'],
|
|
[1, 'Z', 1, 2, 3],
|
|
[2, 'Z_summary_state', 0, 5, 0],
|
|
[3, 'Table1', 0, 8, 9],
|
|
[4, 'Stations', 4, 12, 13],
|
|
])
|
|
self.assertTableData('_grist_Views', cols="subset", data=[
|
|
['id', 'name'],
|
|
[1, 'Z'],
|
|
[2, 'Z'],
|
|
[3, 'B'],
|
|
[4, 'Stations'],
|
|
])
|
|
# Replacing only a page name (though primary)
|
|
self.apply_user_action(['UpdateRecord', '_grist_Views', 4, {'name': 'Z'}])
|
|
self.assertTableData('_grist_Views', cols="subset", data=[
|
|
['id', 'name'],
|
|
[1, 'Z'],
|
|
[2, 'Z'],
|
|
[3, 'B'],
|
|
[4, 'Z']
|
|
])
|
|
|
|
# Rename table Z to Schools. Primary view for Stations (Z) should not be renamed.
|
|
self.apply_user_action(['UpdateRecord', '_grist_Views_section', 2,
|
|
{'title': 'Schools'}])
|
|
|
|
self.assertTableData('_grist_Tables', cols="subset", data=[
|
|
['id', 'tableId'],
|
|
[1, 'Schools'],
|
|
[2, 'Schools_summary_state'],
|
|
[3, 'Table1'],
|
|
[4, 'Stations'],
|
|
])
|
|
self.assertTableData('_grist_Views', cols="subset", data=[
|
|
['id', 'name'],
|
|
[1, 'Schools'],
|
|
[2, 'Schools'],
|
|
[3, 'B'],
|
|
[4, 'Z']
|
|
])
|
|
|
|
#----------------------------------------------------------------------
|
|
|
|
def test_section_removes(self):
|
|
# Add a couple of tables and views, to trigger creation of some related items.
|
|
self.init_views_sample()
|
|
self.apply_user_action(['BulkUpdateRecord', '_grist_Views', [2], {
|
|
'layoutSpec': [
|
|
"{\"children\":[{\"children\":[{\"leaf\":10},{\"leaf\":4}]}],"
|
|
+ "\"collapsed\":[{\"leaf\":6}]}",
|
|
]
|
|
}])
|
|
|
|
self.assertViews([
|
|
View(1, sections=[
|
|
Section(1, parentKey="record", tableRef=1, fields=[
|
|
Field(1, colRef=2),
|
|
Field(2, colRef=3),
|
|
Field(3, colRef=4),
|
|
]),
|
|
]),
|
|
View(2, sections=[
|
|
Section(4, parentKey="detail", tableRef=1, fields=[
|
|
Field(10, colRef=2),
|
|
Field(11, colRef=3),
|
|
Field(12, colRef=4),
|
|
]),
|
|
Section(6, parentKey="record", tableRef=2, fields=[
|
|
Field(16, colRef=5),
|
|
Field(17, colRef=7),
|
|
Field(18, colRef=8),
|
|
]),
|
|
Section(10, parentKey='record', tableRef=3, fields=[
|
|
Field(27, colRef=10),
|
|
Field(28, colRef=11),
|
|
Field(29, colRef=12),
|
|
]),
|
|
]),
|
|
View(3, sections=[
|
|
Section(7, parentKey="chart", tableRef=1, fields=[
|
|
Field(19, colRef=2),
|
|
Field(20, colRef=3),
|
|
]),
|
|
])
|
|
])
|
|
|
|
# Remove a couple of sections. Ensure their fields get removed.
|
|
self.apply_user_action(['BulkRemoveRecord', '_grist_Views_section', [6, 10]])
|
|
|
|
self.assertViews([
|
|
View(1, sections=[
|
|
Section(1, parentKey="record", tableRef=1, fields=[
|
|
Field(1, colRef=2),
|
|
Field(2, colRef=3),
|
|
Field(3, colRef=4),
|
|
]),
|
|
]),
|
|
View(2, sections=[
|
|
Section(4, parentKey="detail", tableRef=1, fields=[
|
|
Field(10, colRef=2),
|
|
Field(11, colRef=3),
|
|
Field(12, colRef=4),
|
|
])
|
|
]),
|
|
View(3, sections=[
|
|
Section(7, parentKey="chart", tableRef=1, fields=[
|
|
Field(19, colRef=2),
|
|
Field(20, colRef=3),
|
|
]),
|
|
])
|
|
])
|
|
|
|
# Check that the view layout spec also got updated.
|
|
self.assertTableData('_grist_Views', rows='subset', cols='subset', data=[
|
|
['id', 'layoutSpec'],
|
|
[2, "{\"children\": [{\"children\": [{\"leaf\": 4}]}], \"collapsed\": []}"],
|
|
])
|
|
|
|
#----------------------------------------------------------------------
|
|
|
|
def test_field_removes(self):
|
|
self.init_views_sample()
|
|
self.apply_user_action(['BulkUpdateRecord', '_grist_Views_section', [1, 4, 6, 7], {
|
|
'layoutSpec': [
|
|
"",
|
|
"{\"children\":[{\"children\":[{\"leaf\":10},{\"leaf\":11}]},{\"leaf\":12}]}",
|
|
"{\"id\":\"d748210f-91e1-4209-8f56-c27f4f62efde\",\"type\":\"Layout\",\"children\":[{\"id"
|
|
+ "\":\"a939f6b0-c728-4b36-bdde-6bb727ce4027\",\"type\":\"Paragraph\",\"text\":\"## **F"
|
|
+ "orm Title**\",\"alignment\":\"center\"},{\"id\":\"428c5f4f-b1f8-4bb6-89e5-5cdab4fca5"
|
|
+ "d0\",\"type\":\"Paragraph\",\"text\":\"Your form description goes here.\",\"alignmen"
|
|
+ "t\":\"center\"},{\"id\":\"9bfaa427-7776-411e-9724-e351fb4d5bdf\",\"type\":\"Section"
|
|
+ "\",\"children\":[{\"id\":\"8192f1c8-918f-4b4f-b51c-c775588a6087\",\"type\":\"Paragra"
|
|
+ "ph\",\"text\":\"### **Header**\"},{\"id\":\"6005123f-069b-4b31-85d1-d8c677c53f7e\","
|
|
+ "\"type\":\"Paragraph\",\"text\":\"Description\"},{\"id\":\"444ffba4-5a99-4a3f-abb9-9"
|
|
+ "09deea5c059\",\"type\":\"Field\",\"leaf\":16},{\"id\":\"c7deaa62-3c06-45a8-aa79-122d"
|
|
+ "01c77832\",\"type\":\"Field\",\"leaf\":17},{\"id\":\"b7326be0-a73e-4cc1-8f8b-6c5033d"
|
|
+ "227e4\",\"type\":\"Field\",\"leaf\":18}]},{\"id\":\"3a70d09f-2b08-4875-a813-3432d2d0"
|
|
+ "ae3d\",\"type\":\"Submit\"}]}",
|
|
"invalid",
|
|
]
|
|
}])
|
|
|
|
# Remove a couple of fields. Ensure the layout specs of their sections get updated.
|
|
self.apply_user_action(['BulkRemoveRecord', '_grist_Views_section_field', [1, 10, 12, 16, 19]])
|
|
self.assertTableData('_grist_Views_section', rows='subset', cols='subset', data=[
|
|
['id', 'layoutSpec'],
|
|
[1, ""],
|
|
[4, "{\"children\": [{\"children\": [{\"leaf\": 11}]}]}"],
|
|
[6, "{\"children\": [{\"alignment\": \"center\", \"id\": \"a939f6b0-c728-4b36-bdde-6bb727ce4"
|
|
+ "027\", \"text\": \"## **Form Title**\", \"type\": \"Paragraph\"}, {\"alignment\": \"cen"
|
|
+ "ter\", \"id\": \"428c5f4f-b1f8-4bb6-89e5-5cdab4fca5d0\", \"text\": \"Your form descript"
|
|
+ "ion goes here.\", \"type\": \"Paragraph\"}, {\"children\": [{\"id\": \"8192f1c8-918f-4b"
|
|
+ "4f-b51c-c775588a6087\", \"text\": \"### **Header**\", \"type\": \"Paragraph\"}, {\"id\""
|
|
+ ": \"6005123f-069b-4b31-85d1-d8c677c53f7e\", \"text\": \"Description\", \"type\": \"Para"
|
|
+ "graph\"}, {\"id\": \"c7deaa62-3c06-45a8-aa79-122d01c77832\", \"leaf\": 17, \"type\": \""
|
|
+ "Field\"}, {\"id\": \"b7326be0-a73e-4cc1-8f8b-6c5033d227e4\", \"leaf\": 18, \"type\": \""
|
|
+ "Field\"}], \"id\": \"9bfaa427-7776-411e-9724-e351fb4d5bdf\", \"type\": \"Section\"}, {"
|
|
+ "\"id\": \"3a70d09f-2b08-4875-a813-3432d2d0ae3d\", \"type\": \"Submit\"}], \"id\": \"d74"
|
|
+ "8210f-91e1-4209-8f56-c27f4f62efde\", \"type\": \"Layout\"}"],
|
|
[7, "invalid"],
|
|
])
|
|
|
|
#----------------------------------------------------------------------
|
|
|
|
def test_schema_consistency_check(self):
|
|
# Verify that schema consistency check actually runs, but only when schema is affected.
|
|
|
|
self.init_views_sample()
|
|
|
|
# Replace the engine's assert_schema_consistent() method with a mocked version.
|
|
orig_method = self.engine.assert_schema_consistent
|
|
count_calls = [0]
|
|
def override(self): # pylint: disable=unused-argument
|
|
count_calls[0] += 1
|
|
# pylint: disable=not-callable
|
|
orig_method()
|
|
self.engine.assert_schema_consistent = types.MethodType(override, self.engine)
|
|
|
|
# Do a non-schema action to ensure it doesn't get called.
|
|
self.apply_user_action(['UpdateRecord', '_grist_Views', 2, {'name': 'A'}])
|
|
self.assertEqual(count_calls[0], 0)
|
|
|
|
# Do a schema action to ensure it gets called: this causes a table rename.
|
|
# 8 is id of raw view section for the Table1 table
|
|
self.apply_user_action(['UpdateRecord', '_grist_Views_section', 8, {'title': 'C'}])
|
|
self.assertEqual(count_calls[0], 1)
|
|
|
|
self.assertTableData('_grist_Tables', cols="subset", data=[
|
|
[ 'id', 'tableId', 'primaryViewId' ],
|
|
[ 1, 'Schools', 1],
|
|
[ 2, 'Schools_summary_state', 0],
|
|
[ 3, 'C', 0],
|
|
])
|
|
|
|
# Do another schema and non-schema action.
|
|
self.apply_user_action(['UpdateRecord', 'Schools', 1, {'city': 'Seattle'}])
|
|
self.assertEqual(count_calls[0], 1)
|
|
|
|
self.apply_user_action(['UpdateRecord', '_grist_Tables_column', 2, {'colId': 'city2'}])
|
|
self.assertEqual(count_calls[0], 2)
|
|
|
|
#----------------------------------------------------------------------
|
|
|
|
def test_new_column_conversions(self):
|
|
self.init_views_sample()
|
|
self.apply_user_action(['AddColumn', 'Schools', None, {}])
|
|
self.assertTableData('_grist_Tables_column', cols="subset", data=[
|
|
["id", "colId", "type", "isFormula", "formula"],
|
|
[1, "manualSort", "ManualSortPos",False, ""],
|
|
[2, "city", "Text", False, ""],
|
|
[3, "state", "Text", False, ""],
|
|
[4, "size", "Numeric", False, ""],
|
|
[13, "A", "Any", True, ""],
|
|
], rows=lambda r: r.parentId.id == 1)
|
|
self.assertTableData('Schools', cols="subset", data=[
|
|
["id", "city", "A"],
|
|
[1, "New York", None],
|
|
[2, "Colombia", None],
|
|
[3, "New York", None],
|
|
[4, "", None],
|
|
])
|
|
|
|
# Check that typing in text into the column produces a text column.
|
|
out_actions = self.apply_user_action(['UpdateRecord', 'Schools', 3, {"A": "foo"}])
|
|
self.assertTableData('_grist_Tables_column', cols="subset", rows="subset", data=[
|
|
["id", "colId", "type", "isFormula", "formula"],
|
|
[13, "A", "Text", False, ""],
|
|
])
|
|
self.assertTableData('Schools', cols="subset", data=[
|
|
["id", "city", "A" ],
|
|
[1, "New York", "" ],
|
|
[2, "Colombia", "" ],
|
|
[3, "New York", "foo" ],
|
|
[4, "", "" ],
|
|
])
|
|
|
|
# Undo, and check that typing in a number produces a numeric column.
|
|
self.apply_undo_actions(out_actions.undo)
|
|
out_actions = self.apply_user_action(['UpdateRecord', 'Schools', 3, {"A": " -17.6"}])
|
|
self.assertTableData('_grist_Tables_column', cols="subset", rows="subset", data=[
|
|
["id", "colId", "type", "isFormula", "formula"],
|
|
[13, "A", "Numeric", False, ""],
|
|
])
|
|
self.assertTableData('Schools', cols="subset", data=[
|
|
["id", "city", "A" ],
|
|
[1, "New York", 0.0 ],
|
|
[2, "Colombia", 0.0 ],
|
|
[3, "New York", -17.6 ],
|
|
[4, "", 0.0 ],
|
|
])
|
|
|
|
# Undo, and set a formula for the new column instead.
|
|
self.apply_undo_actions(out_actions.undo)
|
|
self.apply_user_action(['UpdateRecord', '_grist_Tables_column', 13, {'formula': 'len($city)'}])
|
|
self.assertTableData('_grist_Tables_column', cols="subset", rows="subset", data=[
|
|
["id", "colId", "type", "isFormula", "formula"],
|
|
[13, "A", "Any", True, "len($city)"],
|
|
])
|
|
self.assertTableData('Schools', cols="subset", data=[
|
|
["id", "city", "A" ],
|
|
[1, "New York", 8 ],
|
|
[2, "Colombia", 8 ],
|
|
[3, "New York", 8 ],
|
|
[4, "", 0 ],
|
|
])
|
|
|
|
# Convert the formula column to non-formula.
|
|
self.apply_user_action(['UpdateRecord', '_grist_Tables_column', 13, {'isFormula': False}])
|
|
self.assertTableData('_grist_Tables_column', cols="subset", rows="subset", data=[
|
|
["id", "colId", "type", "isFormula", "formula"],
|
|
[13, "A", "Numeric", False, "len($city)"],
|
|
])
|
|
self.assertTableData('Schools', cols="subset", data=[
|
|
["id", "city", "A" ],
|
|
[1, "New York", 8 ],
|
|
[2, "Colombia", 8 ],
|
|
[3, "New York", 8 ],
|
|
[4, "", 0 ],
|
|
])
|
|
|
|
# Add some more formula columns of type 'Any'.
|
|
self.apply_user_action(['AddColumn', 'Schools', None, {"formula": "1"}])
|
|
self.apply_user_action(['AddColumn', 'Schools', None, {"formula": "'x'"}])
|
|
self.apply_user_action(['AddColumn', 'Schools', None, {"formula": "$city == 'New York'"}])
|
|
self.apply_user_action(['AddColumn', 'Schools', None, {"formula": "$city=='New York' or '-'"}])
|
|
self.assertTableData('_grist_Tables_column', cols="subset", data=[
|
|
["id", "colId", "type", "isFormula", "formula"],
|
|
[1, "manualSort", "ManualSortPos",False, ""],
|
|
[2, "city", "Text", False, ""],
|
|
[3, "state", "Text", False, ""],
|
|
[4, "size", "Numeric", False, ""],
|
|
[13, "A", "Numeric", False, "len($city)"],
|
|
[14, "B", "Any", True, "1"],
|
|
[15, "C", "Any", True, "'x'"],
|
|
[16, "D", "Any", True, "$city == 'New York'"],
|
|
[17, "E", "Any", True, "$city=='New York' or '-'"],
|
|
], rows=lambda r: r.parentId.id == 1)
|
|
self.assertTableData('Schools', cols="subset", data=[
|
|
["id", "city", "A", "B", "C", "D", "E"],
|
|
[1, "New York", 8, 1, "x", True, True],
|
|
[2, "Colombia", 8, 1, "x", False, '-' ],
|
|
[3, "New York", 8, 1, "x", True, True],
|
|
[4, "", 0, 1, "x", False, '-' ],
|
|
])
|
|
|
|
# Convert all these formulas to non-formulas, and see that their types get guessed OK.
|
|
# TODO: We should also guess Int, Bool, Reference, ReferenceList, Date, and DateTime.
|
|
# TODO: It is possibly better if B became Int, and D became Bool.
|
|
self.apply_user_action(['BulkUpdateRecord', '_grist_Tables_column', [14,15,16,17],
|
|
{'isFormula': [False, False, False, False]}])
|
|
self.assertTableData('_grist_Tables_column', cols="subset", data=[
|
|
["id", "colId", "type", "isFormula", "formula"],
|
|
[1, "manualSort", "ManualSortPos",False, ""],
|
|
[2, "city", "Text", False, ""],
|
|
[3, "state", "Text", False, ""],
|
|
[4, "size", "Numeric", False, ""],
|
|
[13, "A", "Numeric", False, "len($city)"],
|
|
[14, "B", "Numeric", False, "1"],
|
|
[15, "C", "Text", False, "'x'"],
|
|
[16, "D", "Text", False, "$city == 'New York'"],
|
|
[17, "E", "Text", False, "$city=='New York' or '-'"],
|
|
], rows=lambda r: r.parentId.id == 1)
|
|
self.assertTableData('Schools', cols="subset", data=[
|
|
["id", "city", "A", "B", "C", "D", "E"],
|
|
[1, "New York", 8, 1.0, "x", "True", 'True'],
|
|
[2, "Colombia", 8, 1.0, "x", "False", '-' ],
|
|
[3, "New York", 8, 1.0, "x", "True", 'True'],
|
|
[4, "", 0, 1.0, "x", "False", '-' ],
|
|
])
|
|
|
|
#----------------------------------------------------------------------
|
|
|
|
def test_useraction_failures(self):
|
|
# Verify that when a useraction fails, we revert any changes already applied.
|
|
|
|
self.load_sample(self.sample)
|
|
|
|
# Simple failure: bad action (last argument should be a dict). It shouldn't cause any actions
|
|
# in the first place, just raise an exception about the argument being an int.
|
|
with self.assertRaisesRegex(AttributeError, r"'int'"):
|
|
self.apply_user_action(['AddColumn', 'Address', "A", 17])
|
|
|
|
# Do some successful actions, just to make sure we know what they look like.
|
|
self.engine.apply_user_actions([useractions.from_repr(ua) for ua in (
|
|
['AddColumn', 'Address', "B", {"isFormula": True}],
|
|
['UpdateRecord', 'Address', 11, {"city": "New York2"}],
|
|
)])
|
|
|
|
# More complicated: here some actions should succeed, but get reverted when a later one fails.
|
|
with self.assertRaisesRegex(AttributeError, r"'int'"):
|
|
self.engine.apply_user_actions([useractions.from_repr(ua) for ua in (
|
|
['UpdateRecord', 'Address', 11, {"city": "New York3"}],
|
|
['AddColumn', 'Address', "C", {"isFormula": True}],
|
|
['AddColumn', 'Address', "D", 17]
|
|
)])
|
|
|
|
with self.assertRaisesRegex(Exception, r"non-existent record #77"):
|
|
self.engine.apply_user_actions([useractions.from_repr(ua) for ua in (
|
|
['UpdateRecord', 'Address', 11, {"city": "New York4"}],
|
|
['UpdateRecord', 'Address', 77, {"city": "Chicago"}],
|
|
)])
|
|
|
|
# Make sure that no columns got added except the intentionally successful one.
|
|
self.assertTableData('_grist_Tables_column', cols="subset", data=[
|
|
["id", "colId", "type", "isFormula", "formula"],
|
|
[21, "city", "Text", False, ""],
|
|
[22, "B", "Any", True, ""],
|
|
], rows=lambda r: r.parentId.id == 1)
|
|
|
|
# Make sure that no columns got added here either, and the only change to "New York" is the
|
|
# one in the successful user-action.
|
|
self.assertTableData('Address', cols="all", data=[
|
|
["id", "city" , "B" ],
|
|
[11, "New York2" , None ],
|
|
[12, "Colombia" , None ],
|
|
[13, "New Haven" , None ],
|
|
[14, "West Haven", None ],
|
|
])
|
|
|
|
#----------------------------------------------------------------------
|
|
|
|
def test_pages_remove(self):
|
|
# Test that orphan pages get fixed after removing a page
|
|
|
|
self.init_views_sample()
|
|
|
|
# Moves page 2 to children of page 1.
|
|
self.apply_user_action(['BulkUpdateRecord', '_grist_Pages', [2], {'indentation': [1]}])
|
|
self.assertTableData('_grist_Pages', cols='subset', data=[
|
|
['id', 'indentation'],
|
|
[ 1, 0],
|
|
[ 2, 1],
|
|
[ 3, 0],
|
|
])
|
|
|
|
# Verify that removing page 1 fixes page 2 indentation.
|
|
self.apply_user_action(['RemoveRecord', '_grist_Pages', 1])
|
|
self.assertTableData('_grist_Pages', cols='subset', data=[
|
|
['id', 'indentation'],
|
|
[ 2, 0],
|
|
[ 3, 0],
|
|
])
|
|
|
|
# Removing last page should not fail
|
|
# Verify that removing page 1 fixes page 2 indentation.
|
|
self.apply_user_action(['RemoveRecord', '_grist_Pages', 4])
|
|
self.assertTableData('_grist_Pages', cols='subset', data=[
|
|
['id', 'indentation'],
|
|
[ 2, 0],
|
|
[ 3, 0],
|
|
])
|
|
|
|
# Removing a page that has no children should do nothing
|
|
self.apply_user_action(['RemoveRecord', '_grist_Pages', 2])
|
|
self.assertTableData('_grist_Pages', cols='subset', data=[
|
|
['id', 'indentation'],
|
|
[ 3, 0],
|
|
])
|
|
|
|
#----------------------------------------------------------------------
|
|
|
|
def test_rename_choices(self):
|
|
sample = testutil.parse_test_sample({
|
|
"SCHEMA": [
|
|
[1, "ChoiceTable", [
|
|
[1, "ChoiceColumn", "Choice", False, "", "ChoiceColumn", ""],
|
|
]],
|
|
[2, "ChoiceListTable", [
|
|
[2, "ChoiceListColumn", "ChoiceList", False, "", "ChoiceListColumn", ""],
|
|
]],
|
|
],
|
|
"DATA": {
|
|
"ChoiceTable": [
|
|
["id", "ChoiceColumn"],
|
|
[1, "a"],
|
|
[2, "b"],
|
|
[3, "c"],
|
|
[4, "d"],
|
|
[5, None],
|
|
[6, 5],
|
|
[7, [[]]],
|
|
],
|
|
"ChoiceListTable": [
|
|
["id", "ChoiceListColumn"],
|
|
[1, ["a"]],
|
|
[2, ["b"]],
|
|
[3, ["c"]],
|
|
[4, ["d"]],
|
|
[5, None],
|
|
[7, ["a", "b"]],
|
|
[8, ["b", "c"]],
|
|
[9, ["a", "c"]],
|
|
[10, ["a", "b", "c"]],
|
|
[11, 5],
|
|
[12, [[]]],
|
|
],
|
|
}
|
|
})
|
|
self.load_sample(sample)
|
|
|
|
# Renames go in a loop to make sure that works correctly
|
|
# a -> b -> c -> a -> b -> ...
|
|
renames = {"a": "b", "b": "c", "c": "a"}
|
|
out_actions_choice = self.apply_user_action(
|
|
["RenameChoices", "ChoiceTable", "ChoiceColumn", renames])
|
|
out_actions_choice_list = self.apply_user_action(
|
|
["RenameChoices", "ChoiceListTable", "ChoiceListColumn", renames])
|
|
|
|
self.assertPartialOutActions(
|
|
out_actions_choice,
|
|
{'stored':
|
|
[['BulkUpdateRecord',
|
|
'ChoiceTable',
|
|
[1, 2, 3],
|
|
{'ChoiceColumn': [u'b', u'c', u'a']}]]})
|
|
|
|
self.assertPartialOutActions(
|
|
out_actions_choice_list,
|
|
{'stored':
|
|
[['BulkUpdateRecord',
|
|
'ChoiceListTable',
|
|
[1, 2, 3, 7, 8, 9, 10],
|
|
{'ChoiceListColumn': [['L', u'b'],
|
|
['L', u'c'],
|
|
['L', u'a'],
|
|
['L', u'b', u'c'],
|
|
['L', u'c', u'a'],
|
|
['L', u'b', u'a'],
|
|
['L', u'b', u'c', u'a']]}]]})
|
|
|
|
self.assertTableData('ChoiceTable', data=[
|
|
["id", "ChoiceColumn"],
|
|
[1, "b"],
|
|
[2, "c"],
|
|
[3, "a"],
|
|
[4, "d"],
|
|
[5, None],
|
|
[6, 5],
|
|
[7, [[]]],
|
|
])
|
|
|
|
self.assertTableData('ChoiceListTable', data=[
|
|
["id", "ChoiceListColumn"],
|
|
[1, ["b"]],
|
|
[2, ["c"]],
|
|
[3, ["a"]],
|
|
[4, ["d"]],
|
|
[5, None],
|
|
[7, ["b", "c"]],
|
|
[8, ["c", "a"]],
|
|
[9, ["b", "a"]],
|
|
[10, ["b", "c", "a"]],
|
|
[11, 5],
|
|
[12, [[]]],
|
|
])
|
|
|
|
# Test filters rename
|
|
|
|
# Create new view section
|
|
self.apply_user_action(["CreateViewSection", 1, 0, "record", None, None])
|
|
|
|
# Filter it by first column
|
|
self.apply_user_action(['BulkAddRecord', '_grist_Filters', [None], {
|
|
"viewSectionRef": [1],
|
|
"colRef": [1],
|
|
"filter": [json.dumps({"included": ["b", "c"]})],
|
|
"pinned": [True],
|
|
}])
|
|
|
|
# Add the same filter for second column (to make sure it is not renamed)
|
|
self.apply_user_action(['BulkAddRecord', '_grist_Filters', [None], {
|
|
"viewSectionRef": [1],
|
|
"colRef": [2],
|
|
"filter": [json.dumps({"included": ["b", "c"]})],
|
|
"pinned": [False],
|
|
}])
|
|
|
|
# Rename choices
|
|
renames = {"b": "z", "c": "b"}
|
|
self.apply_user_action(
|
|
["RenameChoices", "ChoiceTable", "ChoiceColumn", renames])
|
|
|
|
# Test filters
|
|
self.assertTableData('_grist_Filters', data=[
|
|
["id", "colRef", "filter", "setAutoRemove", "viewSectionRef", "pinned"],
|
|
[1, 1, json.dumps({"included": ["z", "b"]}), None, 1, True],
|
|
[2, 2, json.dumps({"included": ["b", "c"]}), None, 1, False]
|
|
])
|
|
|
|
def test_add_or_update(self):
|
|
sample = testutil.parse_test_sample({
|
|
"SCHEMA": [
|
|
[1, "Table1", [
|
|
[1, "first_name", "Text", False, "", "first_name", ""],
|
|
[2, "last_name", "Text", False, "", "last_name", ""],
|
|
[3, "pet", "Text", False, "", "pet", ""],
|
|
[4, "color", "Text", False, "", "color", ""],
|
|
[5, "formula", "Text", True, "''", "formula", ""],
|
|
[6, "date", "Date", False, None, "date", ""],
|
|
]],
|
|
],
|
|
"DATA": {
|
|
"Table1": [
|
|
["id", "first_name", "last_name"],
|
|
[1, "John", "Doe"],
|
|
[2, "John", "Smith"],
|
|
],
|
|
}
|
|
})
|
|
self.load_sample(sample)
|
|
|
|
def check(require, values, options, stored):
|
|
self.assertPartialOutActions(
|
|
self.apply_user_action(["AddOrUpdateRecord", "Table1", require, values, options]),
|
|
{"stored": stored},
|
|
)
|
|
|
|
# Exactly one match, so on_many=none has no effect
|
|
check(
|
|
{"first_name": "John", "last_name": "Smith"},
|
|
{"pet": "dog", "color": "red"},
|
|
{"on_many": "none"},
|
|
[["UpdateRecord", "Table1", 2, {"color": "red", "pet": "dog"}]],
|
|
)
|
|
|
|
# Look for a record with pet=dog and change it to pet=cat
|
|
check(
|
|
{"first_name": "John", "pet": "dog"},
|
|
{"pet": "cat"},
|
|
{},
|
|
[["UpdateRecord", "Table1", 2, {"pet": "cat"}]],
|
|
)
|
|
|
|
# Two records match first_name=John, by default we only update the first
|
|
check(
|
|
{"first_name": "John"},
|
|
{"color": "blue"},
|
|
{},
|
|
[["UpdateRecord", "Table1", 1, {"color": "blue"}]],
|
|
)
|
|
|
|
# Update all matching records
|
|
check(
|
|
{"first_name": "John"},
|
|
{"color": "green"},
|
|
{"on_many": "all"},
|
|
[
|
|
["BulkUpdateRecord", "Table1", [1, 2], {"color": ["green", "green"]}],
|
|
],
|
|
)
|
|
|
|
# Update all records with empty require and allow_empty_require
|
|
check(
|
|
{},
|
|
{"color": "greener"},
|
|
{"on_many": "all", "allow_empty_require": True},
|
|
[
|
|
["BulkUpdateRecord", "Table1", [1, 2], {"color": ["greener", "greener"]}],
|
|
],
|
|
)
|
|
|
|
# Missing allow_empty_require
|
|
with self.assertRaises(ValueError):
|
|
check(
|
|
{},
|
|
{"color": "greenest"},
|
|
{},
|
|
[],
|
|
)
|
|
|
|
# Don't update any records when there's several matches
|
|
check(
|
|
{"first_name": "John"},
|
|
{"color": "yellow"},
|
|
{"on_many": "none"},
|
|
[],
|
|
)
|
|
|
|
# Invalid value of on_many
|
|
with self.assertRaises(ValueError):
|
|
check(
|
|
{"first_name": "John"},
|
|
{"color": "yellow"},
|
|
{"on_many": "other"},
|
|
[],
|
|
)
|
|
|
|
# Since there's at least one matching record and update=False, do nothing
|
|
check(
|
|
{"first_name": "John"},
|
|
{"color": "yellow"},
|
|
{"update": False},
|
|
[],
|
|
)
|
|
|
|
# Since there's no matching records and add=False, do nothing
|
|
check(
|
|
{"first_name": "John", "last_name": "Johnson"},
|
|
{"first_name": "Jack", "color": "yellow"},
|
|
{"add": False},
|
|
[],
|
|
)
|
|
|
|
# No matching record, make a new one.
|
|
# first_name=Jack in `values` overrides first_name=John in `require`
|
|
check(
|
|
{"first_name": "John", "last_name": "Johnson"},
|
|
{"first_name": "Jack", "color": "yellow"},
|
|
{},
|
|
[
|
|
["AddRecord", "Table1", 3,
|
|
{"color": "yellow", "first_name": "Jack", "last_name": "Johnson"}]
|
|
],
|
|
)
|
|
|
|
# Specifying a row ID in `require` is allowed
|
|
check(
|
|
{"first_name": "Bob", "id": 100},
|
|
{"pet": "fish"},
|
|
{},
|
|
[["AddRecord", "Table1", 100, {"first_name": "Bob", "pet": "fish"}]],
|
|
)
|
|
|
|
# Now the row already exists
|
|
check(
|
|
{"first_name": "Bob", "id": 100},
|
|
{"pet": "fish"},
|
|
{},
|
|
[],
|
|
)
|
|
|
|
# Nothing matches this `require`, but the row ID already exists
|
|
with self.assertRaises(AssertionError):
|
|
check(
|
|
{"first_name": "Alice", "id": 100},
|
|
{"pet": "fish"},
|
|
{},
|
|
[],
|
|
)
|
|
|
|
# Formula columns in `require` can't be used as values when creating records
|
|
check(
|
|
{"formula": "anything"},
|
|
{"first_name": "Alice"},
|
|
{},
|
|
[["AddRecord", "Table1", 101, {"first_name": "Alice"}]],
|
|
)
|
|
|
|
with self.assertRaises(ValueError):
|
|
# Row ID too high
|
|
check(
|
|
{"first_name": "Alice", "id": 2000000},
|
|
{"pet": "fish"},
|
|
{},
|
|
[],
|
|
)
|
|
|
|
# Check that encoded objects are decoded correctly
|
|
check(
|
|
{"date": ['d', 950400]},
|
|
{},
|
|
{},
|
|
[["AddRecord", "Table1", 102, {"date": 950400}]],
|
|
)
|
|
check(
|
|
{"date": ['d', 950400]},
|
|
{"date": ['d', 1900800]},
|
|
{},
|
|
[["UpdateRecord", "Table1", 102, {"date": 1900800}]],
|
|
)
|
|
|
|
# Empty both does nothing
|
|
check(
|
|
{},
|
|
{},
|
|
{"allow_empty_require": True},
|
|
[],
|
|
)
|
|
|
|
def test_bulk_add_or_update(self):
|
|
sample = testutil.parse_test_sample({
|
|
"SCHEMA": [
|
|
[1, "Table1", [
|
|
[1, "first_name", "Text", False, "", "first_name", ""],
|
|
[2, "last_name", "Text", False, "", "last_name", ""],
|
|
[4, "color", "Text", False, "", "color", ""],
|
|
]],
|
|
],
|
|
"DATA": {
|
|
"Table1": [
|
|
["id", "first_name", "last_name"],
|
|
[1, "John", "Doe"],
|
|
[2, "John", "Smith"],
|
|
],
|
|
}
|
|
})
|
|
self.load_sample(sample)
|
|
|
|
def check(require, values, options, stored):
|
|
self.assertPartialOutActions(
|
|
self.apply_user_action(["BulkAddOrUpdateRecord", "Table1", require, values, options]),
|
|
{"stored": stored},
|
|
)
|
|
|
|
check(
|
|
{
|
|
"first_name": [
|
|
"John",
|
|
"John",
|
|
"John",
|
|
"Bob",
|
|
],
|
|
"last_name": [
|
|
"Doe",
|
|
"Smith",
|
|
"Johnson",
|
|
"Johnson",
|
|
],
|
|
},
|
|
{
|
|
"color": [
|
|
"red",
|
|
"blue",
|
|
"green",
|
|
"yellow",
|
|
],
|
|
},
|
|
{},
|
|
[
|
|
["BulkAddRecord", "Table1", [3, 4], {
|
|
"color": ["green", "yellow"],
|
|
"first_name": ["John", "Bob"],
|
|
"last_name": ["Johnson", "Johnson"],
|
|
}],
|
|
["BulkUpdateRecord", "Table1", [1, 2], {"color": ["red", "blue"]}],
|
|
],
|
|
)
|
|
|
|
with self.assertRaises(ValueError) as cm:
|
|
check(
|
|
{"color": ["yellow"]},
|
|
{"color": ["red", "blue", "green"]},
|
|
{},
|
|
[],
|
|
)
|
|
self.assertEqual(
|
|
str(cm.exception),
|
|
'Value lists must all have the same length, '
|
|
'got {"col_values color": 3, "require color": 1}',
|
|
)
|
|
|
|
with self.assertRaises(ValueError) as cm:
|
|
check(
|
|
{
|
|
"first_name": [
|
|
"John",
|
|
"John",
|
|
],
|
|
"last_name": [
|
|
"Doe",
|
|
],
|
|
},
|
|
{},
|
|
{},
|
|
[],
|
|
)
|
|
self.assertEqual(
|
|
str(cm.exception),
|
|
'Value lists must all have the same length, '
|
|
'got {"require first_name": 2, "require last_name": 1}',
|
|
)
|
|
|
|
with self.assertRaises(ValueError) as cm:
|
|
check(
|
|
{
|
|
"first_name": [
|
|
"John",
|
|
"John",
|
|
],
|
|
"last_name": [
|
|
"Doe",
|
|
"Doe",
|
|
],
|
|
},
|
|
{},
|
|
{},
|
|
[],
|
|
)
|
|
self.assertEqual(
|
|
str(cm.exception),
|
|
"require values must be unique",
|
|
)
|
|
|
|
def test_reference_lookup(self):
|
|
sample = testutil.parse_test_sample({
|
|
"SCHEMA": [
|
|
[1, "Table1", [
|
|
[1, "name", "Text", False, "", "name", ""],
|
|
[2, "ref", "Ref:Table1", False, "", "ref", ""],
|
|
[3, "reflist", "RefList:Table1", False, "", "reflist", ""],
|
|
]],
|
|
],
|
|
"DATA": {
|
|
"Table1": [
|
|
["id", "name"],
|
|
[1, "a"],
|
|
[2, "b"],
|
|
],
|
|
}
|
|
})
|
|
self.load_sample(sample)
|
|
self.update_record("_grist_Tables_column", 2, visibleCol=1)
|
|
|
|
# Normal case
|
|
out_actions = self.apply_user_action(
|
|
["UpdateRecord", "Table1", 1, {"ref": ["l", "b", {"column": "name"}]}])
|
|
self.assertPartialOutActions(out_actions, {'stored': [
|
|
["UpdateRecord", "Table1", 1, {"ref": 2}]]})
|
|
|
|
# Use ref.visibleCol (name) as default lookup column
|
|
out_actions = self.apply_user_action(
|
|
["UpdateRecord", "Table1", 2, {"ref": ["l", "a"]}])
|
|
self.assertPartialOutActions(out_actions, {'stored': [
|
|
["UpdateRecord", "Table1", 2, {"ref": 1}]]})
|
|
|
|
# No match found, generate alttext from value
|
|
out_actions = self.apply_user_action(
|
|
["UpdateRecord", "Table1", 2, {"ref": ["l", "foo", {"column": "name"}]}])
|
|
self.assertPartialOutActions(out_actions, {'stored': [
|
|
["UpdateRecord", "Table1", 2, {"ref": "foo"}]]})
|
|
|
|
# No match found, use provided alttext
|
|
out_actions = self.apply_user_action(
|
|
["UpdateRecord", "Table1", 2, {"ref": ["l", "foo", {"column": "name", "raw": "alt"}]}])
|
|
self.assertPartialOutActions(out_actions, {'stored': [
|
|
["UpdateRecord", "Table1", 2, {"ref": "alt"}]]})
|
|
|
|
# Normal case, adding instead of updating
|
|
out_actions = self.apply_user_action(
|
|
["AddRecord", "Table1", 3,
|
|
{"ref": ["l", "b", {"column": "name"}],
|
|
"name": "c"}])
|
|
self.assertPartialOutActions(out_actions, {'stored': [
|
|
["AddRecord", "Table1", 3,
|
|
{"ref": 2,
|
|
"name": "c"}]]})
|
|
|
|
# Testing reflist and bulk action
|
|
out_actions = self.apply_user_action(
|
|
["BulkUpdateRecord", "Table1", [1, 2, 3],
|
|
{"reflist": [
|
|
["l", "c", {"column": "name"}], # value gets wrapped in list automatically
|
|
["l", ["a", "b"], {"column": "name"}], # normal case
|
|
# "a" matches but "foo" doesn't so the whole thing fails
|
|
["l", ["a", "foo"], {"column": "name", "raw": "alt"}],
|
|
]}])
|
|
self.assertPartialOutActions(out_actions, {'stored': [
|
|
["BulkUpdateRecord", "Table1", [1, 2, 3],
|
|
{"reflist": [
|
|
["L", 3],
|
|
["L", 1, 2],
|
|
"alt",
|
|
]}]]})
|
|
|
|
self.assertTableData('Table1', data=[
|
|
["id", "name", "ref", "reflist"],
|
|
[1, "a", 2, [3]],
|
|
[2, "b", "alt", [1, 2]],
|
|
[3, "c", 2, "alt"],
|
|
])
|
|
|
|
# 'id' is used as the default visibleCol
|
|
out_actions = self.apply_user_action(
|
|
["BulkUpdateRecord", "Table1", [1, 2],
|
|
{"reflist": [
|
|
["l", 2],
|
|
["l", 999], # this row ID doesn't exist
|
|
]}])
|
|
self.assertPartialOutActions(out_actions, {'stored': [
|
|
["BulkUpdateRecord", "Table1", [1, 2],
|
|
{"reflist": [
|
|
["L", 2],
|
|
"999",
|
|
]}]]})
|
|
|
|
def test_num_rows(self):
|
|
self.load_sample(testutil.parse_test_sample({
|
|
"SCHEMA": [
|
|
[1, "Address", [
|
|
[21, "city", "Text", False, "", "", ""],
|
|
]],
|
|
],
|
|
"DATA": {
|
|
}
|
|
}))
|
|
|
|
table = self.engine.tables["Address"]
|
|
for i in range(20):
|
|
self.add_record("Address", None)
|
|
self.assertEqual(i + 1, table._num_rows())
|
|
self.assertEqual({1: i + 1, "total": i + 1}, self.engine.count_rows())
|
|
|
|
def test_raw_view_section_restrictions(self):
|
|
# load_sample handles loading basic metadata, but doesn't create any view sections
|
|
self.load_sample(self.sample)
|
|
# Create a new table which automatically gets a raw view section
|
|
self.apply_user_action(["AddEmptyTable", None])
|
|
|
|
# Note the row IDs of the raw view section (2) and fields (4, 5, 6)
|
|
self.assertTableData('_grist_Views_section', cols="subset", data=[
|
|
["id", "parentId", "tableRef"],
|
|
[1, 1, 2],
|
|
[2, 0, 2], # the raw view section
|
|
[3, 0, 2], # the record card view section
|
|
])
|
|
self.assertTableData('_grist_Views_section_field', cols="subset", data=[
|
|
["id", "parentId"],
|
|
[1, 1],
|
|
[2, 1],
|
|
[3, 1],
|
|
|
|
# the raw view section
|
|
[4, 2],
|
|
[5, 2],
|
|
[6, 2],
|
|
|
|
# the record card view section
|
|
[7, 3],
|
|
[8, 3],
|
|
[9, 3],
|
|
])
|
|
|
|
# Test that the records cannot be removed by normal user actions
|
|
with self.assertRaisesRegex(ValueError, "Cannot remove raw view section$"):
|
|
self.apply_user_action(["RemoveRecord", '_grist_Views_section', 2])
|
|
with self.assertRaisesRegex(ValueError, "Cannot remove raw view section field$"):
|
|
self.apply_user_action(["RemoveRecord", '_grist_Views_section_field', 4])
|
|
|
|
# and most of their column values can't be changed
|
|
with self.assertRaisesRegex(ValueError, "Cannot modify raw view section$"):
|
|
self.apply_user_action(["UpdateRecord", '_grist_Views_section', 2, {"parentId": 1}])
|
|
with self.assertRaisesRegex(ValueError, "Cannot modify raw view section fields$"):
|
|
self.apply_user_action(["UpdateRecord", '_grist_Views_section_field', 5, {"parentId": 1}])
|
|
|
|
# Confirm that the records are unchanged
|
|
self.assertTableData('_grist_Views_section', cols="subset", data=[
|
|
["id", "parentId", "tableRef"],
|
|
[1, 1, 2],
|
|
[2, 0, 2], # the raw view section
|
|
[3, 0, 2], # the record card view section
|
|
])
|
|
self.assertTableData('_grist_Views_section_field', cols="subset", data=[
|
|
["id", "parentId"],
|
|
[1, 1],
|
|
[2, 1],
|
|
[3, 1],
|
|
|
|
# the raw view section
|
|
[4, 2],
|
|
[5, 2],
|
|
[6, 2],
|
|
|
|
# the record card view section
|
|
[7, 3],
|
|
[8, 3],
|
|
[9, 3],
|
|
])
|
|
|
|
def test_record_card_view_section_restrictions(self):
|
|
self.load_sample(self.sample)
|
|
self.apply_user_action(["AddEmptyTable", None])
|
|
|
|
# Check that record card view sections cannot be removed by normal user actions.
|
|
with self.assertRaisesRegex(ValueError, "Cannot remove record card view section$"):
|
|
self.apply_user_action(["RemoveRecord", '_grist_Views_section', 3])
|
|
|
|
# Check that most of their column values can't be changed.
|
|
with self.assertRaisesRegex(ValueError, "Cannot modify record card view section$"):
|
|
self.apply_user_action(["UpdateRecord", '_grist_Views_section', 3, {"parentId": 1}])
|
|
with self.assertRaisesRegex(ValueError, "Cannot modify record card view section fields$"):
|
|
self.apply_user_action(["UpdateRecord", '_grist_Views_section_field', 9, {"parentId": 1}])
|
|
|
|
# Make sure nothing got removed or updated.
|
|
self.assertTableData('_grist_Views_section', cols="subset", data=[
|
|
["id", "parentId", "tableRef"],
|
|
[1, 1, 2],
|
|
[2, 0, 2],
|
|
[3, 0, 2],
|
|
])
|
|
self.assertTableData('_grist_Views_section_field', cols="subset", data=[
|
|
["id", "parentId"],
|
|
[1, 1],
|
|
[2, 1],
|
|
[3, 1],
|
|
[4, 2],
|
|
[5, 2],
|
|
[6, 2],
|
|
[7, 3],
|
|
[8, 3],
|
|
[9, 3],
|
|
])
|
|
|
|
def test_update_current_time(self):
|
|
self.load_sample(self.sample)
|
|
self.apply_user_action(["AddEmptyTable", None])
|
|
self.add_column('Table1', 'now', isFormula=True, formula='NOW()', type='Any')
|
|
|
|
# No records with NOW() in a formula yet, so this action should have no effect at all.
|
|
out_actions = self.apply_user_action(["UpdateCurrentTime"])
|
|
self.assertOutActions(out_actions, {})
|
|
|
|
class FakeDatetime(object):
|
|
counter = 0
|
|
|
|
@classmethod
|
|
def now(cls, *_):
|
|
cls.counter += 1
|
|
return cls.counter
|
|
|
|
import datetime
|
|
original = datetime.datetime
|
|
# This monkeypatch depends on NOW() using `import datetime`
|
|
# as opposed to `from datetime import datetime`
|
|
datetime.datetime = FakeDatetime
|
|
|
|
def check(expected_now):
|
|
self.assertEqual(expected_now, FakeDatetime.counter)
|
|
self.assertTableData('Table1', cols="subset", data=[
|
|
["id", "now"],
|
|
[1, expected_now],
|
|
])
|
|
|
|
try:
|
|
# The counter starts at 0. Adding an initial record calls FakeDatetime.now() for the 1st time.
|
|
# The call increments the counter to 1 before returning.
|
|
self.add_record('Table1')
|
|
check(1)
|
|
|
|
# Testing that unrelated actions don't change the time
|
|
self.apply_user_action(["AddEmptyTable", None])
|
|
self.add_record("Table2")
|
|
self.apply_user_action(["Calculate"]) # only recalculates for fresh docs
|
|
check(1)
|
|
|
|
# Actually testing that the time is updated as requested
|
|
self.apply_user_action(["UpdateCurrentTime"])
|
|
check(2)
|
|
out_actions = self.apply_user_action(["UpdateCurrentTime"])
|
|
check(3)
|
|
self.assertOutActions(out_actions, {
|
|
"direct": [False],
|
|
"stored": [["UpdateRecord", "Table1", 1, {"now": 3}]],
|
|
"undo": [["UpdateRecord", "Table1", 1, {"now": 2}]],
|
|
})
|
|
finally:
|
|
# Revert the monkeypatch
|
|
datetime.datetime = original
|
|
|
|
def test_duplicate_table(self):
|
|
self.load_sample(self.sample)
|
|
|
|
# Create a new table, Table1, and populate it with some data.
|
|
self.apply_user_action(['AddEmptyTable', None])
|
|
self.apply_user_action(['AddColumn', 'Table1', None, {
|
|
'formula': '$B * 100 + len(Table1.all)',
|
|
}])
|
|
self.add_column('Table1', 'E',
|
|
type='DateTime:UTC', isFormula=False, formula="NOW()", recalcWhen=RecalcWhen.MANUAL_UPDATES)
|
|
self.apply_user_action(['AddColumn', 'Table1', None, {
|
|
'type': 'Ref:Address',
|
|
'visibleCol': 21,
|
|
}])
|
|
self.apply_user_action(['AddColumn', 'Table1', None, {
|
|
'type': 'Ref:Table1',
|
|
'visibleCol': 23,
|
|
}])
|
|
self.apply_user_action(['AddColumn', 'Table1', None, {
|
|
'type': 'RefList:Table1',
|
|
'visibleCol': 23,
|
|
}])
|
|
self.apply_user_action(['BulkAddRecord', 'Table1', [1, 2, 3, 4], {
|
|
'A': ['Foo', 'Bar', 'Baz', ''],
|
|
'B': [123, 456, 789, 0],
|
|
'C': ['', '', '', ''],
|
|
'F': [11, 12, 0, 0],
|
|
'G': [1, 2, 0, 0],
|
|
'H': [['L', 1, 2], ['L', 1], None, None],
|
|
}])
|
|
|
|
# Add a row conditional style.
|
|
self.apply_user_action(['AddEmptyRule', 'Table1', 0, 0])
|
|
rules = self.engine.docmodel.tables.lookupOne(tableId='Table1').rawViewSectionRef.rules
|
|
rule = list(rules)[0]
|
|
self.apply_user_action(['UpdateRecord', '_grist_Tables_column', rule.id, {
|
|
'formula': 'rec.id % 2 == 0',
|
|
}])
|
|
|
|
# Add a column conditional style.
|
|
self.apply_user_action(['AddEmptyRule', 'Table1', 0, 23])
|
|
rules = self.engine.docmodel.columns.table.get_record(23).rules
|
|
rule = list(rules)[0]
|
|
self.apply_user_action(['UpdateRecord', '_grist_Tables_column', rule.id, {
|
|
'formula': '$A == "Foo"',
|
|
}])
|
|
|
|
# Add a column and widget description.
|
|
self.apply_user_action(['UpdateRecord', '_grist_Tables_column', 23, {
|
|
'description': 'A column description.',
|
|
}])
|
|
self.apply_user_action(['UpdateRecord', '_grist_Views_section', 2, {
|
|
'description': 'A widget description.',
|
|
}])
|
|
|
|
# Duplicate Table1 as Foo without including any of its data.
|
|
self.apply_user_action(['DuplicateTable', 'Table1', 'Foo', False])
|
|
|
|
# Check that the correct table and options were duplicated.
|
|
existing_table = Table(2, 'Table1', primaryViewId=1, summarySourceTable=0, columns=[
|
|
Column(22, 'manualSort', 'ManualSortPos', isFormula=False, formula='', summarySourceCol=0),
|
|
Column(23, 'A', 'Text', isFormula=False, formula='', summarySourceCol=0),
|
|
Column(24, 'B', 'Numeric', isFormula=False, formula='', summarySourceCol=0),
|
|
Column(25, 'C', 'Any', isFormula=True, formula='', summarySourceCol=0),
|
|
Column(26, 'D', 'Any', isFormula=True, formula='$B * 100 + len(Table1.all)',
|
|
summarySourceCol=0),
|
|
Column(27, 'E', 'DateTime:UTC', isFormula=False, formula='NOW()',
|
|
summarySourceCol=0),
|
|
Column(28, 'F', 'Ref:Address', isFormula=False, formula='', summarySourceCol=0),
|
|
Column(29, 'G', 'Ref:Table1', isFormula=False, formula='', summarySourceCol=0),
|
|
Column(30, 'H', 'RefList:Table1', isFormula=False, formula='', summarySourceCol=0),
|
|
Column(31, 'gristHelper_RowConditionalRule', 'Any', isFormula=True,
|
|
formula='rec.id % 2 == 0', summarySourceCol=0),
|
|
Column(32, 'gristHelper_ConditionalRule', 'Any', isFormula=True, formula='$A == \"Foo\"',
|
|
summarySourceCol=0),
|
|
])
|
|
duplicated_table = Table(3, 'Foo', primaryViewId=0, summarySourceTable=0, columns=[
|
|
Column(33, 'manualSort', 'ManualSortPos', isFormula=False, formula='', summarySourceCol=0),
|
|
Column(34, 'A', 'Text', isFormula=False, formula='', summarySourceCol=0),
|
|
Column(35, 'B', 'Numeric', isFormula=False, formula='', summarySourceCol=0),
|
|
Column(36, 'C', 'Any', isFormula=True, formula='', summarySourceCol=0),
|
|
Column(37, 'D', 'Any', isFormula=True, formula='$B * 100 + len(Foo.all)',
|
|
summarySourceCol=0),
|
|
Column(38, 'E', 'DateTime:UTC', isFormula=False, formula='NOW()',
|
|
summarySourceCol=0),
|
|
Column(39, 'F', 'Ref:Address', isFormula=False, formula='', summarySourceCol=0),
|
|
Column(40, 'G', 'Ref:Foo', isFormula=False, formula='', summarySourceCol=0),
|
|
Column(41, 'H', 'RefList:Foo', isFormula=False, formula='', summarySourceCol=0),
|
|
Column(42, 'gristHelper_ConditionalRule', 'Any', isFormula=True, formula='$A == \"Foo\"',
|
|
summarySourceCol=0),
|
|
Column(43, 'gristHelper_RowConditionalRule', 'Any', isFormula=True,
|
|
formula='rec.id % 2 == 0', summarySourceCol=0),
|
|
])
|
|
self.assertTables([self.starting_table, existing_table, duplicated_table])
|
|
self.assertTableData('Foo', data=[
|
|
["id", "A", "B", "C", "D", "E", "F", "G", "H", "gristHelper_ConditionalRule",
|
|
"gristHelper_RowConditionalRule", "manualSort"],
|
|
])
|
|
self.assertTableData('_grist_Tables_column', rows='subset', cols='subset', data=[
|
|
['id', 'description'],
|
|
[23, 'A column description.'],
|
|
[34, 'A column description.'],
|
|
])
|
|
self.assertTableData('_grist_Views_section', rows='subset', cols='subset', data=[
|
|
['id', 'description'],
|
|
[2, 'A widget description.'],
|
|
[4, 'A widget description.'],
|
|
])
|
|
|
|
# Duplicate Table1 as FooData and include all of its data.
|
|
self.apply_user_action(['DuplicateTable', 'Table1', 'FooData', True])
|
|
|
|
# Check that the correct table, options, and data were duplicated.
|
|
duplicated_table_with_data = Table(4, 'FooData', primaryViewId=0, summarySourceTable=0,
|
|
columns=[
|
|
Column(44, 'manualSort', 'ManualSortPos', isFormula=False, formula='', summarySourceCol=0),
|
|
Column(45, 'A', 'Text', isFormula=False, formula='', summarySourceCol=0),
|
|
Column(46, 'B', 'Numeric', isFormula=False, formula='', summarySourceCol=0),
|
|
Column(47, 'C', 'Any', isFormula=True, formula='', summarySourceCol=0),
|
|
Column(48, 'D', 'Any', isFormula=True, formula='$B * 100 + len(FooData.all)',
|
|
summarySourceCol=0),
|
|
Column(49, 'E', 'DateTime:UTC', isFormula=False, formula='NOW()',
|
|
summarySourceCol=0),
|
|
Column(50, 'F', 'Ref:Address', isFormula=False, formula='', summarySourceCol=0),
|
|
Column(51, 'G', 'Ref:FooData', isFormula=False, formula='', summarySourceCol=0),
|
|
Column(52, 'H', 'RefList:FooData', isFormula=False, formula='', summarySourceCol=0),
|
|
Column(53, 'gristHelper_ConditionalRule', 'Any', isFormula=True, formula='$A == \"Foo\"',
|
|
summarySourceCol=0),
|
|
Column(54, 'gristHelper_RowConditionalRule', 'Any', isFormula=True,
|
|
formula='rec.id % 2 == 0', summarySourceCol=0),
|
|
]
|
|
)
|
|
self.assertTables([
|
|
self.starting_table, existing_table, duplicated_table, duplicated_table_with_data])
|
|
self.assertTableData('Foo', data=[
|
|
["id", "A", "B", "C", "D", "E", "F", "G", "H", "gristHelper_ConditionalRule",
|
|
"gristHelper_RowConditionalRule", "manualSort"],
|
|
], rows="subset")
|
|
self.assertTableData('FooData', data=[
|
|
["id", "A", "B", "C", "D", "F", "G", "H", "gristHelper_ConditionalRule",
|
|
"gristHelper_RowConditionalRule", "manualSort"],
|
|
[1, 'Foo', 123, None, 12304.0, 11, 1, [1, 2], True, False, 1.0],
|
|
[2, 'Bar', 456, None, 45604.0, 12, 2, [1], False, True, 2.0],
|
|
[3, 'Baz', 789, None, 78904.0, 0, 0, None, False, False, 3.0],
|
|
[4, '', 0, None, 4.0, 0, 0, None, False, True, 4.0],
|
|
], cols="subset")
|
|
|
|
# Check that values for the duplicated trigger formula were not re-calculated.
|
|
existing_times = self.engine.fetch_table('Table1').columns['E']
|
|
duplicated_times = self.engine.fetch_table('FooData').columns['E']
|
|
self.assertEqual(existing_times, duplicated_times)
|
|
|
|
def test_duplicate_table_untie_col_id_bug(self):
|
|
# This test case verifies a bug fix: when a column doesn't match its label despite
|
|
# untieColIdFromLabel being False (which is possible), ensure that duplicating still works.
|
|
|
|
self.load_sample(self.sample)
|
|
|
|
# This is the problem situation: "State2" doesn't match "State". It can happen legitimately in
|
|
# the wild if a second column labeled "State" is added, and then the first one removed.
|
|
self.apply_user_action(['AddTable', 'Table1', [
|
|
{'id': 'State2', 'type': 'Text', 'label': 'State'}
|
|
]])
|
|
self.apply_user_action(['BulkAddRecord', 'Table1', [1], {
|
|
'State2': ['NY'],
|
|
}])
|
|
self.apply_user_action(['DuplicateTable', 'Table1', 'Foo', True])
|
|
self.assertTableData('Table1', data=[["id", "State2", 'manualSort'], [1, 'NY', 1.0]])
|
|
self.assertTableData('Foo', data=[["id", "State2", 'manualSort'], [1, 'NY', 1.0]])
|
|
|
|
def test_duplicate_table_record_card(self):
|
|
self.load_sample(self.sample)
|
|
self.apply_user_action(['AddEmptyTable', None])
|
|
self.apply_user_action(['AddColumn', 'Table1', None, {
|
|
'type': 'Ref:Table1',
|
|
'visibleCol': 23,
|
|
}])
|
|
self.apply_user_action(['AddColumn', 'Table1', None, {
|
|
'type': 'RefList:Table1',
|
|
'visibleCol': 24,
|
|
}])
|
|
self.apply_user_action(['BulkUpdateRecord', '_grist_Views_section_field', [11, 13], {
|
|
'visibleCol': [23, 24],
|
|
}])
|
|
self.apply_user_action(['UpdateRecord', '_grist_Views_section', 3, {
|
|
'layoutSpec': '{"children":[{"children":[{"leaf":7},{"leaf":8}]},{"leaf":9},{"leaf":11}]}',
|
|
'options': '{"verticalGridlines":true,"horizontalGridlines":true,"zebraStripes":false,' +
|
|
'"customView":"","numFrozen":0,"disabled":true}',
|
|
'theme': 'compact',
|
|
}])
|
|
self.apply_user_action(['DuplicateTable', 'Table1', 'Foo', False])
|
|
|
|
self.assertTableData('_grist_Views_section', rows="subset", cols="subset", data=[
|
|
["id", "parentId", "tableRef", "layoutSpec", "options", "theme"],
|
|
# The original record card section.
|
|
[3, 0, 2, '{"children":[{"children":[{"leaf":7},{"leaf":8}]},{"leaf":9},{"leaf":11}]}',
|
|
'{"verticalGridlines":true,"horizontalGridlines":true,"zebraStripes":false,' +
|
|
'"customView":"","numFrozen":0,"disabled":true}', 'compact'],
|
|
# The duplicated record card section.
|
|
[5, 0, 3,
|
|
'{"children": [{"children": [{"leaf": 19}, {"leaf": 20}]}, {"leaf": 21}, ' +
|
|
'{"leaf": 22}]}',
|
|
'{"verticalGridlines":true,"horizontalGridlines":true,"zebraStripes":false,' +
|
|
'"customView":"","numFrozen":0,"disabled":true}', 'compact'],
|
|
])
|
|
self.assertTableData('_grist_Views_section_field', rows="subset", cols="subset", data=[
|
|
["id", "parentId", "parentPos", "visibleCol"],
|
|
# The original record card fields.
|
|
[7, 3, 7.0, 0],
|
|
[8, 3, 8.0, 0],
|
|
[9, 3, 9.0, 0],
|
|
[11, 3, 11.0, 23],
|
|
[13, 3, 13.0, 24],
|
|
[19, 5, 6.5, 0],
|
|
[20, 5, 7.5, 0],
|
|
[21, 5, 8.5, 0],
|
|
[22, 5, 10.5, 29],
|
|
[23, 5, 12.5, 30],
|
|
])
|