From cd64237dadc232a5fd85d56ba0ff69a2263ba2c1 Mon Sep 17 00:00:00 2001 From: George Gevoian Date: Wed, 28 Sep 2022 16:01:46 -0700 Subject: [PATCH] (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 --- app/client/components/DataTables.ts | 18 ++++ app/client/ui/DuplicateTable.ts | 122 +++++++++++++++++++++++++ app/common/gristUrls.ts | 1 + app/server/lib/GranularAccess.ts | 81 ++++++++++++++--- sandbox/grist/summary.py | 26 +++--- sandbox/grist/test_useractions.py | 135 ++++++++++++++++++++++++++++ sandbox/grist/useractions.py | 103 ++++++++++++++++++++- 7 files changed, 459 insertions(+), 27 deletions(-) create mode 100644 app/client/ui/DuplicateTable.ts diff --git a/app/client/components/DataTables.ts b/app/client/components/DataTables.ts index a05b9d7f..5bd7d5c3 100644 --- a/app/client/components/DataTables.ts +++ b/app/client/components/DataTables.ts @@ -3,6 +3,7 @@ import {copyToClipboard} from 'app/client/lib/copyToClipboard'; import {setTestState} from 'app/client/lib/testState'; import {TableRec} from 'app/client/models/DocModel'; import {docListHeader, docMenuTrigger} from 'app/client/ui/DocMenuCss'; +import {duplicateTable, DuplicateTableResponse} from 'app/client/ui/DuplicateTable'; import {showTransientTooltip} from 'app/client/ui/tooltips'; import {buildTableName} from 'app/client/ui/WidgetTitle'; import * as css from 'app/client/ui2018/cssVars'; @@ -121,6 +122,16 @@ export class DataTables extends Disposable { private _menuItems(table: TableRec) { const {isReadonly, docModel} = this._gristDoc; return [ + menuItem( + () => this._duplicateTable(table), + 'Duplicate Table', + testId('menu-duplicate-table'), + dom.cls('disabled', use => + use(isReadonly) || + use(table.isHidden) || + use(table.summarySourceTable) !== 0 + ), + ), menuItem( () => this._removeTable(table), 'Remove', @@ -134,6 +145,13 @@ export class DataTables extends Disposable { ]; } + private _duplicateTable(t: TableRec) { + duplicateTable(this._gristDoc, t.tableId(), { + onSuccess: ({raw_section_id}: DuplicateTableResponse) => + this._gristDoc.viewModel.activeSectionId(raw_section_id), + }); + } + private _removeTable(t: TableRec) { const {docModel} = this._gristDoc; function doRemove() { diff --git a/app/client/ui/DuplicateTable.ts b/app/client/ui/DuplicateTable.ts new file mode 100644 index 00000000..0976a9ec --- /dev/null +++ b/app/client/ui/DuplicateTable.ts @@ -0,0 +1,122 @@ +import {GristDoc} from 'app/client/components/GristDoc'; +import {cssInput} from 'app/client/ui/cssInput'; +import {cssField} from 'app/client/ui/MakeCopyMenu'; +import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox'; +import {colors} from 'app/client/ui2018/cssVars'; +import {icon} from 'app/client/ui2018/icons'; +import {cssLink} from 'app/client/ui2018/links'; +import {saveModal} from 'app/client/ui2018/modals'; +import {commonUrls} from 'app/common/gristUrls'; +import {Computed, Disposable, dom, input, makeTestId, Observable, styled} from 'grainjs'; + +const testId = makeTestId('test-duplicate-table-'); + +/** + * Response returned by a DuplicateTable user action. + */ +export interface DuplicateTableResponse { + /** Row id of the new table. */ + id: number; + /** Table id of the new table. */ + table_id: string; + /** Row id of the new raw view section. */ + raw_section_id: number; +} + +export interface DuplicateTableOptions { + onSuccess?(response: DuplicateTableResponse): void; +} + +/** + * Shows a modal with options for duplicating the table `tableId`. + */ +export function duplicateTable( + gristDoc: GristDoc, + tableId: string, + {onSuccess}: DuplicateTableOptions = {} +) { + saveModal((_ctl, owner) => { + const duplicateTableModal = DuplicateTableModal.create(owner, gristDoc, tableId); + return { + title: 'Duplicate Table', + body: duplicateTableModal.buildDom(), + saveFunc: async () => { + const response = await duplicateTableModal.save(); + onSuccess?.(response); + }, + saveDisabled: duplicateTableModal.saveDisabled, + width: 'normal', + }; + }); +} + +class DuplicateTableModal extends Disposable { + private _newTableName = Observable.create(this, ''); + private _includeData = Observable.create(this, false); + private _saveDisabled = Computed.create(this, this._newTableName, (_use, name) => !name.trim()); + + constructor(private _gristDoc: GristDoc, private _tableId: string) { + super(); + } + + public get saveDisabled() { return this._saveDisabled; } + + public save() { + return this._duplicateTable(); + } + + public buildDom() { + return [ + cssField( + input( + this._newTableName, + {onInput: true}, + {placeholder: 'Name for new table'}, + (elem) => { setTimeout(() => { elem.focus(); }, 20); }, + dom.on('focus', (_ev, elem) => { elem.select(); }), + dom.cls(cssInput.className), + testId('name'), + ), + ), + cssWarning( + cssWarningIcon('Warning'), + dom('div', + "Instead of duplicating tables, it's usually better to segment data using linked views. ", + cssLink({href: commonUrls.helpLinkingWidgets, target: '_blank'}, 'Read More.') + ), + ), + cssField( + cssCheckbox( + this._includeData, + 'Copy all data in addition to the table structure.', + testId('copy-all-data'), + ), + ), + dom.maybe(this._includeData, () => cssWarning( + cssWarningIcon('Warning'), + dom('div', 'Only the document default access rules will apply to the copy.'), + testId('acl-warning'), + )), + ]; + } + + private _duplicateTable() { + const {docData} = this._gristDoc; + const [newTableName, includeData] = [this._newTableName.get(), this._includeData.get()]; + return docData.sendAction(['DuplicateTable', this._tableId, newTableName, includeData]); + } +} + +const cssCheckbox = styled(labeledSquareCheckbox, ` + margin-top: 8px; +`); + +const cssWarning = styled('div', ` + display: flex; + column-gap: 8px; +`); + +const cssWarningIcon = styled(icon, ` + --icon-color: ${colors.orange}; + flex-shrink: 0; +`); diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index 56cc81cb..b4b4746d 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -61,6 +61,7 @@ export const MIN_URLID_PREFIX_LENGTH = 12; export const commonUrls = { help: "https://support.getgrist.com", + helpLinkingWidgets: "https://support.getgrist.com/linking-widgets", plans: "https://www.getgrist.com/pricing", createTeamSite: "https://www.getgrist.com/create-team-site", sproutsProgram: "https://www.getgrist.com/sprouts-program", diff --git a/app/server/lib/GranularAccess.ts b/app/server/lib/GranularAccess.ts index 365bbd1e..90c74b4c 100644 --- a/app/server/lib/GranularAccess.ts +++ b/app/server/lib/GranularAccess.ts @@ -77,8 +77,10 @@ function isAclTable(tableId: string): boolean { return ['_grist_ACLRules', '_grist_ACLResources'].includes(tableId); } -function isAddOrUpdateRecordAction(a: UserAction): boolean { - return ['AddOrUpdateRecord', 'BulkAddOrUpdateRecord'].includes(String(a[0])); +const ADD_OR_UPDATE_RECORD_ACTIONS = ['AddOrUpdateRecord', 'BulkAddOrUpdateRecord']; + +function isAddOrUpdateRecordAction([actionName]: UserAction): boolean { + return ADD_OR_UPDATE_RECORD_ACTIONS.includes(String(actionName)); } // A list of key metadata tables that need special handling. Other metadata tables may @@ -154,6 +156,9 @@ const OTHER_RECOGNIZED_ACTIONS = new Set([ 'RemoveTable', 'RenameTable', + // A schema action handled specially because of read needs. + 'DuplicateTable', + // Display column support. 'SetDisplayFormula', 'MaybeCopyDisplayFormula', @@ -548,6 +553,7 @@ export class GranularAccess implements GranularAccessForBundle { await this._checkSimpleDataActions(docSession, actions); await this._checkForSpecialOrSurprisingActions(docSession, actions); await this._checkPossiblePythonFormulaModification(docSession, actions); + await this._checkDuplicateTableAccess(docSession, actions); await this._checkAddOrUpdateAccess(docSession, actions); } @@ -988,21 +994,16 @@ export class GranularAccess implements GranularAccessForBundle { // Don't need to apply this particular check. return; } - // Fail if being combined with anything fancy. - if (scanActionsRecursively(actions, (a) => { - const name = a[0]; - return !['ApplyUndoActions', 'ApplyDocActions'].includes(String(name)) && - !isAddOrUpdateRecordAction(a) && - !(isDataAction(a) && !getTableId(a).startsWith('_grist_')); - })) { - throw new Error('Can only combine AddOrUpdate with simple data changes'); - } + + await this._assertOnlyBundledWithSimpleDataActions(ADD_OR_UPDATE_RECORD_ACTIONS, actions); + // Check for read access, and that we're not touching metadata. await applyToActionsRecursively(actions, async (a) => { if (!isAddOrUpdateRecordAction(a)) { return; } + const actionName = String(a[0]); const tableId = validTableIdString(a[1]); if (tableId.startsWith('_grist_')) { - throw new Error(`AddOrUpdate cannot yet be used on metadata tables`); + throw new Error(`${actionName} cannot yet be used on metadata tables`); } const tableAccess = await this.getTableAccess(docSession, tableId); accessChecks.fatal.read.throwIfNotFullyAllowed(tableAccess); @@ -1011,6 +1012,20 @@ export class GranularAccess implements GranularAccessForBundle { }); } + /** + * Asserts that `actionNames` (if present in `actions`) are only bundled with simple data actions. + */ + private async _assertOnlyBundledWithSimpleDataActions(actionNames: string | string[], actions: UserAction[]) { + const names = Array.isArray(actionNames) ? actionNames : [actionNames]; + // Fail if being combined with anything that isn't a simple data action. + await applyToActionsRecursively(actions, async (a) => { + const name = String(a[0]); + if (!names.includes(name) && !(isDataAction(a) && !getTableId(a).startsWith('_grist_'))) { + throw new Error(`Can only combine ${names.join(' and ')} with simple data changes`); + } + }); + } + private async _checkPossiblePythonFormulaModification(docSession: OptDocSession, actions: UserAction[]) { // If changes could include Python formulas, then user must have // +S before we even consider passing these to the data engine. @@ -1022,6 +1037,48 @@ export class GranularAccess implements GranularAccessForBundle { } } + /** + * Like `_checkAddOrUpdateAccess`, but for DuplicateTable actions. + * + * Permitted only when a user has full access, or full table read and schema edit + * access for the table being duplicated. + * + * Currently, DuplicateTable cannot be combined with other action types, including + * simple data actions. This may be relaxed in the future, but should only be done + * after careful consideration of its implications. + */ + private async _checkDuplicateTableAccess(docSession: OptDocSession, actions: UserAction[]) { + if (!scanActionsRecursively(actions, ([actionName]) => String(actionName) === 'DuplicateTable')) { + // Don't need to apply this particular check. + return; + } + + // Fail if being combined with another action. + await applyToActionsRecursively(actions, async ([actionName]) => { + if (String(actionName) !== 'DuplicateTable') { + throw new Error('DuplicateTable currently cannot be combined with other actions'); + } + }); + + // Check for read and schema edit access, and that we're not duplicating metadata tables. + await applyToActionsRecursively(actions, async (a) => { + const tableId = validTableIdString(a[1]); + if (tableId.startsWith('_grist_')) { + throw new Error('DuplicateTable cannot be used on metadata tables'); + } + if (await this.hasFullAccess(docSession)) { return; } + + const tableAccess = await this.getTableAccess(docSession, tableId); + accessChecks.fatal.read.throwIfNotFullyAllowed(tableAccess); + accessChecks.fatal.schemaEdit.throwIfDenied(tableAccess); + + const includeData = a[3]; + if (includeData) { + accessChecks.fatal.create.throwIfDenied(tableAccess); + } + }); + } + /** * Asserts that user has schema access. */ diff --git a/sandbox/grist/summary.py b/sandbox/grist/summary.py index 4d039c2c..b0db847d 100644 --- a/sandbox/grist/summary.py +++ b/sandbox/grist/summary.py @@ -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). diff --git a/sandbox/grist/test_useractions.py b/sandbox/grist/test_useractions.py index 6ff61090..236bc7d7 100644 --- a/sandbox/grist/test_useractions.py +++ b/sandbox/grist/test_useractions.py @@ -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) diff --git a/sandbox/grist/useractions.py b/sandbox/grist/useractions.py index 5a1302c8..5485df1c 100644 --- a/sandbox/grist/useractions.py +++ b/sandbox/grist/useractions.py @@ -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: