mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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:
parent
0eb1fec3d7
commit
cd64237dad
@ -3,6 +3,7 @@ import {copyToClipboard} from 'app/client/lib/copyToClipboard';
|
|||||||
import {setTestState} from 'app/client/lib/testState';
|
import {setTestState} from 'app/client/lib/testState';
|
||||||
import {TableRec} from 'app/client/models/DocModel';
|
import {TableRec} from 'app/client/models/DocModel';
|
||||||
import {docListHeader, docMenuTrigger} from 'app/client/ui/DocMenuCss';
|
import {docListHeader, docMenuTrigger} from 'app/client/ui/DocMenuCss';
|
||||||
|
import {duplicateTable, DuplicateTableResponse} from 'app/client/ui/DuplicateTable';
|
||||||
import {showTransientTooltip} from 'app/client/ui/tooltips';
|
import {showTransientTooltip} from 'app/client/ui/tooltips';
|
||||||
import {buildTableName} from 'app/client/ui/WidgetTitle';
|
import {buildTableName} from 'app/client/ui/WidgetTitle';
|
||||||
import * as css from 'app/client/ui2018/cssVars';
|
import * as css from 'app/client/ui2018/cssVars';
|
||||||
@ -121,6 +122,16 @@ export class DataTables extends Disposable {
|
|||||||
private _menuItems(table: TableRec) {
|
private _menuItems(table: TableRec) {
|
||||||
const {isReadonly, docModel} = this._gristDoc;
|
const {isReadonly, docModel} = this._gristDoc;
|
||||||
return [
|
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(
|
menuItem(
|
||||||
() => this._removeTable(table),
|
() => this._removeTable(table),
|
||||||
'Remove',
|
'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) {
|
private _removeTable(t: TableRec) {
|
||||||
const {docModel} = this._gristDoc;
|
const {docModel} = this._gristDoc;
|
||||||
function doRemove() {
|
function doRemove() {
|
||||||
|
122
app/client/ui/DuplicateTable.ts
Normal file
122
app/client/ui/DuplicateTable.ts
Normal file
@ -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<string>(this, '');
|
||||||
|
private _includeData = Observable.create<boolean>(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;
|
||||||
|
`);
|
@ -61,6 +61,7 @@ export const MIN_URLID_PREFIX_LENGTH = 12;
|
|||||||
|
|
||||||
export const commonUrls = {
|
export const commonUrls = {
|
||||||
help: "https://support.getgrist.com",
|
help: "https://support.getgrist.com",
|
||||||
|
helpLinkingWidgets: "https://support.getgrist.com/linking-widgets",
|
||||||
plans: "https://www.getgrist.com/pricing",
|
plans: "https://www.getgrist.com/pricing",
|
||||||
createTeamSite: "https://www.getgrist.com/create-team-site",
|
createTeamSite: "https://www.getgrist.com/create-team-site",
|
||||||
sproutsProgram: "https://www.getgrist.com/sprouts-program",
|
sproutsProgram: "https://www.getgrist.com/sprouts-program",
|
||||||
|
@ -77,8 +77,10 @@ function isAclTable(tableId: string): boolean {
|
|||||||
return ['_grist_ACLRules', '_grist_ACLResources'].includes(tableId);
|
return ['_grist_ACLRules', '_grist_ACLResources'].includes(tableId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAddOrUpdateRecordAction(a: UserAction): boolean {
|
const ADD_OR_UPDATE_RECORD_ACTIONS = ['AddOrUpdateRecord', 'BulkAddOrUpdateRecord'];
|
||||||
return ['AddOrUpdateRecord', 'BulkAddOrUpdateRecord'].includes(String(a[0]));
|
|
||||||
|
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
|
// 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',
|
'RemoveTable',
|
||||||
'RenameTable',
|
'RenameTable',
|
||||||
|
|
||||||
|
// A schema action handled specially because of read needs.
|
||||||
|
'DuplicateTable',
|
||||||
|
|
||||||
// Display column support.
|
// Display column support.
|
||||||
'SetDisplayFormula',
|
'SetDisplayFormula',
|
||||||
'MaybeCopyDisplayFormula',
|
'MaybeCopyDisplayFormula',
|
||||||
@ -548,6 +553,7 @@ export class GranularAccess implements GranularAccessForBundle {
|
|||||||
await this._checkSimpleDataActions(docSession, actions);
|
await this._checkSimpleDataActions(docSession, actions);
|
||||||
await this._checkForSpecialOrSurprisingActions(docSession, actions);
|
await this._checkForSpecialOrSurprisingActions(docSession, actions);
|
||||||
await this._checkPossiblePythonFormulaModification(docSession, actions);
|
await this._checkPossiblePythonFormulaModification(docSession, actions);
|
||||||
|
await this._checkDuplicateTableAccess(docSession, actions);
|
||||||
await this._checkAddOrUpdateAccess(docSession, actions);
|
await this._checkAddOrUpdateAccess(docSession, actions);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -988,21 +994,16 @@ export class GranularAccess implements GranularAccessForBundle {
|
|||||||
// Don't need to apply this particular check.
|
// Don't need to apply this particular check.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Fail if being combined with anything fancy.
|
|
||||||
if (scanActionsRecursively(actions, (a) => {
|
await this._assertOnlyBundledWithSimpleDataActions(ADD_OR_UPDATE_RECORD_ACTIONS, actions);
|
||||||
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');
|
|
||||||
}
|
|
||||||
// Check for read access, and that we're not touching metadata.
|
// Check for read access, and that we're not touching metadata.
|
||||||
await applyToActionsRecursively(actions, async (a) => {
|
await applyToActionsRecursively(actions, async (a) => {
|
||||||
if (!isAddOrUpdateRecordAction(a)) { return; }
|
if (!isAddOrUpdateRecordAction(a)) { return; }
|
||||||
|
const actionName = String(a[0]);
|
||||||
const tableId = validTableIdString(a[1]);
|
const tableId = validTableIdString(a[1]);
|
||||||
if (tableId.startsWith('_grist_')) {
|
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);
|
const tableAccess = await this.getTableAccess(docSession, tableId);
|
||||||
accessChecks.fatal.read.throwIfNotFullyAllowed(tableAccess);
|
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[]) {
|
private async _checkPossiblePythonFormulaModification(docSession: OptDocSession, actions: UserAction[]) {
|
||||||
// If changes could include Python formulas, then user must have
|
// If changes could include Python formulas, then user must have
|
||||||
// +S before we even consider passing these to the data engine.
|
// +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.
|
* Asserts that user has schema access.
|
||||||
*/
|
*/
|
||||||
|
@ -13,7 +13,7 @@ ColInfo = namedtuple('ColInfo', ('colId', 'type', 'isFormula', 'formula',
|
|||||||
'widgetOptions', 'label'))
|
'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."""
|
"""Return a ColInfo() with the given fields, optionally copying values from the given column."""
|
||||||
for key in ColInfo._fields:
|
for key in ColInfo._fields:
|
||||||
values.setdefault(key, getattr(col, key) if col else None)
|
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):
|
def _make_sum_col_info(col):
|
||||||
"""Return a ColInfo() for the sum formula column for column 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)
|
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."""
|
"""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())
|
col_values = {k: v for k, v in six.iteritems(col_info._asdict())
|
||||||
if v is not None and k != 'colId'}
|
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):
|
def _group_colinfo(source_table):
|
||||||
"""Returns ColInfo() for the 'group' column that must be present in every summary 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)')
|
isFormula=True, formula='table.getSummarySourceGroup(rec)')
|
||||||
|
|
||||||
|
|
||||||
@ -171,7 +171,7 @@ class SummaryActions(object):
|
|||||||
yield col
|
yield col
|
||||||
else:
|
else:
|
||||||
result = self.useractions.doAddColumn(table.tableId, ci.colId,
|
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'])
|
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))
|
key = tuple(sorted(int(c) for c in source_groupby_columns))
|
||||||
|
|
||||||
groupby_colinfo = [
|
groupby_colinfo = [
|
||||||
_make_col_info(
|
make_col_info(
|
||||||
col=c,
|
col=c,
|
||||||
isFormula=False,
|
isFormula=False,
|
||||||
formula='',
|
formula='',
|
||||||
@ -200,7 +200,7 @@ class SummaryActions(object):
|
|||||||
groupby_col_ids = [c.colId for c in groupby_colinfo]
|
groupby_col_ids = [c.colId for c in groupby_colinfo]
|
||||||
result = self.useractions.doAddTable(
|
result = self.useractions.doAddTable(
|
||||||
encode_summary_table_name(source_table.tableId, groupby_col_ids),
|
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,
|
summarySourceTableRef=source_table.id,
|
||||||
raw_section=True)
|
raw_section=True)
|
||||||
summary_table = self.docmodel.tables.table.get_record(result['id'])
|
summary_table = self.docmodel.tables.table.get_record(result['id'])
|
||||||
@ -236,7 +236,7 @@ class SummaryActions(object):
|
|||||||
if srcCol in source_groupby_colset:
|
if srcCol in source_groupby_colset:
|
||||||
prev_group_cols.append(col)
|
prev_group_cols.append(col)
|
||||||
elif col.isFormula and col.colId not in groupby_colids:
|
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:
|
else:
|
||||||
# if user is removing a numeric column from the group by columns we must add it back as a
|
# if user is removing a numeric column from the group by columns we must add it back as a
|
||||||
# sum formula column
|
# sum formula column
|
||||||
@ -326,7 +326,7 @@ class SummaryActions(object):
|
|||||||
"""
|
"""
|
||||||
c = self._find_sister_column(source_table, col.colId)
|
c = self._find_sister_column(source_table, col.colId)
|
||||||
if c:
|
if c:
|
||||||
all_colinfo.append(_make_col_info(col=c))
|
all_colinfo.append(make_col_info(col=c))
|
||||||
elif col.type in ('Int', 'Numeric'):
|
elif col.type in ('Int', 'Numeric'):
|
||||||
all_colinfo.append(_make_sum_col_info(col))
|
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
|
# '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.
|
# 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):
|
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)'))
|
isFormula=True, formula='len($group)'))
|
||||||
return all_colinfo
|
return all_colinfo
|
||||||
|
|
||||||
@ -379,7 +379,7 @@ class SummaryActions(object):
|
|||||||
field_col_recs = [f.colRef for f in fields]
|
field_col_recs = [f.colRef for f in fields]
|
||||||
|
|
||||||
# Prepare the column info for each column.
|
# 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.
|
# Prepare the 'group' column, which is that one column that's different from the original.
|
||||||
group_args = ', '.join(
|
group_args = ', '.join(
|
||||||
@ -393,12 +393,12 @@ class SummaryActions(object):
|
|||||||
)
|
)
|
||||||
for c in field_col_recs if c.summarySourceCol
|
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,
|
isFormula=True,
|
||||||
formula='%s.lookupRecords(%s)' % (source_table_id, group_args)))
|
formula='%s.lookupRecords(%s)' % (source_table_id, group_args)))
|
||||||
|
|
||||||
# Create the new table.
|
# 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"])
|
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).
|
# Remember the original table, which we need later e.g. to adjust the sort spec (sortColRefs).
|
||||||
|
@ -6,6 +6,7 @@ import useractions
|
|||||||
import testutil
|
import testutil
|
||||||
import test_engine
|
import test_engine
|
||||||
from test_engine import Table, Column, View, Section, Field
|
from test_engine import Table, Column, View, Section, Field
|
||||||
|
from schema import RecalcWhen
|
||||||
|
|
||||||
log = logger.Logger(__name__, logger.INFO)
|
log = logger.Logger(__name__, logger.INFO)
|
||||||
|
|
||||||
@ -1497,3 +1498,137 @@ class TestUserActions(test_engine.EngineTestCase):
|
|||||||
finally:
|
finally:
|
||||||
# Revert the monkeypatch
|
# Revert the monkeypatch
|
||||||
datetime.datetime = original
|
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)
|
||||||
|
@ -1694,7 +1694,14 @@ class UserActions(object):
|
|||||||
@useraction
|
@useraction
|
||||||
def AddEmptyRule(self, table_id, field_ref, col_ref):
|
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"
|
assert table_id, "table_id is required"
|
||||||
|
|
||||||
@ -1711,7 +1718,7 @@ class UserActions(object):
|
|||||||
col_info = self.AddHiddenColumn(table_id, col_name, {
|
col_info = self.AddHiddenColumn(table_id, col_name, {
|
||||||
"type": "Any",
|
"type": "Any",
|
||||||
"isFormula": True,
|
"isFormula": True,
|
||||||
"formula": ''
|
"formula": formula
|
||||||
})
|
})
|
||||||
new_rule = col_info['colRef']
|
new_rule = col_info['colRef']
|
||||||
existing_rules = rule_owner.rules._get_encodable_row_ids() if rule_owner.rules else []
|
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
|
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):
|
def _fetch_table_col_recs(self, table_ref, col_refs):
|
||||||
"""Helper that converts col_refs from table table_ref into column Records."""
|
"""Helper that converts col_refs from table table_ref into column Records."""
|
||||||
try:
|
try:
|
||||||
|
Loading…
Reference in New Issue
Block a user