mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +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:
@@ -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() {
|
||||
|
||||
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 = {
|
||||
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",
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user