(core) Updating RawData views

Summary:
- Better focus on the widget title
- Adding columns only to the current view section
- New popup with options when user wants to delete a page
- New dialog to enter table name
- New table as a widget doesn't create a separate page
- Removing a table doesn't remove the primary view

Test Plan: Updated and new tests

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3410
pull/203/head
Jarosław Sadziński 2 years ago
parent 97f3a8805c
commit f194d6861b

@ -653,15 +653,22 @@ GridView.prototype.addNewColumn = function() {
.then(() => this.scrollPaneRight());
};
GridView.prototype.insertColumn = function(index) {
var pos = tableUtil.fieldInsertPositions(this.viewSection.viewFields(), index)[0];
GridView.prototype.insertColumn = async function(index) {
const pos = tableUtil.fieldInsertPositions(this.viewSection.viewFields(), index)[0];
var action = ['AddColumn', null, {"_position": pos}];
return this.tableModel.sendTableAction(action)
.bind(this).then(function() {
this.selectColumn(index);
this.currentEditingColumnIndex(index);
// this.columnConfigTab.show();
await this.gristDoc.docData.bundleActions('Insert column', async () => {
const colInfo = await this.tableModel.sendTableAction(action);
if (!this.viewSection.isRaw.peek()){
const fieldInfo = {
colRef: colInfo.colRef,
parentPos: pos,
parentId: this.viewSection.id.peek()
};
await this.gristDoc.docModel.viewFields.sendTableAction(['AddRecord', null, fieldInfo]);
}
});
this.selectColumn(index);
this.currentEditingColumnIndex(index);
};
GridView.prototype.scrollPaneRight = function() {

@ -45,6 +45,7 @@ import {IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
import {IPageWidgetLink, linkFromId, selectBy} from 'app/client/ui/selectBy';
import {startWelcomeTour} from 'app/client/ui/welcomeTour';
import {isNarrowScreen, mediaSmall, testId} from 'app/client/ui2018/cssVars';
import {invokePrompt} from 'app/client/ui2018/modals';
import {IconName} from 'app/client/ui2018/IconList';
import {FieldEditor} from "app/client/widgets/FieldEditor";
import {MinimalActionGroup} from 'app/common/ActionGroup';
@ -500,7 +501,9 @@ export class GristDoc extends DisposableWithEvents {
* Sends an action to create a new empty table and switches to that table's primary view.
*/
public async addEmptyTable(): Promise<void> {
const tableInfo = await this.docData.sendAction(['AddEmptyTable']);
const name = await this._promptForName();
if (name === undefined) { return; }
const tableInfo = await this.docData.sendAction(['AddEmptyTable', name || null]);
await this.openDocPage(this.docModel.tables.getRowModel(tableInfo.id).primaryViewId());
}
@ -510,10 +513,16 @@ export class GristDoc extends DisposableWithEvents {
public async addWidgetToPage(val: IPageWidget) {
const docData = this.docModel.docData;
const viewName = this.viewModel.name.peek();
let tableId: string|null|undefined;
if (val.table === 'New Table') {
tableId = await this._promptForName();
if (tableId === undefined) {
return;
}
}
const res = await docData.bundleActions(
`Added new linked section to view ${viewName}`,
() => this.addWidgetToPageImpl(val)
() => this.addWidgetToPageImpl(val, tableId ?? null)
);
// The newly-added section should be given focus.
@ -523,13 +532,12 @@ export class GristDoc extends DisposableWithEvents {
/**
* The actual implementation of addWidgetToPage
*/
public async addWidgetToPageImpl(val: IPageWidget) {
public async addWidgetToPageImpl(val: IPageWidget, tableId: string|null = null) {
const viewRef = this.activeViewId.get();
const tableRef = val.table === 'New Table' ? 0 : val.table;
const link = linkFromId(val.link);
const tableRef = val.table === 'New Table' ? 0 : val.table;
const result = await this.docData.sendAction(
['CreateViewSection', tableRef, viewRef, val.type, val.summarize ? val.columns : null]
['CreateViewSection', tableRef, viewRef, val.type, val.summarize ? val.columns : null, tableId]
);
if (val.type === 'chart') {
await this._ensureOneNumericSeries(result.sectionRef);
@ -549,13 +557,15 @@ export class GristDoc extends DisposableWithEvents {
*/
public async addNewPage(val: IPageWidget) {
if (val.table === 'New Table') {
const result = await this.docData.sendAction(['AddEmptyTable']);
const name = await this._promptForName();
if (name === undefined) { return; }
const result = await this.docData.sendAction(['AddEmptyTable', name]);
await this.openDocPage(result.views[0].id);
} else {
let result: any;
await this.docData.bundleActions(`Add new page`, async () => {
result = await this.docData.sendAction(
['CreateViewSection', val.table, 0, val.type, val.summarize ? val.columns : null]
['CreateViewSection', val.table, 0, val.type, val.summarize ? val.columns : null, null]
);
if (val.type === 'chart') {
await this._ensureOneNumericSeries(result.sectionRef);
@ -917,6 +927,10 @@ export class GristDoc extends DisposableWithEvents {
}
}
private async _promptForName() {
return await invokePrompt("Table name", "Create", '', "Default table name");
}
private async _replaceViewSection(section: ViewSectionRec, newVal: IPageWidget) {
const docModel = this.docModel;

@ -186,7 +186,7 @@ async function createNewViewSections(docData: GristDoc['docData'], viewSections:
// Helper to create an action that add widget to the view with viewId.
function newViewSectionAction(widget: IPageWidget, viewId: number) {
return ['CreateViewSection', widget.table, viewId, widget.type, widget.summarize ? widget.columns : null];
return ['CreateViewSection', widget.table, viewId, widget.type, widget.summarize ? widget.columns : null, null];
}
/**

@ -167,11 +167,16 @@ export function buildPageWidgetPicker(
link: value.link.get(),
section: value.section.get(),
});
// If savePromise throws an error, before or after timeout, we let the error propagate as it
// should be handle by the caller.
if (await isLongerThan(savePromise, DELAY_BEFORE_SPINNER_MS)) {
const label = getWidgetTypes(type).label;
await spinnerModal(`Building ${label} widget`, savePromise);
if (value.table.get() === 'New Table') {
// Adding empty table will show a prompt, so we don't want to wait for it.
await savePromise;
} else {
// If savePromise throws an error, before or after timeout, we let the error propagate as it
// should be handle by the caller.
if (await isLongerThan(savePromise, DELAY_BEFORE_SPINNER_MS)) {
const label = getWidgetTypes(type).label;
await spinnerModal(`Building ${label} widget`, savePromise);
}
}
}

@ -1,15 +1,19 @@
import { createGroup } from "app/client/components/commands";
import { duplicatePage } from "app/client/components/duplicatePage";
import { GristDoc } from "app/client/components/GristDoc";
import { PageRec } from "app/client/models/DocModel";
import { urlState } from "app/client/models/gristUrlState";
import * as MetaTableModel from "app/client/models/MetaTableModel";
import { find as findInTree, fromTableData, TreeItemRecord, TreeRecord,
TreeTableData} from "app/client/models/TreeModel";
import { TreeViewComponent } from "app/client/ui/TreeViewComponent";
import { buildPageDom, PageActions } from "app/client/ui2018/pages";
import { mod } from 'app/common/gutil';
import { Computed, Disposable, dom, fromKo, observable, Observable } from "grainjs";
import {createGroup} from 'app/client/components/commands';
import {duplicatePage} from 'app/client/components/duplicatePage';
import {GristDoc} from 'app/client/components/GristDoc';
import {PageRec} from 'app/client/models/DocModel';
import {urlState} from 'app/client/models/gristUrlState';
import * as MetaTableModel from 'app/client/models/MetaTableModel';
import {find as findInTree, fromTableData, TreeItemRecord, TreeRecord,
TreeTableData} from 'app/client/models/TreeModel';
import {TreeViewComponent} from 'app/client/ui/TreeViewComponent';
import {labeledCircleCheckbox} from 'app/client/ui2018/checkbox';
import {colors} from 'app/client/ui2018/cssVars';
import {cssLink} from 'app/client/ui2018/links';
import {ISaveModalOptions, saveModal} from 'app/client/ui2018/modals';
import {buildPageDom, PageActions} from 'app/client/ui2018/pages';
import {mod} from 'app/common/gutil';
import {Computed, Disposable, dom, DomContents, fromKo, makeTestId, observable, Observable, styled} from 'grainjs';
// build dom for the tree view of pages
export function buildPagesDom(owner: Disposable, activeDoc: GristDoc, isOpen: Observable<boolean>) {
@ -49,16 +53,18 @@ export function buildPagesDom(owner: Disposable, activeDoc: GristDoc, isOpen: Ob
return dom('div', dom.create(TreeViewComponent, model, {isOpen, selected, isReadonly: activeDoc.isReadonly}));
}
function buildDomFromTable(pagesTable: MetaTableModel<PageRec>, activeDoc: GristDoc, id: number) {
const testId = makeTestId('test-removepage-');
function buildDomFromTable(pagesTable: MetaTableModel<PageRec>, activeDoc: GristDoc, pageId: number) {
const {isReadonly} = activeDoc;
const pageName = pagesTable.rowModels[id].view.peek().name;
const viewId = pagesTable.rowModels[id].view.peek().id.peek();
const docData = pagesTable.tableData.docData;
const pageName = pagesTable.rowModels[pageId].view.peek().name;
const viewId = pagesTable.rowModels[pageId].view.peek().id.peek();
const actions: PageActions = {
onRename: (newName: string) => newName.length && pageName.saveOnly(newName),
onRemove: () => docData.sendAction(['RemoveRecord', '_grist_Views', viewId]),
onRemove: () => removeView(activeDoc, viewId, pageName.peek()),
// TODO: duplicate should prompt user for confirmation
onDuplicate: () => duplicatePage(activeDoc, id),
onDuplicate: () => duplicatePage(activeDoc, pageId),
// Can't remove last visible page
isRemoveDisabled: () => activeDoc.docModel.visibleDocPages.peek().length <= 1,
isReadonly
@ -67,6 +73,46 @@ function buildDomFromTable(pagesTable: MetaTableModel<PageRec>, activeDoc: Grist
return buildPageDom(fromKo(pageName), actions, urlState().setLinkUrl({docPage: viewId}));
}
function removeView(activeDoc: GristDoc, viewId: number, pageName: string) {
const docData = activeDoc.docData;
// Create a set with tables on other pages (but not on this one).
const tablesOnOtherViews = new Set(activeDoc.docModel.viewSections.rowModels
.filter(vs => !vs.isRaw.peek() && vs.parentId.peek() !== viewId)
.map(vs => vs.tableRef.peek()));
// Check if this page is a last page for some tables.
const notVisibleTables = [...new Set(activeDoc.docModel.viewSections.rowModels
.filter(vs => vs.parentId.peek() === viewId) // Get all sections on this view
.filter(vs => !vs.table.peek().summarySourceTable.peek()) // Sections that have normal tables
.filter(vs => !tablesOnOtherViews.has(vs.tableRef.peek())) // That aren't on other views
.filter(vs => vs.table.peek().tableId.peek()) // Which we can access (has tableId)
.map(vs => vs.table.peek()))]; // Return tableRec object, and remove duplicates.
const removePage = () => [['RemoveRecord', '_grist_Views', viewId]];
const removeAll = () => [
...removePage(),
...notVisibleTables.map(t => ['RemoveTable', t.tableId.peek()])
];
if (notVisibleTables.length) {
const tableNames = notVisibleTables.map(t => t.tableNameDef.peek());
buildPrompt(tableNames, async (option) => {
// Errors are handled in the dialog.
if (option === 'data') {
await docData.sendActions(removeAll(), `Remove page ${pageName} with tables ${tableNames}`);
} else if (option === 'page') {
await docData.sendActions(removePage(), `Remove only page ${pageName}`);
} else {
// This should not happen, as save should be disabled when no option is selected.
}
});
} else {
return docData.sendActions(removePage(), `Remove only page ${pageName}`);
}
}
type RemoveOption = '' | 'data' | 'page';
// Select another page in cyclic ordering of pages. Order is downard if given a positive `delta`,
// upward otherwise.
function otherPage(currentPage: TreeItemRecord, delta: number) {
@ -75,3 +121,94 @@ function otherPage(currentPage: TreeItemRecord, delta: number) {
const docPage = records[index].viewRef;
return urlState().pushUrl({docPage});
}
function buildPrompt(tableNames: string[], onSave: (option: RemoveOption) => Promise<any>) {
saveModal((ctl, owner): ISaveModalOptions => {
const selected = Observable.create<RemoveOption>(owner, '');
const saveDisabled = Computed.create(owner, use => use(selected) === '');
const saveFunc = () => onSave(selected.get());
return {
title: `The following table${tableNames.length > 1 ? 's' : ''} will no longer be visible`,
body: dom('div',
testId('popup'),
buildWarning(tableNames),
cssOptions(
buildOption(selected, 'data', `Delete data and this page.`),
buildOption(selected, 'page',
[
`Delete this page, but do not delete data. `,
`Table will remain available in `,
cssLink(urlState().setHref({docPage: 'data'}), 'raw data page', { target: '_blank'}),
`.`
]),
)
),
saveDisabled,
saveLabel: 'Delete',
saveFunc,
width: 'fixed-wide',
extraButtons: [],
};
});
}
function buildOption(value: Observable<RemoveOption>, id: RemoveOption, content: DomContents) {
const selected = Computed.create(null, use => use(value) === id)
.onWrite(val => val ? value.set(id) : void 0);
return dom.update(
labeledCircleCheckbox(selected, content, dom.autoDispose(selected)),
testId(`option-${id}`),
cssBlockCheckbox.cls(''),
cssBlockCheckbox.cls('-block', selected),
);
}
function buildWarning(tables: string[]) {
return cssWarning(
dom.forEach(tables, (t) => cssTableName(t, testId('table')))
);
}
const cssOptions = styled('div', `
display: flex;
flex-direction: column;
gap: 10px;
`);
// We need to reset top and left of ::before element, as it is wrongly set
// on the inline checkbox.
// To simulate radio button behavior, we will block user input after option is selected, because
// checkbox doesn't support two-way binding.
const cssBlockCheckbox = styled('div', `
display: flex;
padding: 10px 8px;
border: 1px solid ${colors.mediumGrey};
border-radius: 3px;
cursor: pointer;
& input::before, & input::after {
top: unset;
left: unset;
}
&:hover {
border-color: ${colors.lightGreen};
}
&-block {
pointer-events: none;
}
&-block a {
pointer-events: all;
}
`);
const cssWarning = styled('div', `
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
`);
const cssTableName = styled('div', `
background: #eee;
padding: 3px 6px;
border-radius: 4px;
`);

@ -60,16 +60,18 @@ function buildWidgetRenamePopup(ctrl: IOpenController, vs: ViewSectionRec, optio
// User input for table name.
const inputTableName = Observable.create(ctrl, tableName);
// User input for widget title.
const inputWidgetTitle = Observable.create(ctrl, vs.title.peek());
const inputWidgetTitle = Observable.create(ctrl, vs.title.peek() ?? '');
// Placeholder for widget title:
// - when widget title is empty shows a default widget title (what would be shown when title is empty)
// - when widget title is set, shows just a text to override it.
const inputWidgetPlaceholder = !vs.title.peek() ? 'Override widget title' : vs.defaultWidgetTitle.peek();
const disableSave = Computed.create(ctrl, (use) =>
(use(inputTableName) === tableName || use(inputTableName).trim() === '') &&
use(inputWidgetTitle) === vs.title.peek()
);
const disableSave = Computed.create(ctrl, (use) => {
const newTableName = use(inputTableName)?.trim() ?? '';
const newWidgetTitle = use(inputWidgetTitle)?.trim() ?? '';
// Can't save when table name is empty or there wasn't any change.
return !newTableName || (newTableName === tableName && newWidgetTitle === use(vs.title));
});
const modalCtl = ModalControl.create(ctrl, () => ctrl.close());
@ -88,9 +90,10 @@ function buildWidgetRenamePopup(ctrl: IOpenController, vs: ViewSectionRec, optio
};
const saveWidgetTitle = async () => {
const newTitle = inputWidgetTitle.get()?.trim() ?? '';
// If value was changed.
if (inputWidgetTitle.get() !== vs.title.peek()) {
await vs.title.saveOnly(inputWidgetTitle.get());
if (newTitle !== vs.title.peek()) {
await vs.title.saveOnly(newTitle);
}
};
const doSave = modalCtl.doWork(() => Promise.all([
@ -99,23 +102,20 @@ function buildWidgetRenamePopup(ctrl: IOpenController, vs: ViewSectionRec, optio
]), {close: true});
function initialFocus() {
// Set focus on a thing user is likely to change.
// Initial focus is set on tableName unless:
// - if this is a summary table - as it is not editable,
// - if widgetTitle is not empty - so user wants to change it further,
// - if widgetTitle is empty but the default widget name will have type suffix (like Table1 (Card)), so it won't
// be a table name - so user doesn't want the default value.
if (
!widgetInput ||
isSummary ||
vs.title.peek() ||
(
!vs.title.peek() &&
vs.defaultWidgetTitle.peek().toUpperCase() !== tableRec.tableName.peek().toUpperCase()
)) {
widgetInput?.focus();
} else if (!isSummary) {
tableInput?.focus();
const isRawView = !widgetInput;
const isWidgetTitleEmpty = !vs.title.peek();
function focus(inputEl?: HTMLInputElement) {
inputEl?.focus();
inputEl?.select();
}
if (isSummary) {
focus(widgetInput);
} else if (isRawView) {
focus(tableInput);
} else if (isWidgetTitleEmpty) {
focus(tableInput);
} else {
focus(widgetInput);
}
}

@ -34,6 +34,8 @@ export const cssLabel = styled('label', `
}
`);
// TODO: the !important markings are to trump bootstrap, and should be removed when it's gone.
export const cssCheckboxSquare = styled('input', `
-webkit-appearance: none;

@ -7,7 +7,6 @@ import { dom, IDomArgs, Observable, styled } from 'grainjs';
* Styling for a simple green <A HREF> link.
*/
// Match the font-weight of buttons.
export const cssLink = styled('a', `
color: ${colors.lightGreen};
--icon-color: ${colors.lightGreen};

@ -1,10 +1,11 @@
import {FocusLayer} from 'app/client/lib/FocusLayer';
import {reportError} from 'app/client/models/errors';
import {cssInput} from 'app/client/ui/MakeCopyMenu';
import {bigBasicButton, bigPrimaryButton, cssButton} from 'app/client/ui2018/buttons';
import {colors, mediaSmall, testId, vars} from 'app/client/ui2018/cssVars';
import {loadingSpinner} from 'app/client/ui2018/loaders';
import {waitGrainObs} from 'app/common/gutil';
import {Computed, Disposable, dom, DomContents, DomElementArg, MultiHolder, Observable, styled} from 'grainjs';
import {Computed, Disposable, dom, DomContents, DomElementArg, input, MultiHolder, Observable, styled} from 'grainjs';
// IModalControl is passed into the function creating the body of the modal.
export interface IModalControl {
@ -287,6 +288,93 @@ export function confirmModal(
}));
}
/**
* Creates a simple prompt modal (replacement for the native one).
* Closed via clicking anywhere outside the modal or Cancel button.
*
* Example usage:
* promptModal(
* "Enter your name",
* (name: string) => alert(`Hello ${name}`),
* "Ok" // Confirm button name,
* "John doe", // Initial text (can be empty or undefined)
* "Enter your name", // input placeholder
* () => console.log('User cancelled') // Called when user cancels, or clicks outside.
* )
*
* @param title: Prompt text.
* @param onConfirm: Handler for Confirm button.
* @param btnText: Text of the confirm button.
* @param initial: Initial value in the input element.
* @param placeholder: Placeholder for the input element.
* @param onCancel: Optional cancel handler.
*/
export function promptModal(
title: string,
onConfirm: (text: string) => Promise<unknown>,
btnText: string,
initial?: string,
placeholder?: string,
onCancel?: () => void
): void {
saveModal((ctl, owner): ISaveModalOptions => {
let confirmed = false;
const text = Observable.create(owner, initial ?? '');
const txtInput = input(text, { onInput : true }, { placeholder }, cssInput.cls(''), testId('modal-prompt'));
const options: ISaveModalOptions = {
title,
body: txtInput,
saveLabel: btnText,
saveFunc: () => {
// Mark that confirm was invoked.
confirmed = true;
return onConfirm(text.get() || '');
},
width: 'normal'
};
owner.onDispose(() => {
if (confirmed) { return; }
onCancel?.();
});
setTimeout(() => txtInput.focus(), 10);
return options;
});
}
/**
* Wraps prompt modal in a promise that is resolved either when user confirms or cancels.
* When user cancels the returned value is always undefined.
*
* Example usage:
* async handler() {
* const name = await invokePrompt("Please enter your name");
* if (name !== undefined) alert(`Hello ${name}`);
* }
*
* @param title: Prompt text.
* @param btnText: Text of the confirm button, default is "Ok".
* @param initial: Initial value in the input element.
* @param placeholder: Placeholder for the input element.
*/
export function invokePrompt(
title: string,
btnText?: string,
initial?: string,
placeholder?: string
): Promise<string|undefined> {
let onResolve: (text: string|undefined) => any;
const prom = new Promise<string|undefined>((resolve) => {
onResolve = resolve;
});
promptModal(title, onResolve!, btnText ?? 'Ok', initial, placeholder, () => {
if (onResolve) {
onResolve(undefined);
}
});
return prom;
}
/**
* Builds a simple spinner modal. The modal gets removed when `promise` resolves.
*/

@ -611,7 +611,7 @@ export class ActiveDoc extends EventEmitter {
public addInitialTable(docSession: OptDocSession) {
// Use a non-client-specific session, so that this action is not part of anyone's undo history.
const newDocSession = makeExceptionalDocSession('nascent');
return this.applyUserActions(newDocSession, [["AddEmptyTable"]]);
return this.applyUserActions(newDocSession, [["AddEmptyTable", null]]);
}
/**

@ -185,9 +185,10 @@ class TestColumnActions(test_engine.EngineTestCase):
def init_sample_data(self):
# Add a new view with a section, and a new table to that view, and a summary table.
self.load_sample(self.sample2)
self.apply_user_action(["CreateViewSection", 1, 0, "record", None])
self.apply_user_action(["CreateViewSection", 0, 1, "record", None])
self.apply_user_action(["CreateViewSection", 1, 1, "record", [12]])
self.apply_user_action(["CreateViewSection", 1, 0, "record", None, None])
self.apply_user_action(["AddEmptyTable", None])
self.apply_user_action(["CreateViewSection", 2, 1, "record", None, None])
self.apply_user_action(["CreateViewSection", 1, 1, "record", [12], None])
self.apply_user_action(["BulkAddRecord", "Table1", [None]*3, {
"A": ["a", "b", "c"],
"B": ["d", "e", "f"],

@ -57,7 +57,7 @@ class TestDerived(test_engine.EngineTestCase):
self.load_sample(self.sample)
# Create a derived table summarizing count and total of orders by year.
self.apply_user_action(["CreateViewSection", 2, 0, 'record', [10]])
self.apply_user_action(["CreateViewSection", 2, 0, 'record', [10], None])
# Check the results.
self.assertPartialData("GristSummary_6_Orders", ["id", "year", "count", "amount", "group" ], [
@ -134,7 +134,7 @@ class TestDerived(test_engine.EngineTestCase):
"""
self.load_sample(self.sample)
self.apply_user_action(["CreateViewSection", 2, 0, 'record', [10, 12]])
self.apply_user_action(["CreateViewSection", 2, 0, 'record', [10, 12], None])
self.assertPartialData("GristSummary_6_Orders", [
"id", "year", "product", "count", "amount", "group"
], [
@ -193,7 +193,7 @@ class TestDerived(test_engine.EngineTestCase):
self.load_sample(self.sample)
# Create a summary on the Customers table. Adding orders involves a lookup for each customer.
self.apply_user_action(["CreateViewSection", 1, 0, 'record', [3]])
self.apply_user_action(["CreateViewSection", 1, 0, 'record', [3], None])
self.add_column("GristSummary_9_Customers", "totalAmount",
formula="sum(sum(Orders.lookupRecords(customer=c).amount) for c in $group)")
@ -263,7 +263,7 @@ class TestDerived(test_engine.EngineTestCase):
self.load_sample(self.sample)
# Create a summary table summarizing count and total of orders by year.
self.apply_user_action(["CreateViewSection", 2, 0, 'record', [10]])
self.apply_user_action(["CreateViewSection", 2, 0, 'record', [10], None])
self.assertPartialData("GristSummary_6_Orders", ["id", "year", "count", "amount", "group" ], [
[1, 2012, 1, 15.0, [1]],
[2, 2013, 2, 30.0, [2,3]],

@ -135,7 +135,7 @@ class TestUserActions(test_engine.EngineTestCase):
])
# Add a new column with a formula.
self.apply_user_action(['AddColumn', 'Favorites', 'fav_viewers', {
self.apply_user_action(['AddVisibleColumn', 'Favorites', 'fav_viewers', {
'formula': '$favorite.viewers'
}])
# Add a field back for the favorites table and set its display formula to the

@ -201,7 +201,7 @@ class TestRules(test_engine.EngineTestCase):
# Test that rules are removed with a column when attached to a field.
self.load_sample(self.sample)
self.apply_user_action(['CreateViewSection', 1, 0, 'record', None])
self.apply_user_action(['CreateViewSection', 1, 0, 'record', None, None])
self.field_add_empty(2)
self.field_set_rule(2, 0, "$Stock == 0")
before = self.engine.docmodel.columns.lookupOne(colId='gristHelper_ConditionalRule')
@ -218,7 +218,7 @@ class TestRules(test_engine.EngineTestCase):
# Test that rules are removed with a field.
self.load_sample(self.sample)
self.apply_user_action(['CreateViewSection', 1, 0, 'record', None])
self.apply_user_action(['CreateViewSection', 1, 0, 'record', None, None])
self.field_add_empty(2)
self.field_set_rule(2, 0, "$Stock == 0")
rule_id = self.engine.docmodel.columns.lookupOne(colId='gristHelper_ConditionalRule').id

@ -96,7 +96,7 @@ class TestSummary(test_engine.EngineTestCase):
self.assertViews([])
# Create a view + section for the initial table.
self.apply_user_action(["CreateViewSection", 1, 0, "record", None])
self.apply_user_action(["CreateViewSection", 1, 0, "record", None, None])
# Verify that we got a new view, with one section, and three fields.
self.assertTables([self.starting_table])
@ -112,7 +112,7 @@ class TestSummary(test_engine.EngineTestCase):
self.assertTableData("Address", self.starting_table_data)
# Create a "Totals" section, i.e. a summary with no group-by columns.
self.apply_user_action(["CreateViewSection", 1, 0, "record", []])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [], None])
# Verify that a new table gets created, and a new view, with a section for that table,
# and some auto-generated summary fields.
@ -141,7 +141,7 @@ class TestSummary(test_engine.EngineTestCase):
])
# Create a summary section, grouped by the "State" column.
self.apply_user_action(["CreateViewSection", 1, 0, "record", [12]])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [12], None])
# Verify that a new table gets created again, a new view, and a section for that table.
# Note that we also check that summarySourceTable and summarySourceCol fields are correct.
@ -182,7 +182,7 @@ class TestSummary(test_engine.EngineTestCase):
])
# Create a summary section grouped by two columns ("city" and "state").
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11,12]])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11,12], None])
# Verify the new table and views.
summary_table3 = Table(4, "GristSummary_7_Address3", primaryViewId=0, summarySourceTable=1,
@ -229,8 +229,8 @@ class TestSummary(test_engine.EngineTestCase):
def test_summary_gencode(self):
self.maxDiff = 1000 # If there is a discrepancy, allow the bigger diff.
self.load_sample(self.sample)
self.apply_user_action(["CreateViewSection", 1, 0, "record", []])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11,12]])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [], None])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11,12], None])
self.assertMultiLineEqual(self.engine.fetch_table_schema(),
"""import grist
from functions import * # global uppercase functions
@ -266,7 +266,7 @@ class Address:
self.load_sample(self.sample)
# Create a summary section grouped by two columns ("city" and "state").
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11,12]])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11,12], None])
# Verify the new table and views.
summary_table = Table(2, "GristSummary_7_Address", primaryViewId=0, summarySourceTable=1,
@ -293,8 +293,8 @@ class Address:
# Create twoo other views + view sections with the same breakdown (in different order
# of group-by fields, which should still reuse the same table).
self.apply_user_action(["CreateViewSection", 1, 0, "record", [12,11]])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11,12]])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [12,11], None])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11,12], None])
summary_view2 = View(2, sections=[
Section(2, parentKey="record", tableRef=2, fields=[
Field(5, colRef=15),
@ -337,8 +337,8 @@ class Address:
# Load table and create a couple summary sections, for totals, and grouped by "state".
self.load_sample(self.sample)
self.apply_user_action(["CreateViewSection", 1, 0, "record", []])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [12]])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [], None])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [12], None])
self.assertTables([
self.starting_table,
@ -376,8 +376,8 @@ class Address:
])
# Now create similar summary sections for the new table.
self.apply_user_action(["CreateViewSection", 4, 0, "record", []])
self.apply_user_action(["CreateViewSection", 4, 0, "record", [23]])
self.apply_user_action(["CreateViewSection", 4, 0, "record", [], None])
self.apply_user_action(["CreateViewSection", 4, 0, "record", [23], None])
# Make sure this creates new section rather than reuses similar ones for the wrong table.
self.assertTables([
@ -421,7 +421,7 @@ class Address:
# Load sample and create a summary section grouped by two columns ("city" and "state").
self.load_sample(self.sample)
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11,12]])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11,12], None])
# Verify that the summary table respects all updates to the source table.
self._do_test_updates("Address", "GristSummary_7_Address")
@ -536,7 +536,7 @@ class Address:
# Load sample and create a couple of summary sections.
self.load_sample(self.sample)
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11,12]])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11,12], None])
# Check what tables we have now.
self.assertPartialData("_grist_Tables", ["id", "tableId", "summarySourceTable"], [
@ -561,8 +561,8 @@ class Address:
# Similar to the above, verify renames, but now with two summary tables.
self.load_sample(self.sample)
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11,12]])
self.apply_user_action(["CreateViewSection", 1, 0, "record", []])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11,12], None])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [], None])
self.assertPartialData("_grist_Tables", ["id", "tableId", "summarySourceTable"], [
[1, "Address", 0],
[2, "GristSummary_7_Address", 1],
@ -604,8 +604,8 @@ class Address:
# table, sharing all formulas and differing only in the group-by columns.)
self.load_sample(self.sample)
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11,12]])
self.apply_user_action(["CreateViewSection", 1, 0, "record", []])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11,12], None])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [], None])
# These are the tables and columns we automatically get.
self.assertTables([
@ -667,7 +667,7 @@ class Address:
])
# Add a new summary table, and check that it gets the new formula.
self.apply_user_action(["CreateViewSection", 1, 0, "record", [12]])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [12], None])
self.assertTables([
self.starting_table,
Table(2, "GristSummary_7_Address", 0, 1, columns=[
@ -710,9 +710,9 @@ class Address:
# Verify that we can convert the type of a column when there is a summary table using that
# column to group by. Since converting generates extra summary records, this may cause bugs.
self.apply_user_action(["AddEmptyTable"])
self.apply_user_action(["AddEmptyTable", None])
self.apply_user_action(["BulkAddRecord", "Table1", [None]*3, {"A": [10,20,10], "B": [1,2,3]}])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [2]])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [2], None])
# Verify metadata and actual data initially.
self.assertTables([
@ -778,10 +778,10 @@ class Address:
# Verify that we can remove a column when there is a summary table using that column to group
# by. (Bug T188.)
self.apply_user_action(["AddEmptyTable"])
self.apply_user_action(["AddEmptyTable", None])
self.apply_user_action(["BulkAddRecord", "Table1", [None]*3,
{"A": ['a','b','c'], "B": [1,1,2], "C": [4,5,6]}])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [2,3]])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [2,3], None])
# Verify metadata and actual data initially.
self.assertTables([

@ -27,8 +27,8 @@ class TestSummary2(test_engine.EngineTestCase):
# Start as in test_change_summary_formula() test case; see there for what tables and columns
# we expect to have at this point.
self.load_sample(self.sample)
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11,12]])
self.apply_user_action(["CreateViewSection", 1, 0, "record", []])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11,12], None])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [], None])
# Check that we cannot add a non-formula column.
with self.assertRaisesRegex(ValueError, r'non-formula column'):
@ -37,16 +37,17 @@ class TestSummary2(test_engine.EngineTestCase):
# Add two formula columns: one for 'state' (an existing column name, and a group-by column in
# some tables), and one for 'average' (a new column name).
self.apply_user_action(["AddColumn", "GristSummary_7_Address2", "state",
self.apply_user_action(["AddVisibleColumn", "GristSummary_7_Address2", "state",
{"formula": "':'.join(sorted(set($group.state)))"}])
self.apply_user_action(["AddColumn", "GristSummary_7_Address", "average",
self.apply_user_action(["AddVisibleColumn", "GristSummary_7_Address", "average",
{"formula": "$amount / $count"}])
# Add two more summary tables: by 'city', and by 'state', and see what columns they get.
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11]])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [12]])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11], None])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [12], None])
# And also a summary table for an existing breakdown.
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11,12]])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11,12], None])
# Check the table and columns for all the summary tables.
self.assertTables([
@ -166,8 +167,8 @@ class TestSummary2(test_engine.EngineTestCase):
# Start as in test_change_summary_formula() test case; see there for what tables and columns
# we expect to have at this point.
self.load_sample(self.sample)
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11,12]])
self.apply_user_action(["CreateViewSection", 1, 0, "record", []])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11,12], None])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [], None])
# Check that we cannot rename a summary group-by column. (Perhaps it's better to raise an
# exception, but currently we translate the invalid request to a no-op.)
@ -336,8 +337,8 @@ class TestSummary2(test_engine.EngineTestCase):
# (5) no renaming summary tables.
self.load_sample(self.sample)
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11,12]])
self.apply_user_action(["CreateViewSection", 1, 0, "record", []])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11,12], None])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [], None])
self.assertTableData('GristSummary_7_Address', cols="all", data=[
[ "id", "city", "state", "group", "count", "amount" ],
@ -418,7 +419,7 @@ class TestSummary2(test_engine.EngineTestCase):
return [c for c in self.engine.tables[table_id].all_columns if c.startswith('#summary#')]
self.load_sample(self.sample)
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11,12]])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11,12], None])
# We should have a single summary table, and a single section referring to it.
self.assertTables([
@ -582,7 +583,7 @@ class TestSummary2(test_engine.EngineTestCase):
self.assertEqual(get_helper_cols('Address'), ['#summary#GristSummary_7_Address'])
# Now add a different view section with the same group-by columns.
self.apply_user_action(["CreateViewSection", 1, 1, "record", [11,12]])
self.apply_user_action(["CreateViewSection", 1, 1, "record", [11,12], None])
self.assertTables([
self.starting_table,
Table(6, "GristSummary_7_Address", 0, 1, columns=[
@ -737,8 +738,8 @@ class TestSummary2(test_engine.EngineTestCase):
# Verify that if we add a group-by column that conflicts with a formula, group-by column wins.
self.load_sample(self.sample)
self.apply_user_action(["CreateViewSection", 1, 0, "record", [12]])
self.apply_user_action(["AddColumn", "GristSummary_7_Address", "city",
self.apply_user_action(["CreateViewSection", 1, 0, "record", [12], None])
self.apply_user_action(["AddVisibleColumn", "GristSummary_7_Address", "city",
{"formula": "$state.lower()"}])
# We should have a single summary table, and a single section referring to it.
@ -801,10 +802,10 @@ class TestSummary2(test_engine.EngineTestCase):
# Create one view with one summary section, and another view with three sections.
self.load_sample(self.sample)
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11,12]]) # Creates View #1
self.apply_user_action(["CreateViewSection", 1, 0, "record", []]) # Creates View #2
self.apply_user_action(["CreateViewSection", 1, 2, "record", [11,12]]) # Refers to View #2
self.apply_user_action(["CreateViewSection", 1, 2, "record", [12]]) # Refers to View #2
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11,12], None]) # Creates View #1
self.apply_user_action(["CreateViewSection", 1, 0, "record", [], None]) # Creates View #2
self.apply_user_action(["CreateViewSection", 1, 2, "record", [11,12], None]) # Refers to View #2
self.apply_user_action(["CreateViewSection", 1, 2, "record", [12], None]) # Refers to View #2
# We should have a single summary table, and a single section referring to it.
self.assertTables([
@ -883,7 +884,7 @@ class TestSummary2(test_engine.EngineTestCase):
# Verify that we correctly update sort spec when we update a summary view section.
self.load_sample(self.sample)
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11,12]])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11,12], None])
self.apply_user_action(["UpdateRecord", "_grist_Views_section", 1,
{"sortColRefs": "[15,14,-17]"}])
@ -927,10 +928,10 @@ class TestSummary2(test_engine.EngineTestCase):
self.load_sample(self.sample)
# Add a couple of summary tables.
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11,12]])
self.apply_user_action(["CreateViewSection", 1, 0, "record", []])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11,12], None])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [], None])
# Add a formula column
self.apply_user_action(["AddColumn", "GristSummary_7_Address", "average",
self.apply_user_action(["AddVisibleColumn", "GristSummary_7_Address", "average",
{"formula": "$amount / $count"}])
# Check the table and columns for all the summary tables.
@ -1037,11 +1038,11 @@ class TestSummary2(test_engine.EngineTestCase):
# Add a summary table and detach it. Then add a summary table of that table.
self.load_sample(self.sample)
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11,12]])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11,12], None])
self.apply_user_action(["DetachSummaryViewSection", 1])
# Create a summary of the detached table (tableRef 3) by state (colRef 21).
self.apply_user_action(["CreateViewSection", 3, 0, "record", [21]])
self.apply_user_action(["CreateViewSection", 3, 0, "record", [21], None])
# Verify the resulting metadata.
self.assertTables([

@ -43,7 +43,7 @@ class TestSummaryChoiceList(EngineTestCase):
self.assertViews([])
# Create a summary section, grouped by the "choices1" column.
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11]])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11], None])
summary_table1 = Table(
2, "GristSummary_6_Source", primaryViewId=0, summarySourceTable=1,
@ -57,7 +57,7 @@ class TestSummaryChoiceList(EngineTestCase):
)
# Create another summary section, grouped by both choicelist columns.
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11, 12]])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11, 12], None])
summary_table2 = Table(
3, "GristSummary_6_Source2", primaryViewId=0, summarySourceTable=1,
@ -72,7 +72,7 @@ class TestSummaryChoiceList(EngineTestCase):
)
# Create another summary section, grouped by the non-choicelist column
self.apply_user_action(["CreateViewSection", 1, 0, "record", [10]])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [10], None])
summary_table3 = Table(
4, "GristSummary_6_Source3", primaryViewId=0, summarySourceTable=1,
@ -86,7 +86,7 @@ class TestSummaryChoiceList(EngineTestCase):
)
# Create another summary section, grouped by the non-choicelist column and choices1
self.apply_user_action(["CreateViewSection", 1, 0, "record", [10, 11]])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [10, 11], None])
summary_table4 = Table(
5, "GristSummary_6_Source4", primaryViewId=0, summarySourceTable=1,
@ -321,7 +321,7 @@ class TestSummaryChoiceList(EngineTestCase):
self.assertViews([])
# Create a summary section, grouped by the "choices1" column.
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11]])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11], None])
summary_table = Table(
2, "GristSummary_6_Source", primaryViewId=0, summarySourceTable=1,
@ -359,7 +359,7 @@ class TestSummaryChoiceList(EngineTestCase):
self.load_sample(self.sample)
# Create a summary section, grouped by both choicelist columns.
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11, 12]])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [11, 12], None])
summary_table = Table(
2, "GristSummary_6_Source", primaryViewId=0, summarySourceTable=1,

@ -30,7 +30,7 @@ class TestSummaryUndo(test_engine.EngineTestCase):
# This tests a particular case of a bug when a summary table wasn't fully updated after UNDO.
self.load_sample(self.sample)
# Create a summary section, grouped by the "State" column.
self.apply_user_action(["CreateViewSection", 1, 0, "record", [1]])
self.apply_user_action(["CreateViewSection", 1, 0, "record", [1], None])
self.assertTableData('GristSummary_6_Person', cols="subset", data=[
[ "id", "state", "count"],
[ 1, "NY", 2],

@ -53,9 +53,9 @@ class TestTableActions(test_engine.EngineTestCase):
self.apply_user_action(["BulkAddRecord", "People", d.row_ids, d.columns])
# Add a view with several sections, including a summary table.
self.apply_user_action(["CreateViewSection", 1, 0, 'record', None])
self.apply_user_action(["CreateViewSection", 1, 3, 'record', [3]])
self.apply_user_action(["CreateViewSection", 2, 3, 'record', None])
self.apply_user_action(["CreateViewSection", 1, 0, 'record', None, None])
self.apply_user_action(["CreateViewSection", 1, 3, 'record', [3], None])
self.apply_user_action(["CreateViewSection", 2, 3, 'record', None, None])
# Verify the new structure of tables and views.
self.assertTables([
@ -211,7 +211,7 @@ class TestTableActions(test_engine.EngineTestCase):
self.init_sample_data()
# Add a table grouped by a reference column (the 'Ref:Address' column named 'address').
self.apply_user_action(["CreateViewSection", 2, 0, 'record', [7]])
self.apply_user_action(["CreateViewSection", 2, 0, 'record', [7], None])
self.assertTableData('_grist_Tables_column', cols="subset", data=[
["id", "colId", "type", "isFormula", "formula" ],
[ 13, "address", "Ref:Address", False, "" ],
@ -258,10 +258,10 @@ class TestTableActions(test_engine.EngineTestCase):
self.init_sample_data()
# Add one more table, and one more view for tables #1 and #4 (those we are about to delete).
self.apply_user_action(["AddEmptyTable"])
out_actions = self.apply_user_action(["CreateViewSection", 1, 0, 'detail', None])
self.apply_user_action(["AddEmptyTable", None])
out_actions = self.apply_user_action(["CreateViewSection", 1, 0, 'detail', None, None])
self.assertEqual(out_actions.retValues[0]["viewRef"], 5)
self.apply_user_action(["CreateViewSection", 4, 5, 'detail', None])
self.apply_user_action(["CreateViewSection", 4, 5, 'detail', None, None])
# See what's in TabBar table, to verify after we remove a table.
self.assertTableData('_grist_TabBar', cols="subset", data=[

@ -6,7 +6,7 @@ class TestUndo(test_engine.EngineTestCase):
def test_bad_undo(self):
# Sometimes undo can make metadata inconsistent with schema. Check that we disallow it.
self.load_sample(testsamples.sample_students)
out_actions1 = self.apply_user_action(['AddEmptyTable'])
out_actions1 = self.apply_user_action(['AddEmptyTable', None])
self.assertPartialData("_grist_Tables", ["id", "tableId", "columns"], [
[1, "Students", [1,2,4,5,6]],
[2, "Schools", [10,12]],
@ -60,7 +60,7 @@ class TestUndo(test_engine.EngineTestCase):
# during undo of imports when the undo could omit part of the action bundle.
self.load_sample(testsamples.sample_students)
out_actions1 = self.apply_user_action(['AddEmptyTable'])
out_actions1 = self.apply_user_action(['AddEmptyTable', None])
out_actions2 = self.add_column('Table1', 'D', type='Text')
out_actions3 = self.remove_column('Table1', 'D')
out_actions4 = self.apply_user_action(['RemoveTable', 'Table1'])

@ -77,10 +77,7 @@ class TestUserActions(test_engine.EngineTestCase):
'type': 'Text'
}],
["AddRecord", "_grist_Views_section_field", 3, {
"colRef": 24, "parentId": 1, "parentPos": 3.0
}],
["AddRecord", "_grist_Views_section_field", 4, {
"colRef": 24, "parentId": 2, "parentPos": 4.0
"colRef": 24, "parentId": 2, "parentPos": 3.0
}],
["BulkUpdateRecord", "Schools", [1, 2, 3],
{"grist_Transform": ["New York", "Colombia", "New York"]}],
@ -124,7 +121,7 @@ class TestUserActions(test_engine.EngineTestCase):
out_actions = self.remove_column('Schools', 'grist_Transform')
self.assertPartialOutActions(out_actions, { "stored": [
["BulkRemoveRecord", "_grist_Views_section_field", [3, 4]],
["RemoveRecord", "_grist_Views_section_field", 3],
['RemoveRecord', '_grist_Tables_column', 24],
['RemoveColumn', 'Schools', 'grist_Transform'],
]})
@ -137,7 +134,7 @@ class TestUserActions(test_engine.EngineTestCase):
self.assertTables([self.starting_table])
# Create a view + section for the initial table.
self.apply_user_action(["CreateViewSection", 1, 0, "record", None])
self.apply_user_action(["CreateViewSection", 1, 0, "record", None, None])
# Verify that we got a new view, with one section, and three fields.
self.assertViews([View(1, sections=[
@ -147,7 +144,7 @@ class TestUserActions(test_engine.EngineTestCase):
]) ])
# Create a new section for the same view, check that only a section is added.
self.apply_user_action(["CreateViewSection", 1, 1, "record", None])
self.apply_user_action(["CreateViewSection", 1, 1, "record", None, None])
self.assertTables([self.starting_table])
self.assertViews([View(1, sections=[
Section(1, parentKey="record", tableRef=1, fields=[
@ -159,7 +156,7 @@ class TestUserActions(test_engine.EngineTestCase):
]) ])
# Create another section for the same view, this time summarized.
self.apply_user_action(["CreateViewSection", 1, 1, "record", [21]])
self.apply_user_action(["CreateViewSection", 1, 1, "record", [21], None])
summary_table = Table(2, "GristSummary_7_Address", 0, summarySourceTable=1, columns=[
Column(22, "city", "Text", isFormula=False, formula="", summarySourceCol=21),
Column(23, "group", "RefList:Address", isFormula=True,
@ -185,7 +182,7 @@ class TestUserActions(test_engine.EngineTestCase):
# Try to create a summary table for an invalid column, and check that it fails.
with self.assertRaises(ValueError):
self.apply_user_action(["CreateViewSection", 1, 1, "record", [23]])
self.apply_user_action(["CreateViewSection", 1, 1, "record", [23], None])
self.assertTables([self.starting_table, summary_table])
self.assertViews([view])
@ -197,69 +194,55 @@ class TestUserActions(test_engine.EngineTestCase):
self.assertTables([self.starting_table])
self.assertViews([])
# When we create a section/view for new table, we get both a primary view, and the new view we
# are creating.
self.apply_user_action(["CreateViewSection", 0, 0, "record", None])
new_table = Table(2, "Table1", primaryViewId=1, summarySourceTable=0, columns=[
# When we create a section/view for new table, we got the new view we are creating,
# without primary view.
self.apply_user_action(["CreateViewSection", 0, 0, "record", None, None])
new_table = Table(2, "Table1", primaryViewId=0, summarySourceTable=0, columns=[
Column(22, "manualSort", "ManualSortPos", isFormula=False, formula="", summarySourceCol=0),
Column(23, "A", "Any", isFormula=True, formula="", summarySourceCol=0),
Column(24, "B", "Any", isFormula=True, formula="", summarySourceCol=0),
Column(25, "C", "Any", isFormula=True, formula="", summarySourceCol=0),
])
primary_view = View(1, sections=[
Section(1, parentKey="record", tableRef=2, fields=[
Field(1, colRef=23),
Field(2, colRef=24),
Field(3, colRef=25),
])
])
new_view = View(2, sections=[
Section(3, parentKey="record", tableRef=2, fields=[
Field(7, colRef=23),
Field(8, colRef=24),
Field(9, colRef=25),
new_view = View(1, sections=[
Section(2, parentKey="record", tableRef=2, fields=[
Field(4, colRef=23),
Field(5, colRef=24),
Field(6, colRef=25),
])
])
self.assertTables([self.starting_table, new_table])
self.assertViews([primary_view, new_view])
self.assertViews([new_view])
# Create another section in an existing view for a new table.
self.apply_user_action(["CreateViewSection", 0, 2, "record", None])
new_table2 = Table(3, "Table2", primaryViewId=3, summarySourceTable=0, columns=[
self.apply_user_action(["CreateViewSection", 0, 1, "record", None, None])
new_table2 = Table(3, "Table2", primaryViewId=0, summarySourceTable=0, columns=[
Column(26, "manualSort", "ManualSortPos", isFormula=False, formula="", summarySourceCol=0),
Column(27, "A", "Any", isFormula=True, formula="", summarySourceCol=0),
Column(28, "B", "Any", isFormula=True, formula="", summarySourceCol=0),
Column(29, "C", "Any", isFormula=True, formula="", summarySourceCol=0),
])
primary_view2 = View(3, sections=[
new_view.sections.append(
Section(4, parentKey="record", tableRef=3, fields=[
Field(10, colRef=27),
Field(11, colRef=28),
Field(12, colRef=29),
])
])
new_view.sections.append(
Section(6, parentKey="record", tableRef=3, fields=[
Field(16, colRef=27),
Field(17, colRef=28),
Field(18, colRef=29),
])
)
# Check that we have a new table, only the primary view as new view; and a new section.
# Check that we have a new table, only the new view; and a new section.
self.assertTables([self.starting_table, new_table, new_table2])
self.assertViews([primary_view, new_view, primary_view2])
self.assertViews([new_view])
# Check that we can't create a summary of a table grouped by a column that doesn't exist yet.
with self.assertRaises(ValueError):
self.apply_user_action(["CreateViewSection", 0, 2, "record", [31]])
self.apply_user_action(["CreateViewSection", 0, 1, "record", [31], None])
self.assertTables([self.starting_table, new_table, new_table2])
self.assertViews([primary_view, new_view, primary_view2])
self.assertViews([new_view])
# But creating a new table and showing totals for it is possible though dumb.
self.apply_user_action(["CreateViewSection", 0, 2, "record", []])
self.apply_user_action(["CreateViewSection", 0, 1, "record", [], None])
# We expect a new table.
new_table3 = Table(4, "Table3", primaryViewId=4, summarySourceTable=0, columns=[
new_table3 = Table(4, "Table3", primaryViewId=0, summarySourceTable=0, columns=[
Column(30, "manualSort", "ManualSortPos", isFormula=False, formula="", summarySourceCol=0),
Column(31, "A", "Any", isFormula=True, formula="", summarySourceCol=0),
Column(32, "B", "Any", isFormula=True, formula="", summarySourceCol=0),
@ -271,20 +254,11 @@ class TestUserActions(test_engine.EngineTestCase):
formula="table.getSummarySourceGroup(rec)", summarySourceCol=0),
Column(35, "count", "Int", isFormula=True, formula="len($group)", summarySourceCol=0),
])
# The primary view of the new table.
primary_view3 = View(4, sections=[
Section(7, parentKey="record", tableRef=4, fields=[
Field(19, colRef=31),
Field(20, colRef=32),
Field(21, colRef=33),
])
])
# And a new view section for the summary.
new_view.sections.append(Section(9, parentKey="record", tableRef=5, fields=[
Field(25, colRef=35)
]))
self.assertTables([self.starting_table, new_table, new_table2, new_table3, summary_table])
self.assertViews([primary_view, new_view, primary_view2, primary_view3])
new_view.sections.append(Section(6, parentKey="record", tableRef=5, fields=[
Field(16, colRef=35)
]))
self.assertViews([new_view])
#----------------------------------------------------------------------
@ -301,10 +275,10 @@ class TestUserActions(test_engine.EngineTestCase):
'size': [1000, 2000, 3000, 4000],
}])
# Add a new view; a second section (summary) to it; and a third view.
self.apply_user_action(['CreateViewSection', 1, 0, 'detail', None])
self.apply_user_action(['CreateViewSection', 1, 2, 'record', [3]])
self.apply_user_action(['CreateViewSection', 1, 0, 'chart', None])
self.apply_user_action(['CreateViewSection', 0, 2, 'record', None])
self.apply_user_action(['CreateViewSection', 1, 0, 'detail', None, None])
self.apply_user_action(['CreateViewSection', 1, 2, 'record', [3], None])
self.apply_user_action(['CreateViewSection', 1, 0, 'chart', None, None])
self.apply_user_action(['CreateViewSection', 0, 2, 'record', None, None])
# Verify the new structure of tables and views.
self.assertTables([
@ -320,7 +294,7 @@ class TestUserActions(test_engine.EngineTestCase):
Column(7, "count", "Int", True, "len($group)", 0),
Column(8, "size", "Numeric", True, "SUM($group.size)", 0),
]),
Table(3, 'Table1', 4, 0, columns=[
Table(3, 'Table1', 0, 0, columns=[
Column(9, "manualSort", "ManualSortPos", False, "", 0),
Column(10, "A", "Any", True, "", 0),
Column(11, "B", "Any", True, "", 0),
@ -346,10 +320,10 @@ class TestUserActions(test_engine.EngineTestCase):
Field(11, colRef=7),
Field(12, colRef=8),
]),
Section(8, parentKey='record', tableRef=3, fields=[
Field(21, colRef=10),
Field(22, colRef=11),
Field(23, colRef=12),
Section(7, parentKey='record', tableRef=3, fields=[
Field(18, colRef=10),
Field(19, colRef=11),
Field(20, colRef=12),
]),
]),
View(3, sections=[
@ -357,28 +331,19 @@ class TestUserActions(test_engine.EngineTestCase):
Field(13, colRef=2),
Field(14, colRef=3),
]),
]),
View(4, sections=[
Section(6, parentKey='record', tableRef=3, fields=[
Field(15, colRef=10),
Field(16, colRef=11),
Field(17, colRef=12),
]),
]),
])
])
self.assertTableData('_grist_TabBar', cols="subset", data=[
["id", "viewRef"],
[1, 1],
[2, 2],
[3, 3],
[4, 4],
])
self.assertTableData('_grist_Pages', cols="subset", data=[
["id", "viewRef"],
[1, 1],
[2, 2],
[3, 3],
[4, 4]
[3, 3]
])
#----------------------------------------------------------------------
@ -388,7 +353,7 @@ class TestUserActions(test_engine.EngineTestCase):
self.init_views_sample()
# Remove a view. Ensure related items, sections, fields get removed.
self.apply_user_action(["BulkRemoveRecord", "_grist_Views", [2,3]])
self.apply_user_action(["BulkRemoveRecord", "_grist_Views", [2, 3]])
# Verify the new structure of tables and views.
self.assertTables([
@ -399,7 +364,7 @@ class TestUserActions(test_engine.EngineTestCase):
Column(4, "size", "Numeric", False, "", 0),
]),
# Note that the summary table is gone.
Table(3, 'Table1', 4, 0, columns=[
Table(3, 'Table1', 0, 0, columns=[
Column(9, "manualSort", "ManualSortPos", False, "", 0),
Column(10, "A", "Any", True, "", 0),
Column(11, "B", "Any", True, "", 0),
@ -413,24 +378,15 @@ class TestUserActions(test_engine.EngineTestCase):
Field(2, colRef=3),
Field(3, colRef=4),
]),
]),
View(4, sections=[
Section(6, parentKey='record', tableRef=3, fields=[
Field(15, colRef=10),
Field(16, colRef=11),
Field(17, colRef=12),
]),
]),
])
])
self.assertTableData('_grist_TabBar', cols="subset", data=[
["id", "viewRef"],
[1, 1],
[4, 4],
])
self.assertTableData('_grist_Pages', cols="subset", data=[
["id", "viewRef"],
[1, 1],
[4, 4],
])
#----------------------------------------------------------------------
@ -444,33 +400,31 @@ class TestUserActions(test_engine.EngineTestCase):
[ 'id', 'tableId', 'primaryViewId' ],
[ 1, 'Schools', 1],
[ 2, 'GristSummary_7_Schools', 0],
[ 3, 'Table1', 4],
[ 3, 'Table1', 0],
])
self.assertTableData('_grist_Views', cols="subset", data=[
[ 'id', 'name', 'primaryViewTable' ],
[ 1, 'Schools', 1],
[ 2, 'New page', 0],
[ 3, 'New page', 0],
[ 4, 'Table1', 3],
])
# Update the names in a few views, and ensure that primary ones won't cause tables to
# get renamed.
self.apply_user_action(['BulkUpdateRecord', '_grist_Views', [2,3,4],
{'name': ['A', 'B', 'C']}])
self.apply_user_action(['BulkUpdateRecord', '_grist_Views', [2, 3],
{'name': ['A', 'B']}])
self.assertTableData('_grist_Tables', cols="subset", data=[
[ 'id', 'tableId', 'primaryViewId' ],
[ 1, 'Schools', 1],
[ 2, 'GristSummary_7_Schools', 0],
[ 3, 'Table1', 4],
[ 3, 'Table1', 0],
])
self.assertTableData('_grist_Views', cols="subset", data=[
[ 'id', 'name', 'primaryViewTable' ],
[ 1, 'Schools', 1],
[ 2, 'A', 0],
[ 3, 'B', 0],
[ 4, 'C', 3]
[ 3, 'B', 0]
])
# Now rename a table (by raw view section) and make sure that a view with the same name
@ -482,14 +436,13 @@ class TestUserActions(test_engine.EngineTestCase):
['id', 'tableId'],
[1, 'Bars', 1],
[2, 'GristSummary_4_Bars', 0],
[3, 'Table1', 4],
[3, 'Table1', 0],
])
self.assertTableData('_grist_Views', cols="subset", data=[
['id', 'name'],
[1, 'Bars'],
[2, 'A'],
[3, 'B'],
[4, 'C']
[3, 'B']
])
# Now rename tables so that two tables will have same names, to test if only the view
@ -501,47 +454,57 @@ class TestUserActions(test_engine.EngineTestCase):
['id', 'tableId'],
[1, 'A', 1],
[2, 'GristSummary_1_A', 0],
[3, 'Table1', 4],
[3, 'Table1', 0],
])
self.assertTableData('_grist_Views', cols="subset", data=[
['id', 'name'],
[1, 'A'],
[2, 'A'],
[3, 'B'],
[4, 'C']
])
self.apply_user_action(['UpdateRecord', '_grist_Views_section', 2,
{'title': 'Z'}])
self.assertTableData('_grist_Tables', cols="subset", data=[
['id', 'tableId'],
[1, 'Z', 1],
[2, 'GristSummary_1_Z', 0],
[3, 'Table1', 4],
['id', 'tableId', 'primaryViewId', 'rawViewSectionRef'],
[1, 'Z', 1, 2],
[2, 'GristSummary_1_Z', 0, 0],
[3, 'Table1', 0, 6],
])
self.assertTableData('_grist_Views', cols="subset", data=[
['id', 'name'],
[1, 'Z'],
[2, 'Z'],
[3, 'B'],
[4, 'C']
])
# Add new table, with a view with the same name (Z) and make sure it won't be renamed
self.apply_user_action(['AddTable', 'Stations', [
{'id': 'city', 'type': 'Text'},
]])
self.assertTableData('_grist_Tables', cols="subset", data=[
['id', 'tableId', 'primaryViewId', 'rawViewSectionRef'],
[1, 'Z', 1, 2],
[2, 'GristSummary_1_Z', 0, 0],
[3, 'Table1', 0, 6],
[4, 'Stations', 4, 9],
])
self.assertTableData('_grist_Views', cols="subset", data=[
['id', 'name'],
[1, 'Z'],
[2, 'Z'],
[3, 'B'],
[4, 'Stations'],
])
# Replacing only a page name (though primary)
self.apply_user_action(['UpdateRecord', '_grist_Views', 5, {'name': 'Z'}])
self.apply_user_action(['UpdateRecord', '_grist_Views', 4, {'name': 'Z'}])
self.assertTableData('_grist_Views', cols="subset", data=[
['id', 'name'],
[1, 'Z'],
[2, 'Z'],
[3, 'B'],
[4, 'C'],
[5, 'Z']
[4, 'Z']
])
# Rename table Z to Schools. Primary view for Stations (Z) should not be renamed.
@ -560,8 +523,7 @@ class TestUserActions(test_engine.EngineTestCase):
[1, 'Schools'],
[2, 'Schools'],
[3, 'B'],
[4, 'C'],
[5, 'Z']
[4, 'Z']
])
#----------------------------------------------------------------------
@ -570,9 +532,6 @@ class TestUserActions(test_engine.EngineTestCase):
# Add a couple of tables and views, to trigger creation of some related items.
self.init_views_sample()
# Remove a couple of sections. Ensure their fields get removed.
self.apply_user_action(['BulkRemoveRecord', '_grist_Views_section', [4, 8]])
self.assertViews([
View(1, sections=[
Section(1, parentKey="record", tableRef=1, fields=[
@ -587,20 +546,49 @@ class TestUserActions(test_engine.EngineTestCase):
Field(8, colRef=3),
Field(9, colRef=4),
]),
Section(4, parentKey="record", tableRef=2, fields=[
Field(10, colRef=5),
Field(11, colRef=7),
Field(12, colRef=8),
]),
Section(7, parentKey='record', tableRef=3, fields=[
Field(18, colRef=10),
Field(19, colRef=11),
Field(20, colRef=12),
]),
]),
View(3, sections=[
Section(5, parentKey="chart", tableRef=1, fields=[
Field(13, colRef=2),
Field(14, colRef=3),
]),
]),
View(4, sections=[
Section(6, parentKey='record', tableRef=3, fields=[
Field(15, colRef=10),
Field(16, colRef=11),
Field(17, colRef=12),
])
])
# Remove a couple of sections. Ensure their fields get removed.
self.apply_user_action(['BulkRemoveRecord', '_grist_Views_section', [4, 7]])
self.assertViews([
View(1, sections=[
Section(1, parentKey="record", tableRef=1, fields=[
Field(1, colRef=2),
Field(2, colRef=3),
Field(3, colRef=4),
]),
]),
View(2, sections=[
Section(3, parentKey="detail", tableRef=1, fields=[
Field(7, colRef=2),
Field(8, colRef=3),
Field(9, colRef=4),
])
]),
View(3, sections=[
Section(5, parentKey="chart", tableRef=1, fields=[
Field(13, colRef=2),
Field(14, colRef=3),
]),
])
])
#----------------------------------------------------------------------
@ -625,14 +613,14 @@ class TestUserActions(test_engine.EngineTestCase):
# Do a schema action to ensure it gets called: this causes a table rename.
# 7 is id of raw view section for the Tabl1 table
self.apply_user_action(['UpdateRecord', '_grist_Views_section', 7, {'title': 'C'}])
self.apply_user_action(['UpdateRecord', '_grist_Views_section', 6, {'title': 'C'}])
self.assertEqual(count_calls[0], 1)
self.assertTableData('_grist_Tables', cols="subset", data=[
[ 'id', 'tableId', 'primaryViewId' ],
[ 1, 'Schools', 1],
[ 2, 'GristSummary_7_Schools', 0],
[ 3, 'C', 4],
[ 3, 'C', 0],
])
# Do another schema and non-schema action.
@ -834,7 +822,6 @@ class TestUserActions(test_engine.EngineTestCase):
[ 1, 0],
[ 2, 1],
[ 3, 0],
[ 4, 0],
])
# Verify that removing page 1 fixes page 2 indentation.
@ -843,7 +830,6 @@ class TestUserActions(test_engine.EngineTestCase):
['id', 'indentation'],
[ 2, 0],
[ 3, 0],
[ 4, 0],
])
# Removing last page should not fail
@ -962,7 +948,7 @@ class TestUserActions(test_engine.EngineTestCase):
# Test filters rename
# Create new view section
self.apply_user_action(["CreateViewSection", 1, 0, "record", None])
self.apply_user_action(["CreateViewSection", 1, 0, "record", None, None])
# Filter it by first column
self.apply_user_action(['BulkAddRecord', '_grist_Filters', [None], {
@ -1287,7 +1273,7 @@ class TestUserActions(test_engine.EngineTestCase):
# load_sample handles loading basic metadata, but doesn't create any view sections
self.load_sample(self.sample)
# Create a new table which automatically gets a raw view section
self.apply_user_action(["AddEmptyTable"])
self.apply_user_action(["AddEmptyTable", None])
# Note the row IDs of the raw view section (2) and fields (4, 5, 6)
self.assertTableData('_grist_Views_section', cols="subset", data=[
@ -1339,7 +1325,7 @@ class TestUserActions(test_engine.EngineTestCase):
def test_update_current_time(self):
self.load_sample(self.sample)
self.apply_user_action(["AddEmptyTable"])
self.apply_user_action(["AddEmptyTable", None])
self.add_column('Table1', 'now', isFormula=True, formula='NOW()', type='Any')
# No records with NOW() in a formula yet, so this action should have no effect at all.
@ -1374,7 +1360,7 @@ class TestUserActions(test_engine.EngineTestCase):
check(1)
# Testing that unrelated actions don't change the time
self.apply_user_action(["AddEmptyTable"])
self.apply_user_action(["AddEmptyTable", None])
self.add_record("Table2")
self.apply_user_action(["Calculate"]) # only recalculates for fresh docs
check(1)

@ -916,7 +916,7 @@
{"parentId": 1, "colRef": 34, "parentPos": 1.0}],
// Raw data widget
["AddRecord", "_grist_Views_section", 2, {"borderWidth": 1, "defaultWidth": 100, "parentKey": "record", "tableRef": 4}],
["AddRecord", "_grist_Views_section", 2, {"borderWidth": 1, "defaultWidth": 100, "parentKey": "record", "tableRef": 4, "title": ""}],
["AddRecord", "_grist_Views_section_field", 2, {"colRef": 34, "parentId": 2, "parentPos": 2.0}],
["UpdateRecord", "_grist_Tables", 4, {"primaryViewId": 1, "rawViewSectionRef": 2}],
@ -927,10 +927,9 @@
{"colId": "world", "parentPos": 13.0,
"formula": "rec.hello.upper()", "parentId": 4, "type": "Text",
"isFormula": true, "label": "world", "widgetOptions": ""}],
["AddRecord", "_grist_Views_section_field", 3, {"colRef": 35, "parentId": 1, "parentPos": 3.0}],
["AddRecord", "_grist_Views_section_field", 4, {"colRef": 35, "parentId": 2, "parentPos": 4.0}]
["AddRecord", "_grist_Views_section_field", 3, {"colRef": 35, "parentId": 2, "parentPos": 3.0}]
],
"direct": [true, true, true, true, true,
"direct": [true, true, true, true,
true, true, true, true, true, true, true,
true, true, true],
"undo": [
@ -947,8 +946,7 @@
["UpdateRecord", "_grist_Tables", 4, {"primaryViewId": 0, "rawViewSectionRef": 0}],
["RemoveColumn", "Bar", "world"],
["RemoveRecord", "_grist_Tables_column", 35],
["RemoveRecord", "_grist_Views_section_field", 3],
["RemoveRecord", "_grist_Views_section_field", 4]
["RemoveRecord", "_grist_Views_section_field", 3]
],
"retValue": [
{
@ -1257,7 +1255,7 @@
"parentId": 1, "parentKey": "record", "sortColRefs": "[]", "title": ""}],
["BulkAddRecord", "_grist_Views_section_field", [1,2],
{"parentId": [1,1], "colRef": [31,32], "parentPos": [1.0,2.0]}],
["AddRecord", "_grist_Views_section", 2, {"borderWidth": 1, "defaultWidth": 100, "parentKey": "record", "tableRef": 4}],
["AddRecord", "_grist_Views_section", 2, {"borderWidth": 1, "defaultWidth": 100, "parentKey": "record", "tableRef": 4, "title": ""}],
["BulkAddRecord", "_grist_Views_section_field", [3, 4], {"colRef": [31, 32], "parentId": [2, 2], "parentPos": [3.0, 4.0]}],
["UpdateRecord", "_grist_Tables", 4, {"primaryViewId": 1, "rawViewSectionRef": 2}],
["BulkRemoveRecord", "_grist_Views_section_field", [1, 3]],
@ -2200,7 +2198,7 @@
["AddRecord", "_grist_Views_section", 1,
{"tableRef": 4, "defaultWidth": 100, "borderWidth": 1,
"parentId": 1, "parentKey": "record", "sortColRefs": "[]", "title": ""}],
["AddRecord", "_grist_Views_section", 2, {"borderWidth": 1, "defaultWidth": 100, "parentKey": "record", "tableRef": 4}],
["AddRecord", "_grist_Views_section", 2, {"borderWidth": 1, "defaultWidth": 100, "parentKey": "record", "tableRef": 4, "title": ""}],
["UpdateRecord", "_grist_Tables", 4, {"primaryViewId": 1, "rawViewSectionRef": 2}],
["AddTable", "Bar", [
{"id": "manualSort", "formula": "", "isFormula": false, "type": "ManualSortPos"},
@ -2230,7 +2228,7 @@
"parentId": 2, "parentKey": "record", "sortColRefs": "[]", "title": ""}],
["BulkAddRecord", "_grist_Views_section_field", [1,2,3],
{"parentId": [3,3,3], "colRef": [32,33,34], "parentPos": [1.0,2.0,3.0]}],
["AddRecord", "_grist_Views_section", 4, {"borderWidth": 1, "defaultWidth": 100, "parentKey": "record", "tableRef": 5}],
["AddRecord", "_grist_Views_section", 4, {"borderWidth": 1, "defaultWidth": 100, "parentKey": "record", "tableRef": 5, "title": ""}],
["BulkAddRecord", "_grist_Views_section_field", [4, 5, 6], {"colRef": [32, 33, 34], "parentId": [4, 4, 4], "parentPos": [4.0, 5.0, 6.0]}],
["UpdateRecord", "_grist_Tables", 5, {"primaryViewId": 2, "rawViewSectionRef": 4}],
["AddRecord", "Bar", 1, {"foo": 0, "hello": "a", "manualSort": 1.0}],
@ -2334,7 +2332,7 @@
{"tableRef": 4, "defaultWidth": 100, "borderWidth": 1,
"parentId": 1, "parentKey": "record", "sortColRefs": "[]", "title": ""}],
// Raw data widget
["AddRecord", "_grist_Views_section", 2, {"borderWidth": 1, "defaultWidth": 100, "parentKey": "record", "tableRef": 4}],
["AddRecord", "_grist_Views_section", 2, {"borderWidth": 1, "defaultWidth": 100, "parentKey": "record", "tableRef": 4, "title": ""}],
// As part of adding a table, we also set the primaryViewId.
["UpdateRecord", "_grist_Tables", 4, {"primaryViewId": 1, "rawViewSectionRef": 2}]
],

@ -1004,10 +1004,6 @@ class UserActions(object):
# Remove any views that no longer have view sections.
views_to_remove = [view for view in self._docmodel.views.all
if not view.viewSections]
# Also delete the primary views for tables being deleted, even if it has remaining sections.
for t in remove_table_recs:
if t.primaryViewId and t.primaryViewId not in views_to_remove:
views_to_remove.append(t.primaryViewId)
self._docmodel.remove(views_to_remove)
# Save table IDs, which will be inaccessible once we remove the metadata records.
@ -1211,20 +1207,49 @@ class UserActions(object):
ret = self.doAddColumn(table_id, col_id, col_info)
if not transform and table_rec.rawViewSectionRef:
# Add a field for this column to the "raw_data" section for this table.
# TODO: the position of the inserted field or of the inserted column will often be
# bogus, since fields and columns are not the same. This requires better coordination
# with the client-side.
self._docmodel.insert(
table_rec.rawViewSectionRef.fields,
col_info.get('_position'),
colRef=ret['colRef']
)
return ret
@useraction
def AddHiddenColumn(self, table_id, col_id, col_info):
return self.doAddColumn(table_id, col_id, col_info)
@useraction
def AddVisibleColumn(self, table_id, col_id, col_info):
'''Inserts column and adds it as a field to all 'record' views'''
ret = self.AddColumn(table_id, col_id, col_info)
table_rec = self._docmodel.get_table_rec(table_id)
transform = (
col_id is not None and
col_id.startswith((
'gristHelper_Transform',
'gristHelper_Converted',
))
)
# Add a field for this column to the view(s) for this table.
if not transform:
# Add a field for this column to the "raw_data" view(s) for this table.
for section in table_rec.viewSections:
if section.parentKey == 'record':
if section.parentKey == 'record' and section != table_rec.rawViewSectionRef:
# TODO: the position of the inserted field or of the inserted column will often be
# bogus, since fields and columns are not the same. This requires better coordination
# with the client-side.
self._docmodel.insert(section.fields, col_info.get('_position'), colRef=ret['colRef'])
return ret
@useraction
def AddHiddenColumn(self, table_id, col_id, col_info):
return self.doAddColumn(table_id, col_id, col_info)
@classmethod
def _pick_col_name(cls, table_rec, col_id, old_col_id=None, avoid_extra=None):
@ -1594,15 +1619,45 @@ class UserActions(object):
#----------------------------------------
@useraction
def AddEmptyTable(self):
def AddEmptyTable(self, table_id):
"""
Adds an empty table. Currently it makes up the next available table name, and adds three
default columns, also picking default names for them (presumably, A, B, and C).
Adds an empty table. Currently it makes up the next available table name (if not provided),
and adds three default columns, also picking default names for them (presumably, A, B, and C).
"""
return self.AddTable(None, [{'id': None, 'isFormula': True} for x in xrange(3)])
columns = [{'id': None, 'isFormula': True} for x in xrange(3)]
return self.AddTable(table_id, columns)
@useraction
def AddTable(self, table_id, columns):
return self.doAddTable(
table_id,
columns,
manual_sort=True,
primary_view=True,
raw_section=True)
@useraction
def AddRawTable(self, table_id):
"""
Same as AddEmptyTable but does not create a primary view (and page).
"""
columns = [{'id': None, 'isFormula': True} for x in xrange(3)]
return self.doAddTable(
table_id,
columns,
manual_sort=True,
primary_view=False,
raw_section=True
)
def doAddTable(self, table_id, columns, manual_sort=False, primary_view=False,
raw_section=False, summarySourceTableRef=0):
"""
Add the given table with columns with or without additional views.
"""
# For any columns missing 'isFormula' field, default to False when formula is empty. We will
# normally default new columns to "empty" (isFormula=True), and AddEmptyTable creates empty
# columns, but an AddTable action created e.g. by an import will default to data columns.
@ -1610,35 +1665,14 @@ class UserActions(object):
c.setdefault("isFormula", bool(c.get('formula')))
# Add a manualSort column.
columns.insert(0, column.MANUAL_SORT_COL_INFO.copy())
# First the tables is created without a primary view assigned as no view for it exists.
result = self.doAddTable(table_id, columns)
# Then its Primary View is created.
primary_view = self.doAddView(result["table_id"], 'raw_data', result["table_id"])
result["views"] = [primary_view]
raw_view_section = self._create_plain_view_section(
result["id"],
result["table_id"],
self._docmodel.view_sections,
"record",
)
self.UpdateRecord('_grist_Tables', result["id"], {
'primaryViewId': primary_view["id"],
'rawViewSectionRef': raw_view_section.id,
})
return result
if manual_sort:
columns.insert(0, column.MANUAL_SORT_COL_INFO.copy())
def doAddTable(self, table_id, columns, summarySourceTableRef=0):
"""
Add the given table with columns without creating views.
"""
# If needed, transform table_id into a valid identifier, and add a suffix to make it unique.
table_title = table_id
table_id = identifiers.pick_table_ident(table_id, avoid=six.viewkeys(self._engine.tables))
if not table_title:
table_title = table_id
# Sanitize and de-duplicate column identifiers.
col_ids = [c['id'] for c in columns]
col_ids = identifiers.pick_col_ident_list(col_ids, avoid={'id'})
@ -1660,12 +1694,36 @@ class UserActions(object):
label = [c.get('label', col_id) for (c, col_id) in zip(columns, col_ids)],
widgetOptions = [c.get('widgetOptions', '') for c in columns])
return {
result = {
"id": table_rec.id,
"table_id": table_id,
"columns": col_ids[1:], # All the column ids, except the auto-added manualSort.
}
if primary_view:
# Create a primary view
primary_view = self.doAddView(result["table_id"], 'raw_data', table_title)
result["views"] = [primary_view]
if raw_section:
# Create raw view section
raw_section = self._create_plain_view_section(
result["id"],
table_id,
self._docmodel.view_sections,
"record",
table_title
)
if primary_view or raw_section:
self.UpdateRecord('_grist_Tables', result["id"], {
'primaryViewId': primary_view["id"] if primary_view else 0,
'rawViewSectionRef': raw_section.id if raw_section else 0,
})
return result
@useraction
def RemoveTable(self, table_id):
# We can remove a table via either a "RemoveTable" useraction or by removing a table
@ -1694,7 +1752,7 @@ class UserActions(object):
return cols
@useraction
def CreateViewSection(self, table_ref, view_ref, section_type, groupby_colrefs):
def CreateViewSection(self, table_ref, view_ref, section_type, groupby_colrefs, table_id):
"""
Create a new view section. If table_ref is 0, also creates a new empty table. If view_ref is
0, also creates a new view that will contain the new section. If groupby_colrefs is None,
@ -1705,7 +1763,7 @@ class UserActions(object):
groupby_cols = self._fetch_table_col_recs(table_ref, groupby_colrefs)
if not table_ref:
table_ref = self.AddEmptyTable()['id']
table_ref = self.AddRawTable(table_id)['id']
table = self._docmodel.tables.table.get_record(table_ref)
if not view_ref:
@ -1720,6 +1778,7 @@ class UserActions(object):
table.tableId,
view.viewSections,
section_type,
''
)
return {
'tableRef': table_ref,
@ -1727,9 +1786,12 @@ class UserActions(object):
'sectionRef': section.id
}
def _create_plain_view_section(self, tableRef, tableId, view_sections, section_type):
def _create_plain_view_section(self, tableRef, tableId, view_sections, section_type, title):
# If title is the same as tableId leave it empty
if title == tableId:
title = ''
section = self._docmodel.add(view_sections, tableRef=tableRef, parentKey=section_type,
borderWidth=1, defaultWidth=100)[0]
title=title, borderWidth=1, defaultWidth=100)[0]
# TODO: We should address the automatic selection of fields for charts in a better way.
self._RebuildViewFields(tableId, section.id,
limit=(2 if section_type == 'chart' else None))

@ -838,13 +838,21 @@ export function getPageNames(): Promise<string[]> {
/**
* Adds a new empty table using the 'Add New' menu.
*/
export async function addNewTable() {
export async function addNewTable(name?: string) {
await driver.findWait('.test-dp-add-new', 2000).click();
await driver.find('.test-dp-empty-table').click();
if (name) {
const prompt = await driver.find(".test-modal-prompt");
await prompt.doClear();
await prompt.click();
await driver.sendKeys(name);
}
await driver.find(".test-modal-confirm").click();
await waitForServer();
}
export interface PageWidgetPickerOptions {
tableName?: string;
selectBy?: RegExp|string; // Optional pattern of SELECT BY option to pick.
summarize?: (RegExp|string)[]; // Optional list of patterns to match Group By columns.
}
@ -868,7 +876,7 @@ export async function addNewPage(
}
// Add a new widget to the current page using the 'Add New' menu.
export async function addNewSection(typeRe: RegExp, tableRe: RegExp, options?: PageWidgetPickerOptions) {
export async function addNewSection(typeRe: RegExp|string, tableRe: RegExp|string, options?: PageWidgetPickerOptions) {
// Click the 'Add widget to page' entry in the 'Add New' menu
await driver.findWait('.test-dp-add-new', 2000).doClick();
await driver.findWait('.test-dp-add-widget-to-page', 500).doClick();
@ -918,6 +926,19 @@ export async function selectWidget(
// let's select right type and save
await driver.findContent('.test-wselect-type', typeRe).doClick();
await driver.find('.test-wselect-addBtn').doClick();
// if we selected a new table, there will be a popup for a name
const prompts = await driver.findAll(".test-modal-prompt");
const prompt = prompts[0];
if (prompt) {
if (options.tableName) {
await prompt.doClear();
await prompt.click();
await driver.sendKeys(options.tableName);
}
await driver.find(".test-modal-confirm").click();
}
await waitForServer();
}
@ -944,11 +965,36 @@ export async function renamePage(oldName: string|RegExp, newName: string) {
/**
* Removes a page from the page menu, checks if the page is actually removable.
*/
export async function removePage(name: string|RegExp) {
* By default it will remove only page (handling prompt if necessary).
*/
export async function removePage(name: string|RegExp, options: {
expectPrompt?: boolean, // default undefined
withData?: boolean // default only page,
tables?: string[],
cancel?: boolean,
} = { }) {
await openPageMenu(name);
assert.equal(await driver.find('.test-docpage-remove').matches('.disabled'), false);
await driver.find('.test-docpage-remove').click();
const popups = await driver.findAll(".test-removepage-popup");
if (options.expectPrompt === true) {
assert.lengthOf(popups, 1);
} else if (options.expectPrompt === false) {
assert.lengthOf(popups, 0);
}
if (popups.length) {
const popup = popups.shift()!;
if (options.tables) {
const popupTables = await driver.findAll(".test-removepage-table", e => e.getText());
assert.deepEqual(popupTables.sort(), options.tables.sort());
}
await popup.find(`.test-removepage-option-${options.withData ? 'data': 'page'}`).click();
if (options.cancel) {
await driver.find(".test-modal-cancel").click();
} else {
await driver.find(".test-modal-confirm").click();
}
}
await waitForServer();
}

Loading…
Cancel
Save