(core) Allow duplicating tables from Raw Data page

Summary:
Adds a "Duplicate Table" menu option to the tables listed on
the Raw Data page. Clicking it opens a dialog that allows you to
make a copy of the table (with or without its data).

Test Plan: Python, server, and browser tests.

Reviewers: jarek, paulfitz

Reviewed By: jarek, paulfitz

Subscribers: jarek

Differential Revision: https://phab.getgrist.com/D3619
This commit is contained in:
George Gevoian
2022-09-28 16:01:46 -07:00
parent 0eb1fec3d7
commit cd64237dad
7 changed files with 459 additions and 27 deletions

View File

@@ -13,7 +13,7 @@ ColInfo = namedtuple('ColInfo', ('colId', 'type', 'isFormula', 'formula',
'widgetOptions', 'label'))
def _make_col_info(col=None, **values):
def make_col_info(col=None, **values):
"""Return a ColInfo() with the given fields, optionally copying values from the given column."""
for key in ColInfo._fields:
values.setdefault(key, getattr(col, key) if col else None)
@@ -21,11 +21,11 @@ def _make_col_info(col=None, **values):
def _make_sum_col_info(col):
"""Return a ColInfo() for the sum formula column for column col."""
return _make_col_info(col=col, isFormula=True,
return make_col_info(col=col, isFormula=True,
formula='SUM($group.%s)' % col.colId)
def _get_colinfo_dict(col_info, with_id=False):
def get_colinfo_dict(col_info, with_id=False):
"""Return a dict suitable to use with AddColumn or AddTable (when with_id=True) actions."""
col_values = {k: v for k, v in six.iteritems(col_info._asdict())
if v is not None and k != 'colId'}
@@ -108,7 +108,7 @@ def decode_summary_table_name(summary_table_info):
def _group_colinfo(source_table):
"""Returns ColInfo() for the 'group' column that must be present in every summary table."""
return _make_col_info(colId='group', type='RefList:%s' % source_table.tableId,
return make_col_info(colId='group', type='RefList:%s' % source_table.tableId,
isFormula=True, formula='table.getSummarySourceGroup(rec)')
@@ -171,7 +171,7 @@ class SummaryActions(object):
yield col
else:
result = self.useractions.doAddColumn(table.tableId, ci.colId,
_get_colinfo_dict(ci, with_id=False))
get_colinfo_dict(ci, with_id=False))
yield self.docmodel.columns.table.get_record(result['colRef'])
@@ -185,7 +185,7 @@ class SummaryActions(object):
key = tuple(sorted(int(c) for c in source_groupby_columns))
groupby_colinfo = [
_make_col_info(
make_col_info(
col=c,
isFormula=False,
formula='',
@@ -200,7 +200,7 @@ class SummaryActions(object):
groupby_col_ids = [c.colId for c in groupby_colinfo]
result = self.useractions.doAddTable(
encode_summary_table_name(source_table.tableId, groupby_col_ids),
[_get_colinfo_dict(ci, with_id=True) for ci in groupby_colinfo + formula_colinfo],
[get_colinfo_dict(ci, with_id=True) for ci in groupby_colinfo + formula_colinfo],
summarySourceTableRef=source_table.id,
raw_section=True)
summary_table = self.docmodel.tables.table.get_record(result['id'])
@@ -236,7 +236,7 @@ class SummaryActions(object):
if srcCol in source_groupby_colset:
prev_group_cols.append(col)
elif col.isFormula and col.colId not in groupby_colids:
formula_colinfo.append(_make_col_info(col))
formula_colinfo.append(make_col_info(col))
else:
# if user is removing a numeric column from the group by columns we must add it back as a
# sum formula column
@@ -326,7 +326,7 @@ class SummaryActions(object):
"""
c = self._find_sister_column(source_table, col.colId)
if c:
all_colinfo.append(_make_col_info(col=c))
all_colinfo.append(make_col_info(col=c))
elif col.type in ('Int', 'Numeric'):
all_colinfo.append(_make_sum_col_info(col))
@@ -349,7 +349,7 @@ class SummaryActions(object):
# 'count' was already added (which we would then prefer as presumably more useful). We add the
# default 'count' right after 'group', to make it the first of the visible formula columns.
if not any(c.colId == 'count' for c in all_colinfo):
all_colinfo.insert(1, _make_col_info(colId='count', type='Int',
all_colinfo.insert(1, make_col_info(colId='count', type='Int',
isFormula=True, formula='len($group)'))
return all_colinfo
@@ -379,7 +379,7 @@ class SummaryActions(object):
field_col_recs = [f.colRef for f in fields]
# Prepare the column info for each column.
col_info = [_make_col_info(col=c) for c in field_col_recs if c.colId != 'group']
col_info = [make_col_info(col=c) for c in field_col_recs if c.colId != 'group']
# Prepare the 'group' column, which is that one column that's different from the original.
group_args = ', '.join(
@@ -393,12 +393,12 @@ class SummaryActions(object):
)
for c in field_col_recs if c.summarySourceCol
)
col_info.append(_make_col_info(colId='group', type='RefList:%s' % source_table_id,
col_info.append(make_col_info(colId='group', type='RefList:%s' % source_table_id,
isFormula=True,
formula='%s.lookupRecords(%s)' % (source_table_id, group_args)))
# Create the new table.
res = self.useractions.AddTable(None, [_get_colinfo_dict(ci, with_id=True) for ci in col_info])
res = self.useractions.AddTable(None, [get_colinfo_dict(ci, with_id=True) for ci in col_info])
new_table = self.docmodel.tables.table.get_record(res["id"])
# Remember the original table, which we need later e.g. to adjust the sort spec (sortColRefs).

View File

@@ -6,6 +6,7 @@ import useractions
import testutil
import test_engine
from test_engine import Table, Column, View, Section, Field
from schema import RecalcWhen
log = logger.Logger(__name__, logger.INFO)
@@ -1497,3 +1498,137 @@ class TestUserActions(test_engine.EngineTestCase):
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"',
}])
# 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"],
])
# 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)

View File

@@ -1694,7 +1694,14 @@ class UserActions(object):
@useraction
def AddEmptyRule(self, table_id, field_ref, col_ref):
"""
Adds empty conditional style rule to a field or column.
Adds an empty conditional style rule to a field, column, or raw view section.
"""
self.doAddRule(table_id, field_ref, col_ref)
def doAddRule(self, table_id, field_ref, col_ref, formula=''):
"""
Adds a conditional style rule to a field, column, or raw view section.
"""
assert table_id, "table_id is required"
@@ -1711,7 +1718,7 @@ class UserActions(object):
col_info = self.AddHiddenColumn(table_id, col_name, {
"type": "Any",
"isFormula": True,
"formula": ''
"formula": formula
})
new_rule = col_info['colRef']
existing_rules = rule_owner.rules._get_encodable_row_ids() if rule_owner.rules else []
@@ -1846,6 +1853,98 @@ class UserActions(object):
return table_rec.tableId
@useraction
def DuplicateTable(self, existing_table_id, new_table_id, include_data=False):
if is_hidden_table(existing_table_id):
raise ValueError('Cannot duplicate a hidden table')
existing_table = self._docmodel.get_table_rec(existing_table_id)
if existing_table.summarySourceTable:
raise ValueError('Cannot duplicate a summary table')
# Copy the columns from the raw view section to a new table.
raw_section = existing_table.rawViewSectionRef
raw_section_cols = [f.colRef for f in raw_section.fields]
col_info = [summary.make_col_info(col=c) for c in raw_section_cols]
columns = [summary.get_colinfo_dict(ci, with_id=True) for ci in col_info]
result = self.doAddTable(
new_table_id,
columns,
manual_sort=True,
primary_view=False,
raw_section=True,
)
new_table_id = result['table_id']
new_raw_section = self._docmodel.get_table_rec(new_table_id).rawViewSectionRef
# Copy view section options to the new raw view section.
self._docmodel.update([new_raw_section], options=raw_section.options)
old_to_new_col_refs = {}
for existing_field, new_field in zip(raw_section.fields, new_raw_section.fields):
old_to_new_col_refs[existing_field.colRef.id] = new_field.colRef
formula_updates = self._prepare_formula_renames({(existing_table_id, None): new_table_id})
for existing_field, new_field in zip(raw_section.fields, new_raw_section.fields):
existing_column = existing_field.colRef
new_column = new_field.colRef
new_type = existing_column.type
new_visible_col = existing_column.visibleCol
if new_type.startswith('Ref'):
# If this is a self-reference column, point it to the new table.
prefix, ref_table_id = new_type.split(':')[:2]
if ref_table_id == existing_table_id:
new_type = prefix + ':' + new_table_id
new_visible_col = old_to_new_col_refs.get(new_visible_col.id, 0)
new_recalc_deps = existing_column.recalcDeps
if new_recalc_deps:
new_recalc_deps = [encode_object([old_to_new_col_refs[colRef.id].id
for colRef in new_recalc_deps])]
# Copy column settings to the new columns.
self._docmodel.update(
[new_column],
type=new_type,
visibleCol=new_visible_col,
untieColIdFromLabel=existing_column.untieColIdFromLabel,
recalcWhen=existing_column.recalcWhen,
recalcDeps=new_recalc_deps,
formula=formula_updates.get(new_column, existing_column.formula),
)
self.maybe_copy_display_formula(existing_column, new_column)
# Copy field settings to the new fields.
self._docmodel.update(
[new_field],
parentPos=existing_field.parentPos,
width=existing_field.width,
)
if existing_column.rules:
# Copy all column conditional styles to the new table.
for rule in existing_column.rules:
self.doAddRule(new_table_id, None, new_column.id, rule.formula)
# Copy all row conditional styles to the new table.
for rule in raw_section.rules:
self.doAddRule(new_table_id, None, None, rule.formula)
# If requested, copy all data from the original table to the new table.
if include_data:
data = self._engine.fetch_table(existing_table_id, formulas=False)
self.doBulkAddOrReplace(new_table_id, data.row_ids, data.columns, replace=True)
return {
'id': result['id'],
'table_id': new_table_id,
'raw_section_id': new_raw_section.id,
}
def _fetch_table_col_recs(self, table_ref, col_refs):
"""Helper that converts col_refs from table table_ref into column Records."""
try: