(core) Record Cards

Summary:
Adds a new Record Card view section to each non-summary table, which can be from opened from various parts of the Grist UI to view and edit records in a popup card view.

Work is still ongoing, so the feature is locked away behind a flag; follow-up work is planned to finish up the implementation and add end-to-end tests.

Test Plan: Python and server tests. Browser tests will be included in a follow-up.

Reviewers: jarek, paulfitz

Reviewed By: jarek

Subscribers: paulfitz

Differential Revision: https://phab.getgrist.com/D4114
pull/758/head
George Gevoian 6 months ago
parent 2eec48b685
commit caf830db08

@ -1,23 +1,28 @@
import * as commands from 'app/client/components/commands';
import {GristDoc} from 'app/client/components/GristDoc'; import {GristDoc} from 'app/client/components/GristDoc';
import {copyToClipboard} from 'app/client/lib/clipboardUtils'; import {copyToClipboard} from 'app/client/lib/clipboardUtils';
import {setTestState} from 'app/client/lib/testState'; import {setTestState} from 'app/client/lib/testState';
import {TableRec} from 'app/client/models/DocModel'; import {TableRec} from 'app/client/models/DocModel';
import {RECORD_CARDS} from 'app/client/models/features';
import {docListHeader, docMenuTrigger} from 'app/client/ui/DocMenuCss'; import {docListHeader, docMenuTrigger} from 'app/client/ui/DocMenuCss';
import {duplicateTable, DuplicateTableResponse} from 'app/client/ui/DuplicateTable'; import {duplicateTable, DuplicateTableResponse} from 'app/client/ui/DuplicateTable';
import {showTransientTooltip} from 'app/client/ui/tooltips'; import {hoverTooltip, showTransientTooltip} from 'app/client/ui/tooltips';
import {buildTableName} from 'app/client/ui/WidgetTitle'; import {buildTableName} from 'app/client/ui/WidgetTitle';
import * as css from 'app/client/ui2018/cssVars'; import * as css from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {loadingDots} from 'app/client/ui2018/loaders'; import {loadingDots} from 'app/client/ui2018/loaders';
import {menu, menuItem, menuText} from 'app/client/ui2018/menus'; import {menu, menuDivider, menuIcon, menuItem, menuItemAsync, menuText} from 'app/client/ui2018/menus';
import {confirmModal} from 'app/client/ui2018/modals'; import {confirmModal} from 'app/client/ui2018/modals';
import {Computed, Disposable, dom, fromKo, makeTestId, Observable, styled} from 'grainjs'; import {Computed, Disposable, dom, fromKo, makeTestId, observable, Observable, styled} from 'grainjs';
import {makeT} from 'app/client/lib/localization'; import {makeT} from 'app/client/lib/localization';
import * as weasel from 'popweasel';
const testId = makeTestId('test-raw-data-'); const testId = makeTestId('test-raw-data-');
const t = makeT('DataTables'); const t = makeT('DataTables');
const DATA_TABLES_TOOLTIP_KEY = 'dataTablesTooltip';
export class DataTables extends Disposable { export class DataTables extends Disposable {
private _tables: Observable<TableRec[]>; private _tables: Observable<TableRec[]>;
@ -47,17 +52,19 @@ export class DataTables extends Disposable {
testId('list'), testId('list'),
cssHeader(t("Raw Data Tables")), cssHeader(t("Raw Data Tables")),
cssList( cssList(
dom.forEach(this._tables, tableRec => dom.forEach(this._tables, tableRec => {
cssItem( const isEditingName = observable(false);
return cssTable(
dom.autoDispose(isEditingName),
testId('table'), testId('table'),
cssLeft( cssTableIcon(
dom.domComputed((use) => cssTableTypeIcon( dom.domComputed((use) => cssTableTypeIcon(
use(tableRec.summarySourceTable) !== 0 ? 'PivotLight' : 'TypeTable', use(tableRec.summarySourceTable) !== 0 ? 'PivotLight' : 'TypeTable',
testId(`table-id-${use(tableRec.tableId)}`) testId(`table-id-${use(tableRec.tableId)}`)
)), )),
), ),
cssMiddle( cssTableNameAndId(
cssTitleRow(cssTableTitle(this._tableTitle(tableRec), testId('table-title'))), cssTitleRow(cssTableTitle(this._tableTitle(tableRec, isEditingName), testId('table-title'))),
cssDetailsRow( cssDetailsRow(
cssTableIdWrapper(cssHoverWrapper( cssTableIdWrapper(cssHoverWrapper(
cssUpperCase("Table ID: "), cssUpperCase("Table ID: "),
@ -76,14 +83,34 @@ export class DataTables extends Disposable {
setTestState({clipboard: tableRec.tableId.peek()}); setTestState({clipboard: tableRec.tableId.peek()});
}) })
)), )),
this._tableRows(tableRec),
), ),
), ),
cssRight( this._tableRows(tableRec),
docMenuTrigger( cssTableButtons(
cssRecordCardButton(
icon('TypeCard'),
dom.on('click', (ev) => {
ev.stopPropagation();
ev.preventDefault();
if (!tableRec.recordCardViewSection().disabled()) {
this._editRecordCard(tableRec);
}
}),
hoverTooltip(
dom.domComputed(use => use(use(tableRec.recordCardViewSection).disabled)
? t('Record Card Disabled')
: t('Record Card')),
{key: DATA_TABLES_TOOLTIP_KEY, closeOnClick: false}
),
dom.hide(!RECORD_CARDS()),
// Make the button invisible to maintain consistent alignment with non-summary tables.
dom.style('visibility', u => u(tableRec.summarySourceTable) === 0 ? 'visible' : 'hidden'),
cssRecordCardButton.cls('-disabled', use => use(use(tableRec.recordCardViewSection).disabled)),
),
cssDotsButton(
testId('table-menu'), testId('table-menu'),
icon('Dots'), icon('Dots'),
menu(() => this._menuItems(tableRec), {placement: 'bottom-start'}), menu(() => this._menuItems(tableRec, isEditingName), {placement: 'bottom-start'}),
dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }), dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }),
) )
), ),
@ -94,14 +121,14 @@ export class DataTables extends Disposable {
} }
this._gristDoc.viewModel.activeSectionId(sectionId); this._gristDoc.viewModel.activeSectionId(sectionId);
}) })
) );
) })
), ),
), ),
); );
} }
private _tableTitle(table: TableRec) { private _tableTitle(table: TableRec, isEditing: Observable<boolean>) {
return dom.domComputed((use) => { return dom.domComputed((use) => {
const rawViewSectionRef = use(fromKo(table.rawViewSectionRef)); const rawViewSectionRef = use(fromKo(table.rawViewSectionRef));
const isSummaryTable = use(table.summarySourceTable) !== 0; const isSummaryTable = use(table.summarySourceTable) !== 0;
@ -113,37 +140,75 @@ export class DataTables extends Disposable {
].filter(p => Boolean(p?.trim())).join(' '); ].filter(p => Boolean(p?.trim())).join(' ');
return cssTableName(tableName); return cssTableName(tableName);
} else { } else {
return dom('div', // to disable flex grow in the widget return cssFlexRow(
dom.domComputed(fromKo(table.rawViewSection), vs => dom.domComputed(fromKo(table.rawViewSection), vs =>
buildTableName(vs, testId('widget-title')) buildTableName(vs, {isEditing}, cssRenamableTableName.cls(''), testId('widget-title'))
) ),
cssRenameTableButton(icon('Pencil'),
dom.on('click', (ev) => {
ev.stopPropagation();
ev.preventDefault();
isEditing.set(true);
}),
cssRenameTableButton.cls('-active', isEditing),
),
); );
} }
}); });
} }
private _menuItems(table: TableRec) { private _menuItems(table: TableRec, isEditingName: Observable<boolean>) {
const {isReadonly, docModel} = this._gristDoc; const {isReadonly, docModel} = this._gristDoc;
return [ return [
menuItem(
() => { isEditingName.set(true); },
t("Rename Table"),
dom.cls('disabled', use => use(isReadonly) || use(table.summarySourceTable) !== 0),
testId('menu-rename-table'),
),
menuItem( menuItem(
() => this._duplicateTable(table), () => this._duplicateTable(table),
t("Duplicate Table"), t("Duplicate Table"),
testId('menu-duplicate-table'),
dom.cls('disabled', use => dom.cls('disabled', use =>
use(isReadonly) || use(isReadonly) ||
use(table.isHidden) || use(table.isHidden) ||
use(table.summarySourceTable) !== 0 use(table.summarySourceTable) !== 0
), ),
testId('menu-duplicate-table'),
), ),
menuItem( menuItem(
() => this._removeTable(table), () => this._removeTable(table),
'Remove', t("Remove Table"),
testId('menu-remove'),
dom.cls('disabled', use => use(isReadonly) || ( dom.cls('disabled', use => use(isReadonly) || (
// Can't delete last visible table, unless it is a hidden table. // Can't delete last visible table, unless it is a hidden table.
use(docModel.visibleTables.getObservable()).length <= 1 && !use(table.isHidden) use(docModel.visibleTables.getObservable()).length <= 1 && !use(table.isHidden)
)) )),
testId('menu-remove-table'),
), ),
dom.maybe(use => RECORD_CARDS() && use(table.summarySourceTable) === 0, () => [
menuDivider(),
menuItem(
() => this._editRecordCard(table),
cssMenuItemIcon('TypeCard'),
t("Edit Record Card"),
dom.cls('disabled', use => use(isReadonly)),
testId('menu-edit-record-card'),
),
dom.domComputed(use => use(use(table.recordCardViewSection).disabled), (isDisabled) => {
return menuItemAsync(
async () => {
if (isDisabled) {
await this._enableRecordCard(table);
} else {
await this._disableRecordCard(table);
}
},
t('{{action}} Record Card', {action: isDisabled ? 'Enable' : 'Disable'}),
dom.cls('disabled', use => use(isReadonly)),
testId(`menu-${isDisabled ? 'enable' : 'disable'}-record-card`),
);
}),
]),
dom.maybe(isReadonly, () => menuText(t("You do not have edit access to this document"))), dom.maybe(isReadonly, () => menuText(t("You do not have edit access to this document"))),
]; ];
} }
@ -166,6 +231,24 @@ export class DataTables extends Disposable {
), 'Delete', doRemove); ), 'Delete', doRemove);
} }
private _editRecordCard(r: TableRec) {
const sectionId = r.recordCardViewSection.peek().getRowId();
if (!sectionId) {
throw new Error(`Table ${r.tableId.peek()} doesn't have a record card view section.`);
}
this._gristDoc.viewModel.activeSectionId(sectionId);
commands.allCommands.editLayout.run();
}
private async _enableRecordCard(r: TableRec) {
await r.recordCardViewSection().disabled.setAndSave(false);
}
private async _disableRecordCard(r: TableRec) {
await r.recordCardViewSection().disabled.setAndSave(true);
}
private _tableRows(table: TableRec) { private _tableRows(table: TableRec) {
return dom.maybe(this._rowCount, (rowCounts) => { return dom.maybe(this._rowCount, (rowCounts) => {
if (rowCounts === 'hidden') { return null; } if (rowCounts === 'hidden') { return null; }
@ -183,6 +266,18 @@ export class DataTables extends Disposable {
} }
} }
const cssMenuItemIcon = styled(menuIcon, `
--icon-color: ${css.theme.menuItemFg};
.${weasel.cssMenuItem.className}-sel & {
--icon-color: ${css.theme.menuItemSelectedFg};
}
.${weasel.cssMenuItem.className}.disabled & {
--icon-color: ${css.theme.menuItemDisabledFg};
}
`);
const container = styled('div', ` const container = styled('div', `
overflow-y: auto; overflow-y: auto;
position: relative; position: relative;
@ -198,42 +293,37 @@ const cssList = styled('div', `
gap: 12px; gap: 12px;
`); `);
const cssItem = styled('div', ` const cssTable = styled('div', `
display: flex; display: grid;
align-items: center; grid-template-columns: 16px auto 100px 56px;
grid-template-rows: 1fr;
grid-column-gap: 8px;
cursor: pointer; cursor: pointer;
border-radius: 3px; border-radius: 3px;
width: 100%; width: 100%;
height: calc(1em * 56/13); /* 56px for 13px font */ height: calc(1em * 56/13); /* 56px for 13px font */
max-width: 750px; max-width: 750px;
padding: 0px 12px 0px 12px;
border: 1px solid ${css.theme.rawDataTableBorder}; border: 1px solid ${css.theme.rawDataTableBorder};
&:hover { &:hover {
border-color: ${css.theme.rawDataTableBorderHover}; border-color: ${css.theme.rawDataTableBorderHover};
} }
`); `);
// Holds icon in top left corner const cssTableIcon = styled('div', `
const cssLeft = styled('div', `
padding-top: 11px; padding-top: 11px;
padding-left: 12px;
margin-right: 8px;
align-self: flex-start;
display: flex; display: flex;
flex: none;
`); `);
const cssMiddle = styled('div', ` const cssTableNameAndId = styled('div', `
flex-grow: 1;
min-width: 0px; min-width: 0px;
display: flex; display: flex;
flex-wrap: wrap; flex-direction: column;
margin-top: 6px; margin-top: 8px;
margin-bottom: 4px;
`); `);
const cssTitleRow = styled('div', ` const cssTitleRow = styled('div', `
min-width: 100%; min-width: 100%;
margin-right: 4px;
`); `);
const cssDetailsRow = styled('div', ` const cssDetailsRow = styled('div', `
@ -243,13 +333,12 @@ const cssDetailsRow = styled('div', `
`); `);
// Holds dots menu (which is 24px x 24px, but has its own 4px right margin) // Holds dots menu (which is 24px x 24px)
const cssRight = styled('div', ` const cssTableButtons = styled('div', `
padding-right: 8px;
margin-left: 8px;
align-self: center;
display: flex; display: flex;
flex: none; align-items: center;
justify-content: flex-end;
column-gap: 8px;
`); `);
const cssTableTypeIcon = styled(icon, ` const cssTableTypeIcon = styled(icon, `
@ -270,13 +359,10 @@ const cssTableIdWrapper = styled('div', `
const cssTableRowsWrapper = styled('div', ` const cssTableRowsWrapper = styled('div', `
display: flex; display: flex;
flex-shrink: 0;
min-width: 100px;
overflow: hidden; overflow: hidden;
align-items: baseline; align-items: center;
color: ${css.theme.lightText}; color: ${css.theme.lightText};
line-height: 18px; line-height: 18px;
padding: 0px 2px;
`); `);
const cssHoverWrapper = styled('div', ` const cssHoverWrapper = styled('div', `
@ -301,6 +387,8 @@ const cssTableRows = cssTableId;
const cssTableTitle = styled('div', ` const cssTableTitle = styled('div', `
color: ${css.theme.text}; color: ${css.theme.text};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
`); `);
@ -327,3 +415,66 @@ const cssLoadingDots = styled(loadingDots, `
const cssTableName = styled('span', ` const cssTableName = styled('span', `
color: ${css.theme.text}; color: ${css.theme.text};
`); `);
const cssRecordCardButton = styled('div', `
display: flex;
align-items: center;
justify-content: center;
height: 24px;
width: 24px;
cursor: default;
padding: 4px;
border-radius: 3px;
--icon-color: ${css.theme.lightText};
&:hover {
background-color: ${css.theme.hover};
--icon-color: ${css.theme.controlFg};
}
&-disabled {
--icon-color: ${css.theme.lightText};
padding: 0px;
opacity: 0.4;
}
&-disabled:hover {
background: none;
--icon-color: ${css.theme.lightText};
}
`);
const cssDotsButton = styled(docMenuTrigger, `
margin: 0px;
&:hover, &.weasel-popup-open {
background-color: ${css.theme.hover};
}
`);
const cssRenameTableButton = styled('div', `
flex-shrink: 0;
width: 16px;
visibility: hidden;
cursor: default;
--icon-color: ${css.theme.lightText};
&:hover {
--icon-color: ${css.theme.controlFg};
}
&-active {
visibility: hidden;
}
.${cssTableTitle.className}:hover & {
visibility: visible;
}
`);
const cssFlexRow = styled('div', `
display: flex;
align-items: center;
column-gap: 8px;
`);
const cssRenamableTableName = styled('div', `
flex: initial;
`);

@ -17,8 +17,8 @@ const {CopySelection} = require('./CopySelection');
const RecordLayout = require('./RecordLayout'); const RecordLayout = require('./RecordLayout');
const commands = require('./commands'); const commands = require('./commands');
const tableUtil = require('../lib/tableUtil'); const tableUtil = require('../lib/tableUtil');
const {CardContextMenu} = require('../ui/CardContextMenu');
const {FieldContextMenu} = require('../ui/FieldContextMenu'); const {FieldContextMenu} = require('../ui/FieldContextMenu');
const {RowContextMenu} = require('../ui/RowContextMenu');
const {parsePasteForView} = require("./BaseView2"); const {parsePasteForView} = require("./BaseView2");
const {descriptionInfoTooltip} = require("../ui/tooltips"); const {descriptionInfoTooltip} = require("../ui/tooltips");
@ -39,7 +39,7 @@ function DetailView(gristDoc, viewSectionModel) {
this.recordLayout = this.autoDispose(RecordLayout.create({ this.recordLayout = this.autoDispose(RecordLayout.create({
viewSection: this.viewSection, viewSection: this.viewSection,
buildFieldDom: this.buildFieldDom.bind(this), buildFieldDom: this.buildFieldDom.bind(this),
buildRowContextMenu : this.buildRowContextMenu.bind(this), buildCardContextMenu : this.buildCardContextMenu.bind(this),
buildFieldContextMenu : this.buildFieldContextMenu.bind(this), buildFieldContextMenu : this.buildFieldContextMenu.bind(this),
resizeCallback: () => { resizeCallback: () => {
if (!this._isSingle) { if (!this._isSingle) {
@ -246,15 +246,14 @@ DetailView.prototype.getSelection = function() {
); );
}; };
DetailView.prototype.buildRowContextMenu = function(row) { DetailView.prototype.buildCardContextMenu = function(row) {
const rowOptions = this._getRowContextMenuOptions(row); const cardOptions = this._getCardContextMenuOptions(row);
return RowContextMenu(rowOptions); return CardContextMenu(cardOptions);
} }
DetailView.prototype.buildFieldContextMenu = function(row) { DetailView.prototype.buildFieldContextMenu = function() {
const rowOptions = this._getRowContextMenuOptions(row);
const fieldOptions = this._getFieldContextMenuOptions(); const fieldOptions = this._getFieldContextMenuOptions();
return FieldContextMenu(rowOptions, fieldOptions); return FieldContextMenu(fieldOptions);
} }
/** /**
@ -490,8 +489,9 @@ DetailView.prototype._canSingleClick = function(field) {
}; };
DetailView.prototype._clearCardFields = function() { DetailView.prototype._clearCardFields = function() {
const {isFormula} = this._getFieldContextMenuOptions(); const selection = this.getSelection();
if (isFormula === true) { const isFormula = Boolean(selection.fields[0]?.column.peek().isRealFormula.peek());
if (isFormula) {
this.activateEditorAtCursor({init: ''}); this.activateEditorAtCursor({init: ''});
} else { } else {
const clearAction = tableUtil.makeDeleteAction(this.getSelection()); const clearAction = tableUtil.makeDeleteAction(this.getSelection());
@ -520,7 +520,7 @@ DetailView.prototype._clearCopySelection = function() {
this.copySelection(null); this.copySelection(null);
}; };
DetailView.prototype._getRowContextMenuOptions = function(row) { DetailView.prototype._getCardContextMenuOptions = function(row) {
return { return {
disableInsert: Boolean( disableInsert: Boolean(
this.gristDoc.isReadonly.get() || this.gristDoc.isReadonly.get() ||
@ -542,7 +542,6 @@ DetailView.prototype._getFieldContextMenuOptions = function() {
return { return {
disableModify: Boolean(selection.fields[0]?.disableModify.peek()), disableModify: Boolean(selection.fields[0]?.disableModify.peek()),
isReadonly: this.gristDoc.isReadonly.get() || this.isPreview, isReadonly: this.gristDoc.isReadonly.get() || this.isPreview,
isFormula: Boolean(selection.fields[0]?.column.peek().isRealFormula.peek()),
}; };
} }

@ -55,6 +55,9 @@ const {NEW_FILTER_JSON} = require('app/client/models/ColumnFilter');
const {CombinedStyle} = require("app/client/models/Styles"); const {CombinedStyle} = require("app/client/models/Styles");
const {buildRenameColumn} = require('app/client/ui/ColumnTitle'); const {buildRenameColumn} = require('app/client/ui/ColumnTitle');
const {makeT} = require('app/client/lib/localization'); const {makeT} = require('app/client/lib/localization');
const {reportError} = require('app/client/models/AppModel');
const {RECORD_CARDS} = require('app/client/models/features');
const {urlState} = require('app/client/models/gristUrlState');
const t = makeT('GridView'); const t = makeT('GridView');
@ -370,7 +373,17 @@ GridView.gridCommands = {
return; return;
} }
this.viewSection.rawNumFrozen.setAndSave(action.numFrozen); this.viewSection.rawNumFrozen.setAndSave(action.numFrozen);
} },
viewAsCard() {
if (!RECORD_CARDS()) { return; }
if (this._isRecordCardDisabled()) { return; }
const selectedRows = this.selectedRows();
const rowId = selectedRows[0];
const sectionId = this.viewSection.tableRecordCard().id();
const anchorUrlState = {hash: {rowId, sectionId, recordCard: true}};
urlState().pushUrl(anchorUrlState, {replace: true}).catch(reportError);
},
}; };
GridView.prototype.onTableLoaded = function() { GridView.prototype.onTableLoaded = function() {
@ -1909,20 +1922,41 @@ GridView.prototype.rowContextMenu = function() {
GridView.prototype._getRowContextMenuOptions = function() { GridView.prototype._getRowContextMenuOptions = function() {
return { return {
disableInsert: Boolean(this.gristDoc.isReadonly.get() || this.viewSection.disableAddRemoveRows() || this.tableModel.tableMetaRow.onDemand()), ...this._getCellContextMenuOptions(),
disableDelete: Boolean(this.gristDoc.isReadonly.get() || this.viewSection.disableAddRemoveRows() || this.getSelection().onlyAddRowSelected()), disableShowRecordCard: this._isRecordCardDisabled(),
isViewSorted: this.viewSection.activeSortSpec.peek().length > 0,
numRows: this.getSelection().rowIds.length,
}; };
}; };
GridView.prototype._isRecordCardDisabled = function() {
return this.getSelection().onlyAddRowSelected() ||
this.viewSection.isTableRecordCardDisabled() ||
this.viewSection.table().summarySourceTable() !== 0;
}
GridView.prototype.cellContextMenu = function() { GridView.prototype.cellContextMenu = function() {
return CellContextMenu( return CellContextMenu(
this._getRowContextMenuOptions(), this._getCellContextMenuOptions(),
this._getColumnMenuOptions(this.getSelection()) this._getColumnMenuOptions(this.getSelection())
); );
}; };
GridView.prototype._getCellContextMenuOptions = function() {
return {
disableInsert: Boolean(
this.gristDoc.isReadonly.get() ||
this.viewSection.disableAddRemoveRows() ||
this.tableModel.tableMetaRow.onDemand()
),
disableDelete: Boolean(
this.gristDoc.isReadonly.get() ||
this.viewSection.disableAddRemoveRows() ||
this.getSelection().onlyAddRowSelected()
),
isViewSorted: this.viewSection.activeSortSpec.peek().length > 0,
numRows: this.getSelection().rowIds.length,
};
};
// End Context Menus // End Context Menus
GridView.prototype.scrollToCursor = function(sync = true) { GridView.prototype.scrollToCursor = function(sync = true) {

@ -17,6 +17,7 @@ import {EditorMonitor} from "app/client/components/EditorMonitor";
import GridView from 'app/client/components/GridView'; import GridView from 'app/client/components/GridView';
import {importFromFile, selectAndImport} from 'app/client/components/Importer'; import {importFromFile, selectAndImport} from 'app/client/components/Importer';
import {RawDataPage, RawDataPopup} from 'app/client/components/RawDataPage'; import {RawDataPage, RawDataPopup} from 'app/client/components/RawDataPage';
import {RecordCardPopup} from 'app/client/components/RecordCardPopup';
import {ActionGroupWithCursorPos, UndoStack} from 'app/client/components/UndoStack'; import {ActionGroupWithCursorPos, UndoStack} from 'app/client/components/UndoStack';
import {ViewLayout} from 'app/client/components/ViewLayout'; import {ViewLayout} from 'app/client/components/ViewLayout';
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
@ -125,7 +126,7 @@ export interface IExtraTool {
content: TabContent[] | IDomComponent; content: TabContent[] | IDomComponent;
} }
interface RawSectionOptions { interface PopupSectionOptions {
viewSection: ViewSectionRec; viewSection: ViewSectionRec;
hash: HashLink; hash: HashLink;
close: () => void; close: () => void;
@ -179,7 +180,7 @@ export class GristDoc extends DisposableWithEvents {
// the space. // the space.
public maximizedSectionId: Observable<number | null> = Observable.create(this, null); public maximizedSectionId: Observable<number | null> = Observable.create(this, null);
// This is id of the section that is currently shown in the popup. Probably this is an external // This is id of the section that is currently shown in the popup. Probably this is an external
// section, like raw data view, or a section from another view.. // section, like raw data view, or a section from another view.
public externalSectionId: Computed<number | null>; public externalSectionId: Computed<number | null>;
public viewLayout: ViewLayout | null = null; public viewLayout: ViewLayout | null = null;
@ -201,15 +202,14 @@ export class GristDoc extends DisposableWithEvents {
private _rightPanelTool = createSessionObs(this, "rightPanelTool", "none", RightPanelTool.guard); private _rightPanelTool = createSessionObs(this, "rightPanelTool", "none", RightPanelTool.guard);
private _showGristTour = getUserOrgPrefObs(this.userOrgPrefs, 'showGristTour'); private _showGristTour = getUserOrgPrefObs(this.userOrgPrefs, 'showGristTour');
private _seenDocTours = getUserOrgPrefObs(this.userOrgPrefs, 'seenDocTours'); private _seenDocTours = getUserOrgPrefObs(this.userOrgPrefs, 'seenDocTours');
private _rawSectionOptions: Observable<RawSectionOptions | null> = Observable.create(this, null); private _popupSectionOptions: Observable<PopupSectionOptions | null> = Observable.create(this, null);
private _activeContent: Computed<IDocPage | RawSectionOptions>; private _activeContent: Computed<IDocPage | PopupSectionOptions>;
private _docTutorialHolder = Holder.create<DocTutorial>(this); private _docTutorialHolder = Holder.create<DocTutorial>(this);
private _isRickRowing: Observable<boolean> = Observable.create(this, false); private _isRickRowing: Observable<boolean> = Observable.create(this, false);
private _showBackgroundVideoPlayer: Observable<boolean> = Observable.create(this, false); private _showBackgroundVideoPlayer: Observable<boolean> = Observable.create(this, false);
private _backgroundVideoPlayerHolder: Holder<YouTubePlayer> = Holder.create(this); private _backgroundVideoPlayerHolder: Holder<YouTubePlayer> = Holder.create(this);
private _disableAutoStartingTours: boolean = false; private _disableAutoStartingTours: boolean = false;
constructor( constructor(
public readonly app: App, public readonly app: App,
public readonly appModel: AppModel, public readonly appModel: AppModel,
@ -256,9 +256,9 @@ export class GristDoc extends DisposableWithEvents {
const viewId = this.docModel.views.tableData.findRow(docPage === 'GristDocTour' ? 'name' : 'id', docPage); const viewId = this.docModel.views.tableData.findRow(docPage === 'GristDocTour' ? 'name' : 'id', docPage);
return viewId || use(defaultViewId); return viewId || use(defaultViewId);
}); });
this._activeContent = Computed.create(this, use => use(this._rawSectionOptions) ?? use(this.activeViewId)); this._activeContent = Computed.create(this, use => use(this._popupSectionOptions) ?? use(this.activeViewId));
this.externalSectionId = Computed.create(this, use => { this.externalSectionId = Computed.create(this, use => {
const externalContent = use(this._rawSectionOptions); const externalContent = use(this._popupSectionOptions);
return externalContent ? use(externalContent.viewSection.id) : null; return externalContent ? use(externalContent.viewSection.id) : null;
}); });
// This viewModel reflects the currently active view, relying on the fact that // This viewModel reflects the currently active view, relying on the fact that
@ -302,7 +302,7 @@ export class GristDoc extends DisposableWithEvents {
try { try {
if (state.hash.popup) { if (state.hash.popup || state.hash.recordCard) {
await this.openPopup(state.hash); await this.openPopup(state.hash);
} else { } else {
// Navigate to an anchor if one is present in the url hash. // Navigate to an anchor if one is present in the url hash.
@ -615,7 +615,17 @@ export class GristDoc extends DisposableWithEvents {
owner.autoDispose(this.activeViewId.addListener(content.close)); owner.autoDispose(this.activeViewId.addListener(content.close));
// In case the section is removed, close the popup. // In case the section is removed, close the popup.
content.viewSection.autoDispose({dispose: content.close}); content.viewSection.autoDispose({dispose: content.close});
return dom.create(RawDataPopup, this, content.viewSection, content.close);
const {recordCard} = content.hash;
if (recordCard) {
return dom.create(RecordCardPopup, {
gristDoc: this,
viewSection: content.viewSection,
onClose: content.close,
});
} else {
return dom.create(RawDataPopup, this, content.viewSection, content.close);
}
}) : }) :
dom.create((owner) => { dom.create((owner) => {
this.viewLayout = ViewLayout.create(owner, this, content); this.viewLayout = ViewLayout.create(owner, this, content);
@ -671,7 +681,11 @@ export class GristDoc extends DisposableWithEvents {
return; return;
} }
// If this is completely unknown section (without a parent), it is probably an import preview. // If this is completely unknown section (without a parent), it is probably an import preview.
if (!desiredSection.parentId.peek() && !desiredSection.isRaw.peek()) { if (
!desiredSection.parentId.peek() &&
!desiredSection.isRaw.peek() &&
!desiredSection.isRecordCard.peek()
) {
const view = desiredSection.viewInstance.peek(); const view = desiredSection.viewInstance.peek();
// Make sure we have a view instance here - it will prove our assumption that this is // Make sure we have a view instance here - it will prove our assumption that this is
// an import preview. Section might also be disconnected during undo/redo. // an import preview. Section might also be disconnected during undo/redo.
@ -1215,7 +1229,8 @@ export class GristDoc extends DisposableWithEvents {
}, false, silent, visitedSections.concat([section.id.peek()])); }, false, silent, visitedSections.concat([section.id.peek()]));
} }
const view: ViewRec = section.view.peek(); const view: ViewRec = section.view.peek();
const docPage: ViewDocPage = section.isRaw.peek() ? "data" : view.getRowId(); const isRawOrRecordCardView = section.isRaw.peek() || section.isRecordCard.peek();
const docPage: ViewDocPage = isRawOrRecordCardView ? 'data' : view.getRowId();
if (docPage != this.activeViewId.get()) { if (docPage != this.activeViewId.get()) {
await this.openDocPage(docPage); await this.openDocPage(docPage);
} }
@ -1303,20 +1318,21 @@ export class GristDoc extends DisposableWithEvents {
// We need to make it active, so that cursor on this section will be the // We need to make it active, so that cursor on this section will be the
// active one. This will change activeViewSectionId on a parent view of this section, // active one. This will change activeViewSectionId on a parent view of this section,
// which might be a diffrent view from what we currently have. If the section is // which might be a diffrent view from what we currently have. If the section is
// a raw data section it will use `EmptyRowModel` as raw sections don't have parents. // a raw data or record card section, it will use `EmptyRowModel` as these sections
// don't currently have parent views.
popupSection.hasFocus(true); popupSection.hasFocus(true);
this._rawSectionOptions.set({ this._popupSectionOptions.set({
hash, hash,
viewSection: popupSection, viewSection: popupSection,
close: () => { close: () => {
// In case we are already close, do nothing. // In case we are already closed, do nothing.
if (!this._rawSectionOptions.get()) { if (!this._popupSectionOptions.get()) {
return; return;
} }
if (popupSection !== prevSection) { if (popupSection !== prevSection) {
// We need to blur raw view section. Otherwise it will automatically be opened // We need to blur the popup section. Otherwise it will automatically be opened
// on raw data view. Note: raw data section doesn't have its own view, it uses // on raw data view. Note: raw data and record card sections don't have parent views;
// empty row model as a parent (which feels like a hack). // they use the empty row model as a parent (which feels like a hack).
if (!popupSection.isDisposed()) { if (!popupSection.isDisposed()) {
popupSection.hasFocus(false); popupSection.hasFocus(false);
} }
@ -1328,17 +1344,21 @@ export class GristDoc extends DisposableWithEvents {
prevSection.hasFocus(true); prevSection.hasFocus(true);
} }
} }
// Clearing popup data will close this popup. // Clearing popup section data will close this popup.
this._rawSectionOptions.set(null); this._popupSectionOptions.set(null);
} }
}); });
// If the anchor link is valid, set the cursor. // If the anchor link is valid, set the cursor.
if (hash.colRef && hash.rowId) { if (hash.rowId || hash.colRef) {
const fieldIndex = popupSection.viewFields.peek().all().findIndex(f => f.colRef.peek() === hash.colRef); const {rowId} = hash;
if (fieldIndex >= 0) { let fieldIndex;
const view = await this._waitForView(popupSection); if (hash.colRef) {
view?.setCursorPos({rowId: hash.rowId, fieldIndex}); const maybeFieldIndex = popupSection.viewFields.peek().all()
.findIndex(f => f.colRef.peek() === hash.colRef);
if (maybeFieldIndex !== -1) { fieldIndex = maybeFieldIndex; }
} }
const view = await this._waitForView(popupSection);
view?.setCursorPos({rowId, fieldIndex});
} }
} }
@ -1586,8 +1606,8 @@ export class GristDoc extends DisposableWithEvents {
*/ */
private async _switchToSectionId(sectionId: number) { private async _switchToSectionId(sectionId: number) {
const section: ViewSectionRec = this.docModel.viewSections.getRowModel(sectionId); const section: ViewSectionRec = this.docModel.viewSections.getRowModel(sectionId);
if (section.isRaw.peek()) { if (section.isRaw.peek() || section.isRecordCard.peek()) {
// This is raw data view // This is a raw data or record card view.
await urlState().pushUrl({docPage: 'data'}); await urlState().pushUrl({docPage: 'data'});
this.viewModel.activeSectionId(sectionId); this.viewModel.activeSectionId(sectionId);
} else if (section.isVirtual.peek()) { } else if (section.isVirtual.peek()) {

@ -23,7 +23,7 @@ export class RawDataPage extends Disposable {
this.autoDispose(commands.createGroup(commandGroup, this, true)); this.autoDispose(commands.createGroup(commandGroup, this, true));
this._lightboxVisible = Computed.create(this, use => { this._lightboxVisible = Computed.create(this, use => {
const section = use(this._gristDoc.viewModel.activeSection); const section = use(this._gristDoc.viewModel.activeSection);
return Boolean(use(section.id)) && use(section.isRaw); return Boolean(use(section.id)) && (use(section.isRaw) || use(section.isRecordCard));
}); });
// When we are disposed, we want to clear active section in the viewModel we got (which is an empty model) // When we are disposed, we want to clear active section in the viewModel we got (which is an empty model)
// to not restore the section when user will come back to Raw Data page. // to not restore the section when user will come back to Raw Data page.
@ -55,7 +55,7 @@ export class RawDataPage extends Disposable {
/*************** Lightbox section **********/ /*************** Lightbox section **********/
dom.domComputed(fromKo(this._gristDoc.viewModel.activeSection), (viewSection) => { dom.domComputed(fromKo(this._gristDoc.viewModel.activeSection), (viewSection) => {
const sectionId = viewSection.getRowId(); const sectionId = viewSection.getRowId();
if (!sectionId || !viewSection.isRaw.peek()) { if (!sectionId || (!viewSection.isRaw.peek() && !viewSection.isRecordCard.peek())) {
return null; return null;
} }
return dom.create(RawDataPopup, this._gristDoc, viewSection, () => this._close()); return dom.create(RawDataPopup, this._gristDoc, viewSection, () => this._close());
@ -97,7 +97,9 @@ export class RawDataPopup extends Disposable {
sectionRowId: this._viewSection.getRowId(), sectionRowId: this._viewSection.getRowId(),
draggable: false, draggable: false,
focusable: false, focusable: false,
widgetNameHidden: this._viewSection.isRaw.peek(), // We are sometimes used for non raw sections. // Expanded, non-raw widgets are also rendered in RawDataPopup.
widgetNameHidden: this._viewSection.isRaw.peek(),
renamable: !this._viewSection.isRecordCard.peek(),
}) })
), ),
cssCloseButton('CrossBig', cssCloseButton('CrossBig',
@ -127,7 +129,7 @@ const cssPage = styled('div', `
} }
`); `);
const cssOverlay = styled('div', ` export const cssOverlay = styled('div', `
background-color: ${theme.modalBackdrop}; background-color: ${theme.modalBackdrop};
inset: 0px; inset: 0px;
height: 100%; height: 100%;
@ -162,7 +164,7 @@ const cssSectionWrapper = styled('div', `
} }
`); `);
const cssCloseButton = styled(icon, ` export const cssCloseButton = styled(icon, `
position: absolute; position: absolute;
top: 16px; top: 16px;
right: 16px; right: 16px;

@ -0,0 +1,69 @@
import {buildViewSectionDom} from 'app/client/components/buildViewSectionDom';
import * as commands from 'app/client/components/commands';
import {GristDoc} from 'app/client/components/GristDoc';
import {cssCloseButton, cssOverlay} from 'app/client/components/RawDataPage';
import {ViewSectionRec} from 'app/client/models/DocModel';
import {ViewSectionHelper} from 'app/client/components/ViewLayout';
import {theme} from 'app/client/ui2018/cssVars';
import {Disposable, dom, makeTestId, styled} from 'grainjs';
const testId = makeTestId('test-record-card-popup-');
interface RecordCardPopupOptions {
gristDoc: GristDoc;
viewSection: ViewSectionRec;
onClose(): void;
}
export class RecordCardPopup extends Disposable {
private _gristDoc = this._options.gristDoc;
private _viewSection = this._options.viewSection;
private _handleClose = this._options.onClose;
constructor(private _options: RecordCardPopupOptions) {
super();
const commandGroup = {
cancel: () => { this._handleClose(); },
};
this.autoDispose(commands.createGroup(commandGroup, this, true));
}
public buildDom() {
ViewSectionHelper.create(this, this._gristDoc, this._viewSection);
return cssOverlay(
testId('overlay'),
cssSectionWrapper(
buildViewSectionDom({
gristDoc: this._gristDoc,
sectionRowId: this._viewSection.getRowId(),
draggable: false,
focusable: false,
renamable: false,
hideTitleControls: true,
}),
),
cssCloseButton('CrossBig',
dom.on('click', () => this._handleClose()),
testId('close'),
),
dom.on('click', (ev, elem) => void (ev.target === elem ? this._handleClose() : null)),
);
}
}
const cssSectionWrapper = styled('div', `
background: ${theme.mainPanelBg};
height: 100%;
display: flex;
flex-direction: column;
border-radius: 5px;
border-bottom-left-radius: 0px;
border-bottom-right-radius: 0px;
& .viewsection_content {
margin: 0px;
margin-top: 8px;
}
& .viewsection_title {
padding: 0px 12px;
}
`);

@ -54,7 +54,7 @@ const t = makeT('RecordLayout');
function RecordLayout(options) { function RecordLayout(options) {
this.viewSection = options.viewSection; this.viewSection = options.viewSection;
this.buildFieldDom = options.buildFieldDom; this.buildFieldDom = options.buildFieldDom;
this.buildRowContextMenu = options.buildRowContextMenu; this.buildCardContextMenu = options.buildCardContextMenu;
this.buildFieldContextMenu = options.buildFieldContextMenu; this.buildFieldContextMenu = options.buildFieldContextMenu;
this.isEditingLayout = ko.observable(false); this.isEditingLayout = ko.observable(false);
this.editIndex = ko.observable(0); this.editIndex = ko.observable(0);
@ -342,7 +342,7 @@ RecordLayout.prototype.buildLayoutDom = function(row, optCreateEditor) {
this.layoutEditor(null); this.layoutEditor(null);
}) : null, }) : null,
// enables field context menu anywhere on the card // enables field context menu anywhere on the card
contextMenu(() => this.buildFieldContextMenu(row)), contextMenu(() => this.buildFieldContextMenu()),
dom('div.detail_row_num', dom('div.detail_row_num',
kd.text(() => (row._index() + 1)), kd.text(() => (row._index() + 1)),
dom.on('contextmenu', ev => { dom.on('contextmenu', ev => {
@ -358,7 +358,7 @@ RecordLayout.prototype.buildLayoutDom = function(row, optCreateEditor) {
this.viewSection.hasFocus(true); this.viewSection.hasFocus(true);
commands.allCommands.setCursor.run(row); commands.allCommands.setCursor.run(row);
}), }),
menu(() => this.buildRowContextMenu(row)), menu(() => this.buildCardContextMenu(row)),
testId('card-menu-trigger') testId('card-menu-trigger')
) )
), ),

@ -168,7 +168,7 @@ export class RefSelect extends Disposable {
this._getReferrerFields(item.value).forEach(refField => { this._getReferrerFields(item.value).forEach(refField => {
const sectionId = this._fieldObs()!.viewSection().getRowId(); const sectionId = this._fieldObs()!.viewSection().getRowId();
if (refField.column().viewFields().all() if (refField.column().viewFields().all()
.filter(field => !field.viewSection().isRaw()) .filter(field => !field.viewSection().isRaw() && !field.viewSection().isRecordCard())
.some(field => field.parentId() !== sectionId)) { .some(field => field.parentId() !== sectionId)) {
// The col has fields in other sections, remove only the fields in this section. // The col has fields in other sections, remove only the fields in this section.
return this._docModel.viewFields.sendTableAction(['RemoveRecord', refField.getRowId()]); return this._docModel.viewFields.sendTableAction(['RemoveRecord', refField.getRowId()]);

@ -8,7 +8,7 @@ var koArray = require('../lib/koArray');
var commands = require('./commands'); var commands = require('./commands');
var {CustomSectionElement} = require('../lib/CustomSectionElement'); var {CustomSectionElement} = require('../lib/CustomSectionElement');
const {ChartConfig} = require('./ChartView'); const {ChartConfig} = require('./ChartView');
const {Computed, dom: grainjsDom, makeTestId} = require('grainjs'); const {Computed, dom: grainjsDom, makeTestId, Holder} = require('grainjs');
const {cssRow} = require('app/client/ui/RightPanelStyles'); const {cssRow} = require('app/client/ui/RightPanelStyles');
const {SortFilterConfig} = require('app/client/ui/SortFilterConfig'); const {SortFilterConfig} = require('app/client/ui/SortFilterConfig');
@ -37,6 +37,7 @@ function ViewConfigTab(options) {
var self = this; var self = this;
this.gristDoc = options.gristDoc; this.gristDoc = options.gristDoc;
this.viewModel = options.viewModel; this.viewModel = options.viewModel;
this._viewSectionDataHolder = Holder.create(this);
// viewModel may point to different views, but viewSectionData is a single koArray reflecting // viewModel may point to different views, but viewSectionData is a single koArray reflecting
// the sections of the current view. // the sections of the current view.
@ -58,18 +59,21 @@ function ViewConfigTab(options) {
return this.viewModel.activeSection().parentKey() === 'custom';}, this)); return this.viewModel.activeSection().parentKey() === 'custom';}, this));
this.isRaw = this.autoDispose(ko.computed(function() { this.isRaw = this.autoDispose(ko.computed(function() {
return this.viewModel.activeSection().isRaw();}, this)); return this.viewModel.activeSection().isRaw();}, this));
this.isRecordCard = this.autoDispose(ko.computed(function() {
return this.viewModel.activeSection().isRecordCard();}, this));
this.activeRawSectionData = this.autoDispose(ko.computed(function() { this.activeRawOrRecordCardSectionData = this.autoDispose(ko.computed(function() {
return self.isRaw() ? ViewSectionData.create(self.viewModel.activeSection()) : null; return self.isRaw() || self.isRecordCard()
? self._viewSectionDataHolder.autoDispose(ViewSectionData.create(self.viewModel.activeSection()))
: null;
})); }));
this.activeSectionData = this.autoDispose(ko.computed(function() { this.activeSectionData = this.autoDispose(ko.computed(function() {
return ( return (
_.find(self.viewSectionData.all(), function(sectionData) { _.find(self.viewSectionData.all(), function(sectionData) {
return sectionData.section && return sectionData.section &&
sectionData.section.getRowId() === self.viewModel.activeSectionId(); sectionData.section.getRowId() === self.viewModel.activeSectionId();
}) })
|| self.activeRawSectionData() || self.activeRawOrRecordCardSectionData()
|| self.viewSectionData.at(0) || self.viewSectionData.at(0)
); );
})); }));

@ -205,14 +205,14 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
this._onResize(); this._onResize();
// Reset active section to the first one if the section is popup is collapsed. // Reset active section to the first one if the section is popup is collapsed.
if (prev if (prev
&& this.viewModel.activeCollapsedSections.peek().includes(prev) && this.viewModel.activeCollapsedSections.peek().includes(prev)
&& this.previousSectionId) { && this.previousSectionId) {
// Make sure that previous section exists still. // Make sure that previous section exists still.
if (this.viewModel.viewSections.peek().all() if (this.viewModel.viewSections.peek().all()
.some(s => !s.isDisposed() && s.id.peek() === this.previousSectionId)) { .some(s => !s.isDisposed() && s.id.peek() === this.previousSectionId)) {
this.viewModel.activeSectionId(this.previousSectionId); this.viewModel.activeSectionId(this.previousSectionId);
}
} }
}
} else { } else {
// Otherwise resize only active one (the one in popup). // Otherwise resize only active one (the one in popup).
const section = this.viewModel.activeSection.peek(); const section = this.viewModel.activeSection.peek();

@ -65,9 +65,21 @@ export function buildViewSectionDom(options: {
focusable?: boolean, /* defaults to true */ focusable?: boolean, /* defaults to true */
tableNameHidden?: boolean, tableNameHidden?: boolean,
widgetNameHidden?: boolean, widgetNameHidden?: boolean,
renamable?: boolean,
hideTitleControls?: boolean,
}) { }) {
const isResizing = options.isResizing ?? Observable.create(null, false); const isResizing = options.isResizing ?? Observable.create(null, false);
const {gristDoc, sectionRowId, viewModel, draggable = true, focusable = true} = options; const {
gristDoc,
sectionRowId,
viewModel,
draggable = true,
focusable = true,
tableNameHidden,
widgetNameHidden,
renamable = true,
hideTitleControls = false,
} = options;
// Creating normal section dom // Creating normal section dom
const vs: ViewSectionRec = gristDoc.docModel.viewSections.getRowModel(sectionRowId); const vs: ViewSectionRec = gristDoc.docModel.viewSections.getRowModel(sectionRowId);
@ -92,8 +104,13 @@ export function buildViewSectionDom(options: {
), ),
dom.maybe((use) => use(use(viewInstance.viewSection.table).summarySourceTable), () => dom.maybe((use) => use(use(viewInstance.viewSection.table).summarySourceTable), () =>
cssSigmaIcon('Pivot', testId('sigma'))), cssSigmaIcon('Pivot', testId('sigma'))),
buildWidgetTitle(vs, options, testId('viewsection-title'), cssTestClick(testId("viewsection-blank"))), buildWidgetTitle(
viewInstance.buildTitleControls(), vs,
{tableNameHidden, widgetNameHidden, disabled: !renamable},
testId('viewsection-title'),
cssTestClick(testId("viewsection-blank")),
),
hideTitleControls ? null : viewInstance.buildTitleControls(),
dom('div.viewsection_buttons', dom('div.viewsection_buttons',
dom.create(viewSectionMenu, gristDoc, vs) dom.create(viewSectionMenu, gristDoc, vs)
) )

@ -114,6 +114,7 @@ export type CommandName =
| 'clearCopySelection' | 'clearCopySelection'
| 'detachEditor' | 'detachEditor'
| 'activateAssistant' | 'activateAssistant'
| 'viewAsCard'
; ;
@ -270,6 +271,11 @@ export const groups: CommendGroupDef[] = [{
keys: [], keys: [],
desc: 'Activate assistant', desc: 'Activate assistant',
}, },
{
name: 'viewAsCard',
keys: [],
desc: 'Show the record card widget of the selected record',
},
] ]
}, { }, {
group: 'Navigation', group: 'Navigation',

@ -15,6 +15,7 @@ export interface TableRec extends IRowModel<"_grist_Tables"> {
primaryView: ko.Computed<ViewRec>; primaryView: ko.Computed<ViewRec>;
rawViewSection: ko.Computed<ViewSectionRec>; rawViewSection: ko.Computed<ViewSectionRec>;
recordCardViewSection: ko.Computed<ViewSectionRec>;
summarySource: ko.Computed<TableRec>; summarySource: ko.Computed<TableRec>;
// A Set object of colRefs for all summarySourceCols of table. // A Set object of colRefs for all summarySourceCols of table.
@ -52,6 +53,7 @@ export function createTableRec(this: TableRec, docModel: DocModel): void {
this.primaryView = refRecord(docModel.views, this.primaryViewId); this.primaryView = refRecord(docModel.views, this.primaryViewId);
this.rawViewSection = refRecord(docModel.viewSections, this.rawViewSectionRef); this.rawViewSection = refRecord(docModel.viewSections, this.rawViewSectionRef);
this.recordCardViewSection = refRecord(docModel.viewSections, this.recordCardViewSectionRef);
this.summarySource = refRecord(docModel.tables, this.summarySourceTable); this.summarySource = refRecord(docModel.tables, this.summarySourceTable);
this.isHidden = this.autoDispose( this.isHidden = this.autoDispose(
// This is repeated logic from isHiddenTable. // This is repeated logic from isHiddenTable.

@ -85,6 +85,19 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO
// true if this record is its table's rawViewSection, i.e. a 'raw data view' // true if this record is its table's rawViewSection, i.e. a 'raw data view'
// in which case the UI prevents various things like hiding columns or changing the widget type. // in which case the UI prevents various things like hiding columns or changing the widget type.
isRaw: ko.Computed<boolean>; isRaw: ko.Computed<boolean>;
tableRecordCard: ko.Computed<ViewSectionRec>
isRecordCard: ko.Computed<boolean>;
/** True if this section is disabled. Currently only used by Record Card sections. */
disabled: modelUtil.KoSaveableObservable<boolean>;
/**
* True if the Record Card section of this section's table is disabled. Shortcut for
* `this.tableRecordCard().disabled()`.
*/
isTableRecordCardDisabled: ko.Computed<boolean>;
isVirtual: ko.Computed<boolean>; isVirtual: ko.Computed<boolean>;
isCollapsed: ko.Computed<boolean>; isCollapsed: ko.Computed<boolean>;
@ -443,7 +456,13 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
// true if this record is its table's rawViewSection, i.e. a 'raw data view' // true if this record is its table's rawViewSection, i.e. a 'raw data view'
// in which case the UI prevents various things like hiding columns or changing the widget type. // in which case the UI prevents various things like hiding columns or changing the widget type.
this.isRaw = this.autoDispose(ko.pureComputed(() => this.table().rawViewSectionRef() === this.getRowId())); this.isRaw = this.autoDispose(ko.pureComputed(() => this.table().rawViewSectionRef() === this.id()));
this.tableRecordCard = this.autoDispose(ko.pureComputed(() => this.table().recordCardViewSection()));
this.isRecordCard = this.autoDispose(ko.pureComputed(() =>
this.table().recordCardViewSectionRef() === this.id()));
this.disabled = modelUtil.fieldWithDefault(this.optionsObj.prop('disabled'), false);
this.isTableRecordCardDisabled = ko.pureComputed(() => this.tableRecordCard().disabled());
this.isVirtual = this.autoDispose(ko.pureComputed(() => typeof this.id() === 'string')); this.isVirtual = this.autoDispose(ko.pureComputed(() => typeof this.id() === 'string'));
@ -818,7 +837,7 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
let newColInfo: NewColInfo; let newColInfo: NewColInfo;
await docModel.docData.bundleActions('Insert column', async () => { await docModel.docData.bundleActions('Insert column', async () => {
newColInfo = await docModel.dataTables[this.tableId.peek()].sendTableAction(action); newColInfo = await docModel.dataTables[this.tableId.peek()].sendTableAction(action);
if (!this.isRaw.peek()) { if (!this.isRaw.peek() && !this.isRecordCard.peek()) {
const fieldInfo = { const fieldInfo = {
colRef: newColInfo.colRef, colRef: newColInfo.colRef,
parentId: this.id.peek(), parentId: this.id.peek(),

@ -33,3 +33,7 @@ export function PERMITTED_CUSTOM_WIDGETS(): Observable<string[]> {
} }
return G.window.PERMITTED_CUSTOM_WIDGETS; return G.window.PERMITTED_CUSTOM_WIDGETS;
} }
export function RECORD_CARDS() {
return Boolean(getGristConfig().experimentalPlugins);
}

@ -0,0 +1,49 @@
import { allCommands } from 'app/client/components/commands';
import { makeT } from 'app/client/lib/localization';
import { menuDivider, menuItemCmd } from 'app/client/ui2018/menus';
import { dom } from 'grainjs';
const t = makeT('CardContextMenu');
export interface ICardContextMenu {
disableInsert: boolean;
disableDelete: boolean;
isViewSorted: boolean;
numRows: number;
}
export function CardContextMenu({
disableInsert,
disableDelete,
isViewSorted,
numRows
}: ICardContextMenu) {
const result: Element[] = [];
if (isViewSorted) {
result.push(
menuItemCmd(allCommands.insertRecordAfter, t("Insert card"),
dom.cls('disabled', disableInsert)),
);
} else {
result.push(
menuItemCmd(allCommands.insertRecordBefore, t("Insert card above"),
dom.cls('disabled', disableInsert)),
menuItemCmd(allCommands.insertRecordAfter, t("Insert card below"),
dom.cls('disabled', disableInsert)),
);
}
result.push(
menuItemCmd(allCommands.duplicateRows, t("Duplicate card"),
dom.cls('disabled', disableInsert || numRows === 0)),
);
result.push(
menuDivider(),
menuItemCmd(allCommands.deleteRecords, t("Delete card"),
dom.cls('disabled', disableDelete)),
);
result.push(
menuDivider(),
menuItemCmd(allCommands.copyLink, t("Copy anchor link"))
);
return result;
}

@ -2,32 +2,36 @@ import { allCommands } from 'app/client/components/commands';
import { makeT } from 'app/client/lib/localization'; import { makeT } from 'app/client/lib/localization';
import { menuDivider, menuItemCmd } from 'app/client/ui2018/menus'; import { menuDivider, menuItemCmd } from 'app/client/ui2018/menus';
import { IMultiColumnContextMenu } from 'app/client/ui/GridViewMenus'; import { IMultiColumnContextMenu } from 'app/client/ui/GridViewMenus';
import { IRowContextMenu } from 'app/client/ui/RowContextMenu';
import { COMMENTS } from 'app/client/models/features'; import { COMMENTS } from 'app/client/models/features';
import { dom } from 'grainjs'; import { dom } from 'grainjs';
const t = makeT('CellContextMenu'); const t = makeT('CellContextMenu');
export function CellContextMenu(rowOptions: IRowContextMenu, colOptions: IMultiColumnContextMenu) { export interface ICellContextMenu {
disableInsert: boolean;
disableDelete: boolean;
isViewSorted: boolean;
numRows: number;
}
export function CellContextMenu(cellOptions: ICellContextMenu, colOptions: IMultiColumnContextMenu) {
const { disableInsert, disableDelete, isViewSorted } = rowOptions; const { disableInsert, disableDelete, isViewSorted, numRows } = cellOptions;
const { disableModify, isReadonly } = colOptions; const { numColumns, disableModify, isReadonly, isFiltered } = colOptions;
// disableModify is true if the column is a summary column or is being transformed. // disableModify is true if the column is a summary column or is being transformed.
// isReadonly is true for readonly mode. // isReadonly is true for readonly mode.
const disableForReadonlyColumn = dom.cls('disabled', Boolean(disableModify) || isReadonly); const disableForReadonlyColumn = dom.cls('disabled', Boolean(disableModify) || isReadonly);
const disableForReadonlyView = dom.cls('disabled', isReadonly); const disableForReadonlyView = dom.cls('disabled', isReadonly);
const numCols: number = colOptions.numColumns; const nameClearColumns = isFiltered ?
const nameClearColumns = colOptions.isFiltered ? t("Reset {{count}} entire columns", {count: numColumns}) :
t("Reset {{count}} entire columns", {count: numCols}) : t("Reset {{count}} columns", {count: numColumns});
t("Reset {{count}} columns", {count: numCols}); const nameDeleteColumns = t("Delete {{count}} columns", {count: numColumns});
const nameDeleteColumns = t("Delete {{count}} columns", {count: numCols});
const numRows: number = rowOptions.numRows;
const nameDeleteRows = t("Delete {{count}} rows", {count: numRows}); const nameDeleteRows = t("Delete {{count}} rows", {count: numRows});
const nameClearCells = (numRows > 1 || numCols > 1) ? t("Clear values") : t("Clear cell"); const nameClearCells = (numRows > 1 || numColumns > 1) ? t("Clear values") : t("Clear cell");
const result: Array<Element|null> = []; const result: Array<Element|null> = [];
@ -42,13 +46,13 @@ export function CellContextMenu(rowOptions: IRowContextMenu, colOptions: IMultiC
menuItemCmd(allCommands.clearColumns, nameClearColumns, disableForReadonlyColumn), menuItemCmd(allCommands.clearColumns, nameClearColumns, disableForReadonlyColumn),
...( ...(
(numCols > 1 || numRows > 1) ? [] : [ (numColumns > 1 || numRows > 1) ? [] : [
menuDivider(), menuDivider(),
menuItemCmd(allCommands.copyLink, t("Copy anchor link")), menuItemCmd(allCommands.copyLink, t("Copy anchor link")),
menuDivider(), menuDivider(),
menuItemCmd(allCommands.filterByThisCellValue, t("Filter by this value")), menuItemCmd(allCommands.filterByThisCellValue, t("Filter by this value")),
menuItemCmd(allCommands.openDiscussion, t('Comment'), dom.cls('disabled', ( menuItemCmd(allCommands.openDiscussion, t('Comment'), dom.cls('disabled', (
isReadonly || numRows === 0 || numCols === 0 isReadonly || numRows === 0 || numColumns === 0
)), dom.hide(use => !use(COMMENTS()))) //TODO: i18next )), dom.hide(use => !use(COMMENTS()))) //TODO: i18next
] ]
), ),

@ -1,6 +1,5 @@
import {allCommands} from 'app/client/components/commands'; import {allCommands} from 'app/client/components/commands';
import {makeT} from 'app/client/lib/localization'; import {makeT} from 'app/client/lib/localization';
import {IRowContextMenu} from 'app/client/ui/RowContextMenu';
import {menuDivider, menuItemCmd} from 'app/client/ui2018/menus'; import {menuDivider, menuItemCmd} from 'app/client/ui2018/menus';
import {dom} from 'grainjs'; import {dom} from 'grainjs';
@ -11,7 +10,7 @@ export interface IFieldContextMenu {
isReadonly: boolean; isReadonly: boolean;
} }
export function FieldContextMenu(_rowOptions: IRowContextMenu, fieldOptions: IFieldContextMenu) { export function FieldContextMenu(fieldOptions: IFieldContextMenu) {
const {disableModify, isReadonly} = fieldOptions; const {disableModify, isReadonly} = fieldOptions;
const disableForReadonlyColumn = dom.cls('disabled', disableModify || isReadonly); const disableForReadonlyColumn = dom.cls('disabled', disableModify || isReadonly);
return [ return [

@ -86,7 +86,7 @@ function removeView(activeDoc: GristDoc, viewId: number, pageName: string) {
const docData = activeDoc.docData; const docData = activeDoc.docData;
// Create a set with tables on other pages (but not on this one). // Create a set with tables on other pages (but not on this one).
const tablesOnOtherViews = new Set(activeDoc.docModel.viewSections.rowModels const tablesOnOtherViews = new Set(activeDoc.docModel.viewSections.rowModels
.filter(vs => !vs.isRaw.peek() && vs.parentId.peek() !== viewId) .filter(vs => !vs.isRaw.peek() && !vs.isRecordCard.peek() && vs.parentId.peek() !== viewId)
.map(vs => vs.tableRef.peek())); .map(vs => vs.tableRef.peek()));
// Check if this page is a last page for some tables. // Check if this page is a last page for some tables.

@ -356,7 +356,10 @@ export class RightPanel extends Disposable {
dom.maybe(this._validSection, (activeSection) => ( dom.maybe(this._validSection, (activeSection) => (
buildConfigContainer( buildConfigContainer(
subTab === 'widget' ? dom.create(this._buildPageWidgetConfig.bind(this), activeSection) : subTab === 'widget' ? dom.create(this._buildPageWidgetConfig.bind(this), activeSection) :
subTab === 'sortAndFilter' ? dom.create(this._buildPageSortFilterConfig.bind(this)) : subTab === 'sortAndFilter' ? [
dom.create(this._buildPageSortFilterConfig.bind(this)),
cssConfigContainer.cls('-disabled', activeSection.isRecordCard),
] :
subTab === 'data' ? dom.create(this._buildPageDataConfig.bind(this), activeSection) : subTab === 'data' ? dom.create(this._buildPageDataConfig.bind(this), activeSection) :
null null
) )
@ -397,33 +400,35 @@ export class RightPanel extends Disposable {
return dom.maybe(viewConfigTab, (vct) => [ return dom.maybe(viewConfigTab, (vct) => [
this._disableIfReadonly(), this._disableIfReadonly(),
cssLabel(dom.text(use => use(activeSection.isRaw) ? t("DATA TABLE NAME") : t("WIDGET TITLE")), dom.maybe(use => !use(activeSection.isRecordCard), () => [
dom.style('margin-bottom', '14px'), cssLabel(dom.text(use => use(activeSection.isRaw) ? t("DATA TABLE NAME") : t("WIDGET TITLE")),
), dom.style('margin-bottom', '14px'),
cssRow(cssTextInput( ),
Computed.create(owner, (use) => use(activeSection.titleDef)), cssRow(cssTextInput(
val => activeSection.titleDef.saveOnly(val), Computed.create(owner, (use) => use(activeSection.titleDef)),
dom.boolAttr('disabled', use => { val => activeSection.titleDef.saveOnly(val),
const isRawTable = use(activeSection.isRaw); dom.boolAttr('disabled', use => {
const isSummaryTable = use(use(activeSection.table).summarySourceTable) !== 0; const isRawTable = use(activeSection.isRaw);
return isRawTable && isSummaryTable; const isSummaryTable = use(use(activeSection.table).summarySourceTable) !== 0;
}), return isRawTable && isSummaryTable;
testId('right-widget-title') }),
)), testId('right-widget-title')
)),
cssSection(
dom.create(buildDescriptionConfig, activeSection.description, { cursor, "testPrefix": "right-widget" }), cssSection(
), dom.create(buildDescriptionConfig, activeSection.description, { cursor, "testPrefix": "right-widget" }),
),
]),
dom.maybe( dom.maybe(
(use) => !use(activeSection.isRaw), (use) => !use(activeSection.isRaw) && !use(activeSection.isRecordCard),
() => cssRow( () => cssRow(
primaryButton(t("Change Widget"), this._createPageWidgetPicker()), primaryButton(t("Change Widget"), this._createPageWidgetPicker()),
cssRow.cls('-top-space') cssRow.cls('-top-space')
), ),
), ),
cssSeparator(), cssSeparator(dom.hide(activeSection.isRecordCard)),
dom.maybe((use) => ['detail', 'single'].includes(use(this._pageWidgetType)!), () => [ dom.maybe((use) => ['detail', 'single'].includes(use(this._pageWidgetType)!), () => [
cssLabel(t("Theme")), cssLabel(t("Theme")),
@ -744,7 +749,7 @@ export class RightPanel extends Disposable {
dom.hide((use) => !use(use(table).summarySourceTable)), dom.hide((use) => !use(use(table).summarySourceTable)),
), ),
dom.maybe((use) => !use(activeSection.isRaw), () => dom.maybe((use) => !use(activeSection.isRaw) && !use(activeSection.isRecordCard), () =>
cssButtonRow(primaryButton(t("Edit Data Selection"), this._createPageWidgetPicker(), cssButtonRow(primaryButton(t("Edit Data Selection"), this._createPageWidgetPicker(),
testId('pwc-editDataSelection')), testId('pwc-editDataSelection')),
dom.maybe( dom.maybe(
@ -764,9 +769,9 @@ export class RightPanel extends Disposable {
dom.maybe(viewConfigTab, (vct) => cssRow( dom.maybe(viewConfigTab, (vct) => cssRow(
dom('div', vct._buildAdvancedSettingsDom()), dom('div', vct._buildAdvancedSettingsDom()),
)), )),
cssSeparator(),
dom.maybe((use) => !use(activeSection.isRaw), () => [ dom.maybe((use) => !use(activeSection.isRaw) && !use(activeSection.isRecordCard), () => [
cssSeparator(),
cssLabel(t("SELECT BY")), cssLabel(t("SELECT BY")),
cssRow( cssRow(
dom.update( dom.update(
@ -1033,6 +1038,10 @@ const cssConfigContainer = styled('div.test-config-container', `
& .fieldbuilder_settings { & .fieldbuilder_settings {
margin: 16px 0 0 0; margin: 16px 0 0 0;
} }
&-disabled {
opacity: 0.4;
pointer-events: none;
}
`); `);
const cssDataLabel = styled('div', ` const cssDataLabel = styled('div', `

@ -1,6 +1,7 @@
import { allCommands } from 'app/client/components/commands'; import { allCommands } from 'app/client/components/commands';
import { makeT } from 'app/client/lib/localization'; import { makeT } from 'app/client/lib/localization';
import { menuDivider, menuItemCmd } from 'app/client/ui2018/menus'; import { RECORD_CARDS } from 'app/client/models/features';
import { menuDivider, menuIcon, menuItemCmd, menuItemCmdLabel } from 'app/client/ui2018/menus';
import { dom } from 'grainjs'; import { dom } from 'grainjs';
const t = makeT('RowContextMenu'); const t = makeT('RowContextMenu');
@ -8,12 +9,29 @@ const t = makeT('RowContextMenu');
export interface IRowContextMenu { export interface IRowContextMenu {
disableInsert: boolean; disableInsert: boolean;
disableDelete: boolean; disableDelete: boolean;
disableShowRecordCard: boolean;
isViewSorted: boolean; isViewSorted: boolean;
numRows: number; numRows: number;
} }
export function RowContextMenu({ disableInsert, disableDelete, isViewSorted, numRows }: IRowContextMenu) { export function RowContextMenu({
disableInsert,
disableDelete,
disableShowRecordCard,
isViewSorted,
numRows
}: IRowContextMenu) {
const result: Element[] = []; const result: Element[] = [];
if (RECORD_CARDS() && numRows === 1) {
result.push(
menuItemCmd(
allCommands.viewAsCard,
() => menuItemCmdLabel(menuIcon('TypeCard'), t("View as card")),
dom.cls('disabled', disableShowRecordCard),
),
menuDivider(),
);
}
if (isViewSorted) { if (isViewSorted) {
// When the view is sorted, any newly added records get shifts instantly at the top or // When the view is sorted, any newly added records get shifts instantly at the top or
// bottom. It could be very confusing for users who might expect the record to stay above or // bottom. It could be very confusing for users who might expect the record to stay above or

@ -16,7 +16,7 @@ import {buildUrlId, isFeatureEnabled, parseUrlId} from 'app/common/gristUrls';
import * as roles from 'app/common/roles'; import * as roles from 'app/common/roles';
import {Document} from 'app/common/UserAPI'; import {Document} from 'app/common/UserAPI';
import {dom, DomContents, styled} from 'grainjs'; import {dom, DomContents, styled} from 'grainjs';
import {MenuCreateFunc} from 'popweasel'; import {cssMenuItem, MenuCreateFunc} from 'popweasel';
import {makeT} from 'app/client/lib/localization'; import {makeT} from 'app/client/lib/localization';
const t = makeT('ShareMenu'); const t = makeT('ShareMenu');
@ -378,9 +378,12 @@ const cssMenuIconLink = styled('a', `
padding: 8px 24px; padding: 8px 24px;
--icon-color: ${theme.controlFg}; --icon-color: ${theme.controlFg};
&:hover { .${cssMenuItem.className}-sel > & {
background-color: ${theme.hover}; --icon-color: ${theme.menuItemIconSelectedFg};
--icon-color: ${theme.controlHoverFg}; }
.${cssMenuItem.className}.disabled & {
--icon-color: ${theme.menuItemDisabledFg};
} }
`); `);

@ -58,6 +58,7 @@ export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: bool
const showRawData = (use: UseCB) => { const showRawData = (use: UseCB) => {
return !use(viewSection.isRaw)// Don't show raw data if we're already in raw data. return !use(viewSection.isRaw)// Don't show raw data if we're already in raw data.
&& !use(viewSection.isRecordCard)
&& !isSinglePage // Don't show raw data in single page mode. && !isSinglePage // Don't show raw data in single page mode.
; ;
}; };
@ -88,20 +89,22 @@ export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: bool
dom.maybe(!isSinglePage, () => [ dom.maybe(!isSinglePage, () => [
menuDivider(), menuDivider(),
menuItemCmd(allCommands.viewTabOpen, t("Widget options"), testId('widget-options')), menuItemCmd(allCommands.viewTabOpen, t("Widget options"), testId('widget-options')),
menuItemCmd(allCommands.sortFilterTabOpen, t("Advanced Sort & Filter")), menuItemCmd(allCommands.sortFilterTabOpen, t("Advanced Sort & Filter"), dom.hide(viewSection.isRecordCard)),
menuItemCmd(allCommands.dataSelectionTabOpen, t("Data selection")), menuItemCmd(allCommands.dataSelectionTabOpen, t("Data selection"), dom.hide(viewSection.isRecordCard)),
]), ]),
menuDivider(), menuDivider(dom.hide(viewSection.isRecordCard)),
dom.maybe((use) => use(viewSection.parentKey) === 'custom' && use(viewSection.hasCustomOptions), () => dom.maybe((use) => use(viewSection.parentKey) === 'custom' && use(viewSection.hasCustomOptions), () =>
menuItemCmd(allCommands.openWidgetConfiguration, t("Open configuration"), menuItemCmd(allCommands.openWidgetConfiguration, t("Open configuration"),
testId('section-open-configuration')), testId('section-open-configuration')),
), ),
menuItemCmd(allCommands.collapseSection, t("Collapse widget"), menuItemCmd(allCommands.collapseSection, t("Collapse widget"),
dom.cls('disabled', dontCollapseSection()), dom.cls('disabled', dontCollapseSection()),
dom.hide(viewSection.isRecordCard),
testId('section-collapse')), testId('section-collapse')),
menuItemCmd(allCommands.deleteSection, t("Delete widget"), menuItemCmd(allCommands.deleteSection, t("Delete widget"),
dom.cls('disabled', dontRemoveSection()), dom.cls('disabled', dontRemoveSection()),
dom.hide(viewSection.isRecordCard),
testId('section-delete')), testId('section-delete')),
]; ];
} }

@ -69,6 +69,7 @@ export function viewSectionMenu(
&& use(gristDoc.maximizedSectionId) !== use(viewSection.id) // not in when we are maximized && use(gristDoc.maximizedSectionId) !== use(viewSection.id) // not in when we are maximized
&& use(gristDoc.externalSectionId) !== use(viewSection.id) // not in when we are external && use(gristDoc.externalSectionId) !== use(viewSection.id) // not in when we are external
&& !use(viewSection.isRaw) // not in raw mode && !use(viewSection.isRaw) // not in raw mode
&& !use(viewSection.isRecordCard)
&& !use(singleVisible) // not in single section && !use(singleVisible) // not in single section
; ;
}); });
@ -145,6 +146,7 @@ export function viewSectionMenu(
ctl.close(); ctl.close();
}), }),
]}), ]}),
dom.hide(viewSection.isRecordCard),
), ),
cssMenu( cssMenu(
testId('viewLayout'), testId('viewLayout'),

@ -7,7 +7,7 @@ import { theme } from 'app/client/ui2018/cssVars';
import {menuCssClass} from 'app/client/ui2018/menus'; import {menuCssClass} from 'app/client/ui2018/menus';
import {ModalControl} from 'app/client/ui2018/modals'; import {ModalControl} from 'app/client/ui2018/modals';
import { Computed, dom, DomElementArg, makeTestId, Observable, styled } from 'grainjs'; import { Computed, dom, DomElementArg, makeTestId, Observable, styled } from 'grainjs';
import {IOpenController, setPopupToCreateDom} from 'popweasel'; import {IOpenController, IPopupOptions, PopupControl, setPopupToCreateDom} from 'popweasel';
import { descriptionInfoTooltip } from './tooltips'; import { descriptionInfoTooltip } from './tooltips';
import { autoGrow } from './forms'; import { autoGrow } from './forms';
import { cssInput, cssLabel, cssRenamePopup, cssTextArea } from 'app/client/ui/RenamePopupStyles'; import { cssInput, cssLabel, cssRenamePopup, cssTextArea } from 'app/client/ui/RenamePopupStyles';
@ -18,41 +18,105 @@ const t = makeT('WidgetTitle');
interface WidgetTitleOptions { interface WidgetTitleOptions {
tableNameHidden?: boolean, tableNameHidden?: boolean,
widgetNameHidden?: boolean, widgetNameHidden?: boolean,
disabled?: boolean,
} }
export function buildWidgetTitle(vs: ViewSectionRec, options: WidgetTitleOptions, ...args: DomElementArg[]) { export function buildWidgetTitle(vs: ViewSectionRec, options: WidgetTitleOptions, ...args: DomElementArg[]) {
const title = Computed.create(null, use => use(vs.titleDef)); const title = Computed.create(null, use => use(vs.titleDef));
const description = Computed.create(null, use => use(vs.description)); const description = Computed.create(null, use => use(vs.description));
return buildRenameWidget(vs, title, description, options, dom.autoDispose(title), ...args); return buildRenamableTitle(vs, title, description, options, dom.autoDispose(title), ...args);
} }
export function buildTableName(vs: ViewSectionRec, ...args: DomElementArg[]) { interface TableNameOptions {
isEditing: Observable<boolean>,
disabled?: boolean,
}
export function buildTableName(vs: ViewSectionRec, options: TableNameOptions, ...args: DomElementArg[]) {
const title = Computed.create(null, use => use(use(vs.table).tableNameDef)); const title = Computed.create(null, use => use(use(vs.table).tableNameDef));
const description = Computed.create(null, use => use(vs.description)); const description = Computed.create(null, use => use(vs.description));
return buildRenameWidget(vs, title, description, { widgetNameHidden: true }, dom.autoDispose(title), ...args); return buildRenamableTitle(
vs,
title,
description,
{
openOnClick: false,
widgetNameHidden: true,
...options,
},
dom.autoDispose(title),
...args
);
} }
export function buildRenameWidget( interface RenamableTitleOptions {
tableNameHidden?: boolean,
widgetNameHidden?: boolean,
/** Defaults to true. */
openOnClick?: boolean,
isEditing?: Observable<boolean>,
disabled?: boolean,
}
function buildRenamableTitle(
vs: ViewSectionRec, vs: ViewSectionRec,
title: Observable<string>, title: Observable<string>,
description: Observable<string>, description: Observable<string>,
options: WidgetTitleOptions, options: RenamableTitleOptions,
...args: DomElementArg[]) { ...args: DomElementArg[]
) {
const {openOnClick = true, disabled = false, isEditing, ...renameTitleOptions} = options;
let popupControl: PopupControl | undefined;
return cssTitleContainer( return cssTitleContainer(
cssTitle( cssTitle(
testId('text'), testId('text'),
dom.text(title), dom.text(title),
dom.on('click', () => {
// The popup doesn't close if `openOnClick` is false and the title is
// clicked. Make sure that it does.
if (!openOnClick) { popupControl?.close(); }
}),
// In case titleDef is all blank space, make it visible on hover. // In case titleDef is all blank space, make it visible on hover.
cssTitle.cls("-empty", use => !use(title)?.trim()), cssTitle.cls("-empty", use => !use(title)?.trim()),
cssTitle.cls("-open-on-click", openOnClick),
cssTitle.cls("-disabled", disabled),
elem => { elem => {
setPopupToCreateDom(elem, ctl => buildWidgetRenamePopup(ctl, vs, options), { if (disabled) { return; }
// The widget title popup can be configured to open in up to two ways:
// 1. When the title is clicked - done by setting `openOnClick` to `true`.
// 2. When `isEditing` is set to true - done by setting `isEditing` to `true`.
//
// Typically, the former should be set. The latter is useful for triggering the
// popup from a different part of the UI, like a menu item.
const trigger: IPopupOptions['trigger'] = [];
if (openOnClick) { trigger.push('click'); }
if (isEditing) {
trigger.push((_: Element, ctl: PopupControl) => {
popupControl = ctl;
ctl.autoDispose(isEditing.addListener((editing) => {
if (editing) {
ctl.open();
} else if (!ctl.isDisposed()) {
ctl.close();
}
}));
});
}
setPopupToCreateDom(elem, ctl => {
if (isEditing) {
ctl.onDispose(() => isEditing.set(false));
}
return buildRenameTitlePopup(ctl, vs, renameTitleOptions);
}, {
placement: 'bottom-start', placement: 'bottom-start',
trigger: ['click'], trigger,
attach: 'body', attach: 'body',
boundaries: 'viewport', boundaries: 'viewport',
}); });
}, },
dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }), openOnClick ? dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }) : null,
), ),
dom.maybe(description, () => [ dom.maybe(description, () => [
descriptionInfoTooltip(description.get(), "widget") descriptionInfoTooltip(description.get(), "widget")
@ -61,7 +125,7 @@ export function buildRenameWidget(
); );
} }
function buildWidgetRenamePopup(ctrl: IOpenController, vs: ViewSectionRec, options: WidgetTitleOptions) { function buildRenameTitlePopup(ctrl: IOpenController, vs: ViewSectionRec, options: RenamableTitleOptions) {
const tableRec = vs.table.peek(); const tableRec = vs.table.peek();
// If the table is a summary table. // If the table is a summary table.
const isSummary = Boolean(tableRec.summarySourceTable.peek()); const isSummary = Boolean(tableRec.summarySourceTable.peek());
@ -279,14 +343,16 @@ const cssTitleContainer = styled('div', `
`); `);
const cssTitle = styled('div', ` const cssTitle = styled('div', `
cursor: pointer;
overflow: hidden; overflow: hidden;
border-radius: 3px; border-radius: 3px;
margin: -4px; margin: -4px;
padding: 4px; padding: 4px;
text-overflow: ellipsis; text-overflow: ellipsis;
align-self: start; align-self: start;
&:hover { &-open-on-click:not(&-disabled) {
cursor: pointer;
}
&-open-on-click:not(&-disabled):hover {
background-color: ${theme.hover}; background-color: ${theme.hover};
} }
&-empty { &-empty {

@ -9,7 +9,7 @@ import { IconName } from 'app/client/ui2018/IconList';
import { icon } from 'app/client/ui2018/icons'; import { icon } from 'app/client/ui2018/icons';
import { cssSelectBtn } from 'app/client/ui2018/select'; import { cssSelectBtn } from 'app/client/ui2018/select';
import { import {
BindableValue, Computed, dom, DomElementArg, DomElementMethod, IDomArgs, BindableValue, Computed, dom, DomContents, DomElementArg, DomElementMethod, IDomArgs,
MaybeObsArray, MutableObsArray, Observable, styled MaybeObsArray, MutableObsArray, Observable, styled
} from 'grainjs'; } from 'grainjs';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
@ -574,16 +574,27 @@ export const menuItemAsync: typeof weasel.menuItem = function(action, ...args) {
return menuItem(() => setTimeout(action, 0), ...args); return menuItem(() => setTimeout(action, 0), ...args);
}; };
export function menuItemCmd(cmd: Command, label: string, ...args: DomElementArg[]) { export function menuItemCmd(
cmd: Command,
label: string | (() => DomContents),
...args: DomElementArg[]
) {
return menuItem( return menuItem(
() => cmd.run(), () => cmd.run(),
dom('span', label, testId('cmd-name')), typeof label === 'string'
? dom('span', label, testId('cmd-name'))
: dom('div', label(), testId('cmd-name')),
cmd.humanKeys.length ? cssCmdKey(cmd.humanKeys[0]) : null, cmd.humanKeys.length ? cssCmdKey(cmd.humanKeys[0]) : null,
cssMenuItemCmd.cls(''), // overrides some menu item styles cssMenuItemCmd.cls(''), // overrides some menu item styles
...args ...args
); );
} }
export const menuItemCmdLabel = styled('div', `
display: flex;
align-items: center;
`);
export function menuAnnotate(text: string, ...args: DomElementArg[]) { export function menuAnnotate(text: string, ...args: DomElementArg[]) {
return cssAnnotateMenuItem(text, ...args); return cssAnnotateMenuItem(text, ...args);
} }
@ -701,6 +712,15 @@ const cssInputButtonMenuElem = styled(cssMenuElem, `
const cssMenuItemCmd = styled('div', ` const cssMenuItemCmd = styled('div', `
justify-content: space-between; justify-content: space-between;
--icon-color: ${theme.menuItemFg};
.${weasel.cssMenuItem.className}-sel & {
--icon-color: ${theme.menuItemSelectedFg};
}
.${weasel.cssMenuItem.className}.disabled & {
--icon-color: ${theme.menuItemDisabledFg};
}
`); `);
const cssCmdKey = styled('span', ` const cssCmdKey = styled('span', `
@ -712,7 +732,7 @@ const cssCmdKey = styled('span', `
color: ${theme.menuItemIconSelectedFg}; color: ${theme.menuItemIconSelectedFg};
} }
.${weasel.cssMenuItem.className}.disabled > & { .${weasel.cssMenuItem.className}.disabled & {
color: ${theme.menuItemDisabledFg}; color: ${theme.menuItemDisabledFg};
} }
`); `);

@ -77,7 +77,11 @@ export class CellStyle extends Disposable {
}), }),
cssLine( cssLine(
cssLabel(t('CELL STYLE')), cssLabel(t('CELL STYLE')),
cssButton(t('Open row styles'), dom.on('click', allCommands.viewTabOpen.run)), cssButton(
t('Open row styles'),
dom.on('click', allCommands.viewTabOpen.run),
dom.hide(!isTableWidget),
),
), ),
cssRow( cssRow(
testId('cell-color-select'), testId('cell-color-select'),

@ -1,12 +1,16 @@
import {makeT} from 'app/client/lib/localization'; import {makeT} from 'app/client/lib/localization';
import {DataRowModel} from 'app/client/models/DataRowModel'; import {DataRowModel} from 'app/client/models/DataRowModel';
import {TableRec} from 'app/client/models/DocModel';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {RECORD_CARDS} from 'app/client/models/features';
import {urlState} from 'app/client/models/gristUrlState';
import {cssLabel, cssRow} from 'app/client/ui/RightPanelStyles'; import {cssLabel, cssRow} from 'app/client/ui/RightPanelStyles';
import {hideInPrintView, testId, theme} from 'app/client/ui2018/cssVars'; import {hideInPrintView, testId, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons'; import {icon} from 'app/client/ui2018/icons';
import {IOptionFull, select} from 'app/client/ui2018/menus'; import {IOptionFull, select} from 'app/client/ui2018/menus';
import {NTextBox} from 'app/client/widgets/NTextBox'; import {NTextBox} from 'app/client/widgets/NTextBox';
import {isFullReferencingType, isVersions} from 'app/common/gristTypes'; import {isFullReferencingType, isVersions} from 'app/common/gristTypes';
import {UIRowId} from 'app/plugin/GristAPI';
import {Computed, dom, styled} from 'grainjs'; import {Computed, dom, styled} from 'grainjs';
@ -16,6 +20,7 @@ const t = makeT('Reference');
* Reference - The widget for displaying references to another table's records. * Reference - The widget for displaying references to another table's records.
*/ */
export class Reference extends NTextBox { export class Reference extends NTextBox {
private _refTable: Computed<TableRec | null>;
private _visibleColRef: Computed<number>; private _visibleColRef: Computed<number>;
private _validCols: Computed<Array<IOptionFull<number>>>; private _validCols: Computed<Array<IOptionFull<number>>>;
@ -26,8 +31,10 @@ export class Reference extends NTextBox {
// Note that saveOnly is used here to prevent display value flickering on visible col change. // Note that saveOnly is used here to prevent display value flickering on visible col change.
this._visibleColRef.onWrite((val) => this.field.visibleColRef.saveOnly(val)); this._visibleColRef.onWrite((val) => this.field.visibleColRef.saveOnly(val));
this._refTable = Computed.create(this, (use) => use(use(this.field.column).refTable));
this._validCols = Computed.create(this, (use) => { this._validCols = Computed.create(this, (use) => {
const refTable = use(use(this.field.column).refTable); const refTable = use(this._refTable);
if (!refTable) { return []; } if (!refTable) { return []; }
return use(use(refTable.columns).getObservable()) return use(use(refTable.columns).getObservable())
.filter(col => !use(col.isHiddenCol)) .filter(col => !use(col.isHiddenCol))
@ -75,16 +82,16 @@ export class Reference extends NTextBox {
return id && use(id); return id && use(id);
}); });
const formattedValue = Computed.create(null, (use) => { const formattedValue = Computed.create(null, (use) => {
let [value, hasBlankReference] = ['', false]; let [value, hasBlankReference, hasRecordCard] = ['', false, false];
if (use(row._isAddRow) || this.isDisposed() || use(this.field.displayColModel).isDisposed()) { if (use(row._isAddRow) || this.isDisposed() || use(this.field.displayColModel).isDisposed()) {
// Work around JS errors during certain changes (noticed when visibleCol field gets removed // Work around JS errors during certain changes (noticed when visibleCol field gets removed
// for a column using per-field settings). // for a column using per-field settings).
return {value, hasBlankReference}; return {value, hasBlankReference, hasRecordCard};
} }
const displayValueObs = row.cells[use(use(this.field.displayColModel).colId)]; const displayValueObs = row.cells[use(use(this.field.displayColModel).colId)];
if (!displayValueObs) { if (!displayValueObs) {
return {value, hasBlankReference}; return {value, hasBlankReference, hasRecordCard};
} }
const displayValue = use(displayValueObs); const displayValue = use(displayValueObs);
@ -97,8 +104,12 @@ export class Reference extends NTextBox {
use(this.field.formatter).formatAny(displayValue); use(this.field.formatter).formatAny(displayValue);
hasBlankReference = referenceId.get() !== 0 && value.trim() === ''; hasBlankReference = referenceId.get() !== 0 && value.trim() === '';
const refTable = use(this._refTable);
if (refTable) {
hasRecordCard = !use(use(refTable.recordCardViewSection).disabled);
}
return {value, hasBlankReference}; return {value, hasBlankReference, hasRecordCard};
}); });
return cssRef( return cssRef(
@ -107,12 +118,39 @@ export class Reference extends NTextBox {
cssRef.cls('-blank', use => use(formattedValue).hasBlankReference), cssRef.cls('-blank', use => use(formattedValue).hasBlankReference),
dom.style('text-align', this.alignment), dom.style('text-align', this.alignment),
dom.cls('text_wrapping', this.wrapping), dom.cls('text_wrapping', this.wrapping),
cssRefIcon('FieldReference', testId('ref-link-icon'), hideInPrintView()), cssRefIcon('FieldReference',
dom.text(use => { cssRefIcon.cls('-view-as-card', use =>
if (use(referenceId) === 0) { return ''; } RECORD_CARDS() && use(referenceId) !== 0 && use(formattedValue).hasRecordCard),
if (use(formattedValue).hasBlankReference) { return '[Blank]'; } dom.on('click', async () => {
return use(formattedValue).value; if (!RECORD_CARDS()) { return; }
}) if (referenceId.get() === 0 || !formattedValue.get().hasRecordCard) { return; }
const rowId = referenceId.get() as UIRowId;
const sectionId = this._refTable.get()?.recordCardViewSectionRef();
if (sectionId === undefined) {
throw new Error('Unable to find Record Card section');
}
const anchorUrlState = {hash: {rowId, sectionId, recordCard: true}};
await urlState().pushUrl(anchorUrlState, {replace: true});
}),
dom.on('mousedown', (ev) => {
if (!RECORD_CARDS()) { return; }
ev.stopPropagation();
ev.preventDefault();
}),
hideInPrintView(),
testId('ref-link-icon'),
),
dom('span',
dom.text(use => {
if (use(referenceId) === 0) { return ''; }
if (use(formattedValue).hasBlankReference) { return '[Blank]'; }
return use(formattedValue).value;
}),
testId('ref-text'),
),
); );
} }
} }
@ -121,6 +159,13 @@ const cssRefIcon = styled(icon, `
float: left; float: left;
--icon-color: ${theme.lightText}; --icon-color: ${theme.lightText};
margin: -1px 2px 2px 0; margin: -1px 2px 2px 0;
&-view-as-card {
cursor: pointer;
}
&-view-as-card:hover {
--icon-color: ${theme.controlFg};
}
`); `);
const cssRef = styled('div.field_clip', ` const cssRef = styled('div.field_clip', `

@ -290,9 +290,15 @@ export function encodeUrl(gristConfig: Partial<GristLoadConfig>,
queryParams[`${k}_`] = v; queryParams[`${k}_`] = v;
} }
const hashParts: string[] = []; const hashParts: string[] = [];
if (state.hash && (state.hash.rowId || state.hash.popup)) { if (state.hash && (state.hash.rowId || state.hash.popup || state.hash.recordCard)) {
const hash = state.hash; const hash = state.hash;
hashParts.push(state.hash?.popup ? 'a2' : `a1`); if (hash.recordCard) {
hashParts.push('a3');
} else if (hash.popup) {
hashParts.push('a2');
} else {
hashParts.push('a1');
}
for (const key of ['sectionId', 'rowId', 'colRef'] as Array<keyof HashLink>) { for (const key of ['sectionId', 'rowId', 'colRef'] as Array<keyof HashLink>) {
const partValue = hash[key]; const partValue = hash[key];
if (partValue) { if (partValue) {
@ -480,13 +486,13 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
hashMap.set(part.slice(0, 1), part.slice(1)); hashMap.set(part.slice(0, 1), part.slice(1));
} }
} }
if (hashMap.has('#') && ['a1', 'a2'].includes(hashMap.get('#') || '')) { if (hashMap.has('#') && ['a1', 'a2', 'a3'].includes(hashMap.get('#') || '')) {
const link: HashLink = {}; const link: HashLink = {};
const keys = [ const keys = [
'sectionId', 'sectionId',
'rowId', 'rowId',
'colRef', 'colRef',
] as Array<Exclude<keyof HashLink, 'popup' | 'rickRow'>>; ] as Array<Exclude<keyof HashLink, 'popup' | 'rickRow' | 'recordCard'>>;
for (const key of keys) { for (const key of keys) {
let ch: string; let ch: string;
if (key === 'rowId' && hashMap.has('rr')) { if (key === 'rowId' && hashMap.has('rr')) {
@ -504,7 +510,9 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
} }
} }
if (hashMap.get('#') === 'a2') { if (hashMap.get('#') === 'a2') {
link.popup = true; link.popup = true;
} else if (hashMap.get('#') === 'a3') {
link.recordCard = true;
} }
state.hash = link; state.hash = link;
} }
@ -722,6 +730,8 @@ export interface GristLoadConfig {
// Whether to show the "Delete Account" button in the account page. // Whether to show the "Delete Account" button in the account page.
canCloseAccount?: boolean; canCloseAccount?: boolean;
experimentalPlugins?: boolean;
} }
export const Features = StringUnion( export const Features = StringUnion(
@ -966,6 +976,7 @@ export interface HashLink {
colRef?: number; colRef?: number;
popup?: boolean; popup?: boolean;
rickRow?: boolean; rickRow?: boolean;
recordCard?: boolean;
} }
// Check whether a urlId is a prefix of the docId, and adequately long to be // Check whether a urlId is a prefix of the docId, and adequately long to be

@ -4,7 +4,7 @@ import { GristObjCode } from "app/plugin/GristData";
// tslint:disable:object-literal-key-quotes // tslint:disable:object-literal-key-quotes
export const SCHEMA_VERSION = 39; export const SCHEMA_VERSION = 40;
export const schema = { export const schema = {
@ -23,6 +23,7 @@ export const schema = {
summarySourceTable : "Ref:_grist_Tables", summarySourceTable : "Ref:_grist_Tables",
onDemand : "Bool", onDemand : "Bool",
rawViewSectionRef : "Ref:_grist_Views_section", rawViewSectionRef : "Ref:_grist_Views_section",
recordCardViewSectionRef: "Ref:_grist_Views_section",
}, },
"_grist_Tables_column": { "_grist_Tables_column": {
@ -234,6 +235,7 @@ export interface SchemaTypes {
summarySourceTable: number; summarySourceTable: number;
onDemand: boolean; onDemand: boolean;
rawViewSectionRef: number; rawViewSectionRef: number;
recordCardViewSectionRef: number;
}; };
"_grist_Tables_column": { "_grist_Tables_column": {

@ -6,8 +6,8 @@ export const GRIST_DOC_SQL = `
PRAGMA foreign_keys=OFF; PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION; BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT ''); CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT '');
INSERT INTO _grist_DocInfo VALUES(1,'','','',39,'',''); INSERT INTO _grist_DocInfo VALUES(1,'','','',40,'','');
CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0, "recordCardViewSectionRef" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "description" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL); CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "description" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL);
CREATE TABLE IF NOT EXISTS "_grist_Imports" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "origFileName" TEXT DEFAULT '', "parseFormula" TEXT DEFAULT '', "delimiter" TEXT DEFAULT '', "doublequote" BOOLEAN DEFAULT 0, "escapechar" TEXT DEFAULT '', "quotechar" TEXT DEFAULT '', "skipinitialspace" BOOLEAN DEFAULT 0, "encoding" TEXT DEFAULT '', "hasHeaders" BOOLEAN DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_Imports" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "origFileName" TEXT DEFAULT '', "parseFormula" TEXT DEFAULT '', "delimiter" TEXT DEFAULT '', "doublequote" BOOLEAN DEFAULT 0, "escapechar" TEXT DEFAULT '', "quotechar" TEXT DEFAULT '', "skipinitialspace" BOOLEAN DEFAULT 0, "encoding" TEXT DEFAULT '', "hasHeaders" BOOLEAN DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_External_database" (id INTEGER PRIMARY KEY, "host" TEXT DEFAULT '', "port" INTEGER DEFAULT 0, "username" TEXT DEFAULT '', "dialect" TEXT DEFAULT '', "database" TEXT DEFAULT '', "storage" TEXT DEFAULT ''); CREATE TABLE IF NOT EXISTS "_grist_External_database" (id INTEGER PRIMARY KEY, "host" TEXT DEFAULT '', "port" INTEGER DEFAULT 0, "username" TEXT DEFAULT '', "dialect" TEXT DEFAULT '', "database" TEXT DEFAULT '', "storage" TEXT DEFAULT '');
@ -43,9 +43,9 @@ export const GRIST_DOC_WITH_TABLE1_SQL = `
PRAGMA foreign_keys=OFF; PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION; BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT ''); CREATE TABLE IF NOT EXISTS "_grist_DocInfo" (id INTEGER PRIMARY KEY, "docId" TEXT DEFAULT '', "peers" TEXT DEFAULT '', "basketId" TEXT DEFAULT '', "schemaVersion" INTEGER DEFAULT 0, "timezone" TEXT DEFAULT '', "documentSettings" TEXT DEFAULT '');
INSERT INTO _grist_DocInfo VALUES(1,'','','',39,'',''); INSERT INTO _grist_DocInfo VALUES(1,'','','',40,'','');
CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_Tables" (id INTEGER PRIMARY KEY, "tableId" TEXT DEFAULT '', "primaryViewId" INTEGER DEFAULT 0, "summarySourceTable" INTEGER DEFAULT 0, "onDemand" BOOLEAN DEFAULT 0, "rawViewSectionRef" INTEGER DEFAULT 0, "recordCardViewSectionRef" INTEGER DEFAULT 0);
INSERT INTO _grist_Tables VALUES(1,'Table1',1,0,0,2); INSERT INTO _grist_Tables VALUES(1,'Table1',1,0,0,2,3);
CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "description" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL); CREATE TABLE IF NOT EXISTS "_grist_Tables_column" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colId" TEXT DEFAULT '', "type" TEXT DEFAULT '', "widgetOptions" TEXT DEFAULT '', "isFormula" BOOLEAN DEFAULT 0, "formula" TEXT DEFAULT '', "label" TEXT DEFAULT '', "description" TEXT DEFAULT '', "untieColIdFromLabel" BOOLEAN DEFAULT 0, "summarySourceCol" INTEGER DEFAULT 0, "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "rules" TEXT DEFAULT NULL, "recalcWhen" INTEGER DEFAULT 0, "recalcDeps" TEXT DEFAULT NULL);
INSERT INTO _grist_Tables_column VALUES(1,1,1,'manualSort','ManualSortPos','',0,'','manualSort','',0,0,0,0,NULL,0,NULL); INSERT INTO _grist_Tables_column VALUES(1,1,1,'manualSort','ManualSortPos','',0,'','manualSort','',0,0,0,0,NULL,0,NULL);
INSERT INTO _grist_Tables_column VALUES(2,1,2,'A','Any','',1,'','A','',0,0,0,0,NULL,0,NULL); INSERT INTO _grist_Tables_column VALUES(2,1,2,'A','Any','',1,'','A','',0,0,0,0,NULL,0,NULL);
@ -65,6 +65,7 @@ INSERT INTO _grist_Views VALUES(1,'Table1','raw_data','');
CREATE TABLE IF NOT EXISTS "_grist_Views_section" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "parentId" INTEGER DEFAULT 0, "parentKey" TEXT DEFAULT '', "title" TEXT DEFAULT '', "description" TEXT DEFAULT '', "defaultWidth" INTEGER DEFAULT 0, "borderWidth" INTEGER DEFAULT 0, "theme" TEXT DEFAULT '', "options" TEXT DEFAULT '', "chartType" TEXT DEFAULT '', "layoutSpec" TEXT DEFAULT '', "filterSpec" TEXT DEFAULT '', "sortColRefs" TEXT DEFAULT '', "linkSrcSectionRef" INTEGER DEFAULT 0, "linkSrcColRef" INTEGER DEFAULT 0, "linkTargetColRef" INTEGER DEFAULT 0, "embedId" TEXT DEFAULT '', "rules" TEXT DEFAULT NULL); CREATE TABLE IF NOT EXISTS "_grist_Views_section" (id INTEGER PRIMARY KEY, "tableRef" INTEGER DEFAULT 0, "parentId" INTEGER DEFAULT 0, "parentKey" TEXT DEFAULT '', "title" TEXT DEFAULT '', "description" TEXT DEFAULT '', "defaultWidth" INTEGER DEFAULT 0, "borderWidth" INTEGER DEFAULT 0, "theme" TEXT DEFAULT '', "options" TEXT DEFAULT '', "chartType" TEXT DEFAULT '', "layoutSpec" TEXT DEFAULT '', "filterSpec" TEXT DEFAULT '', "sortColRefs" TEXT DEFAULT '', "linkSrcSectionRef" INTEGER DEFAULT 0, "linkSrcColRef" INTEGER DEFAULT 0, "linkTargetColRef" INTEGER DEFAULT 0, "embedId" TEXT DEFAULT '', "rules" TEXT DEFAULT NULL);
INSERT INTO _grist_Views_section VALUES(1,1,1,'record','','',100,1,'','','','','','[]',0,0,0,'',NULL); INSERT INTO _grist_Views_section VALUES(1,1,1,'record','','',100,1,'','','','','','[]',0,0,0,'',NULL);
INSERT INTO _grist_Views_section VALUES(2,1,0,'record','','',100,1,'','','','','','',0,0,0,'',NULL); INSERT INTO _grist_Views_section VALUES(2,1,0,'record','','',100,1,'','','','','','',0,0,0,'',NULL);
INSERT INTO _grist_Views_section VALUES(3,1,0,'single','','',100,1,'','','','','','',0,0,0,'',NULL);
CREATE TABLE IF NOT EXISTS "_grist_Views_section_field" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colRef" INTEGER DEFAULT 0, "width" INTEGER DEFAULT 0, "widgetOptions" TEXT DEFAULT '', "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "filter" TEXT DEFAULT '', "rules" TEXT DEFAULT NULL); CREATE TABLE IF NOT EXISTS "_grist_Views_section_field" (id INTEGER PRIMARY KEY, "parentId" INTEGER DEFAULT 0, "parentPos" NUMERIC DEFAULT 1e999, "colRef" INTEGER DEFAULT 0, "width" INTEGER DEFAULT 0, "widgetOptions" TEXT DEFAULT '', "displayCol" INTEGER DEFAULT 0, "visibleCol" INTEGER DEFAULT 0, "filter" TEXT DEFAULT '', "rules" TEXT DEFAULT NULL);
INSERT INTO _grist_Views_section_field VALUES(1,1,1,2,0,'',0,0,'',NULL); INSERT INTO _grist_Views_section_field VALUES(1,1,1,2,0,'',0,0,'',NULL);
INSERT INTO _grist_Views_section_field VALUES(2,1,2,3,0,'',0,0,'',NULL); INSERT INTO _grist_Views_section_field VALUES(2,1,2,3,0,'',0,0,'',NULL);
@ -72,6 +73,9 @@ INSERT INTO _grist_Views_section_field VALUES(3,1,3,4,0,'',0,0,'',NULL);
INSERT INTO _grist_Views_section_field VALUES(4,2,4,2,0,'',0,0,'',NULL); INSERT INTO _grist_Views_section_field VALUES(4,2,4,2,0,'',0,0,'',NULL);
INSERT INTO _grist_Views_section_field VALUES(5,2,5,3,0,'',0,0,'',NULL); INSERT INTO _grist_Views_section_field VALUES(5,2,5,3,0,'',0,0,'',NULL);
INSERT INTO _grist_Views_section_field VALUES(6,2,6,4,0,'',0,0,'',NULL); INSERT INTO _grist_Views_section_field VALUES(6,2,6,4,0,'',0,0,'',NULL);
INSERT INTO _grist_Views_section_field VALUES(7,3,7,2,0,'',0,0,'',NULL);
INSERT INTO _grist_Views_section_field VALUES(8,3,8,3,0,'',0,0,'',NULL);
INSERT INTO _grist_Views_section_field VALUES(9,3,9,4,0,'',0,0,'',NULL);
CREATE TABLE IF NOT EXISTS "_grist_Validations" (id INTEGER PRIMARY KEY, "formula" TEXT DEFAULT '', "name" TEXT DEFAULT '', "tableRef" INTEGER DEFAULT 0); CREATE TABLE IF NOT EXISTS "_grist_Validations" (id INTEGER PRIMARY KEY, "formula" TEXT DEFAULT '', "name" TEXT DEFAULT '', "tableRef" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "_grist_REPL_Hist" (id INTEGER PRIMARY KEY, "code" TEXT DEFAULT '', "outputText" TEXT DEFAULT '', "errorText" TEXT DEFAULT ''); CREATE TABLE IF NOT EXISTS "_grist_REPL_Hist" (id INTEGER PRIMARY KEY, "code" TEXT DEFAULT '', "outputText" TEXT DEFAULT '', "errorText" TEXT DEFAULT '');
CREATE TABLE IF NOT EXISTS "_grist_Attachments" (id INTEGER PRIMARY KEY, "fileIdent" TEXT DEFAULT '', "fileName" TEXT DEFAULT '', "fileType" TEXT DEFAULT '', "fileSize" INTEGER DEFAULT 0, "fileExt" TEXT DEFAULT '', "imageHeight" INTEGER DEFAULT 0, "imageWidth" INTEGER DEFAULT 0, "timeDeleted" DATETIME DEFAULT NULL, "timeUploaded" DATETIME DEFAULT NULL); CREATE TABLE IF NOT EXISTS "_grist_Attachments" (id INTEGER PRIMARY KEY, "fileIdent" TEXT DEFAULT '', "fileName" TEXT DEFAULT '', "fileType" TEXT DEFAULT '', "fileSize" INTEGER DEFAULT 0, "fileExt" TEXT DEFAULT '', "imageHeight" INTEGER DEFAULT 0, "imageWidth" INTEGER DEFAULT 0, "timeDeleted" DATETIME DEFAULT NULL, "timeUploaded" DATETIME DEFAULT NULL);

@ -86,6 +86,7 @@ export function makeGristConfig(options: MakeGristConfigOptions): GristLoadConfi
deploymentType: server?.getDeploymentType(), deploymentType: server?.getDeploymentType(),
templateOrg: getTemplateOrg(), templateOrg: getTemplateOrg(),
canCloseAccount: isAffirmative(process.env.GRIST_ACCOUNT_CLOSE), canCloseAccount: isAffirmative(process.env.GRIST_ACCOUNT_CLOSE),
experimentalPlugins: isAffirmative(process.env.GRIST_EXPERIMENTAL_PLUGINS),
...extra, ...extra,
}; };
} }

@ -10,6 +10,7 @@ processes = []
APP_STATIC_URL="https://{APP_NAME}.fly.dev" APP_STATIC_URL="https://{APP_NAME}.fly.dev"
ALLOWED_WEBHOOK_DOMAINS="webhook.site" ALLOWED_WEBHOOK_DOMAINS="webhook.site"
PERMITTED_CUSTOM_WIDGETS="calendar" PERMITTED_CUSTOM_WIDGETS="calendar"
GRIST_EXPERIMENTAL_PLUGINS="1"
GRIST_SINGLE_ORG="docs" GRIST_SINGLE_ORG="docs"
PORT = "8080" PORT = "8080"
FLY_DEPLOY_EXPIRATION = "{FLY_DEPLOY_EXPIRATION}" FLY_DEPLOY_EXPIRATION = "{FLY_DEPLOY_EXPIRATION}"

@ -141,6 +141,9 @@ class MetaTableExtras(object):
def isRaw(rec, table): def isRaw(rec, table):
return rec.tableRef.rawViewSectionRef == rec return rec.tableRef.rawViewSectionRef == rec
def isRecordCard(rec, table):
return rec.tableRef.recordCardViewSectionRef == rec
class _grist_Filters(object): class _grist_Filters(object):
def setAutoRemove(rec, table): def setAutoRemove(rec, table):
"""Marks the filter for removal if its column no longer exists.""" """Marks the filter for removal if its column no longer exists."""

@ -1235,3 +1235,57 @@ def migration39(tdset):
if 'description' not in tdset.all_tables['_grist_Views_section'].columns: if 'description' not in tdset.all_tables['_grist_Views_section'].columns:
doc_actions.append(add_column('_grist_Views_section', 'description', 'Text')) doc_actions.append(add_column('_grist_Views_section', 'description', 'Text'))
return tdset.apply_doc_actions(doc_actions) return tdset.apply_doc_actions(doc_actions)
@migration(schema_version=40)
def migration40(tdset):
"""
Adds a recordCardViewSectionRef column to _grist_Tables, populating it
for each non-summary table in _grist_Tables that has a rawViewSectionRef.
"""
doc_actions = [
add_column(
'_grist_Tables',
'recordCardViewSectionRef',
'Ref:_grist_Views_section'
),
]
tables = list(actions.transpose_bulk_action(tdset.all_tables["_grist_Tables"]))
columns = list(actions.transpose_bulk_action(tdset.all_tables["_grist_Tables_column"]))
new_view_section_id = next_id(tdset, "_grist_Views_section")
for table in sorted(tables, key=lambda t: t.tableId):
if not table.rawViewSectionRef or table.summarySourceTable:
continue
table_columns = [
col for col in columns
if table.id == col.parentId and is_visible_column(col.colId)
]
table_columns.sort(key=lambda c: c.parentPos)
fields = {
"parentId": [new_view_section_id] * len(table_columns),
"colRef": [col.id for col in table_columns],
"parentPos": [col.parentPos for col in table_columns],
}
field_ids = [None] * len(table_columns)
doc_actions += [
actions.AddRecord("_grist_Views_section", new_view_section_id, {
"tableRef": table.id,
"parentId": 0,
"parentKey": "single",
"title": "",
"defaultWidth": 100,
"borderWidth": 1,
}),
actions.UpdateRecord("_grist_Tables", table.id, {
"recordCardViewSectionRef": new_view_section_id,
}),
actions.BulkAddRecord("_grist_Views_section_field", field_ids, fields),
]
new_view_section_id += 1
return tdset.apply_doc_actions(doc_actions)

@ -15,7 +15,7 @@ import six
import actions import actions
SCHEMA_VERSION = 39 SCHEMA_VERSION = 40
def make_column(col_id, col_type, formula='', isFormula=False): def make_column(col_id, col_type, formula='', isFormula=False):
return { return {
@ -58,6 +58,7 @@ def schema_create_actions():
make_column("onDemand", "Bool"), make_column("onDemand", "Bool"),
make_column("rawViewSectionRef", "Ref:_grist_Views_section"), make_column("rawViewSectionRef", "Ref:_grist_Views_section"),
make_column("recordCardViewSectionRef", "Ref:_grist_Views_section"),
]), ]),
# All columns in all user tables. # All columns in all user tables.

@ -202,7 +202,8 @@ class SummaryActions(object):
encode_summary_table_name(source_table.tableId, groupby_col_ids), encode_summary_table_name(source_table.tableId, groupby_col_ids),
[get_colinfo_dict(ci, with_id=True) for ci in groupby_colinfo + formula_colinfo], [get_colinfo_dict(ci, with_id=True) for ci in groupby_colinfo + formula_colinfo],
summarySourceTableRef=source_table.id, summarySourceTableRef=source_table.id,
raw_section=True) raw_section=True,
record_card_section=False)
summary_table = self.docmodel.tables.table.get_record(result['id']) summary_table = self.docmodel.tables.table.get_record(result['id'])
created = True created = True
# Note that in this case, _get_or_add_columns() below should not add any new columns, # Note that in this case, _get_or_add_columns() below should not add any new columns,

@ -223,15 +223,15 @@ class TestColumnActions(test_engine.EngineTestCase):
Field(2, colRef=12), Field(2, colRef=12),
Field(3, colRef=13), Field(3, colRef=13),
]), ]),
Section(4, parentKey="record", tableRef=2, fields=[ Section(5, parentKey="record", tableRef=2, fields=[
Field(10, colRef=15), Field(13, colRef=15),
Field(11, colRef=16), Field(14, colRef=16),
Field(12, colRef=17), Field(15, colRef=17),
]), ]),
Section(6, parentKey="record", tableRef=3, fields=[ Section(7, parentKey="record", tableRef=3, fields=[
Field(16, colRef=18), Field(19, colRef=18),
Field(17, colRef=20), Field(20, colRef=20),
Field(18, colRef=21), Field(21, colRef=21),
]), ]),
]), ]),
View(2, sections=[ View(2, sections=[
@ -311,14 +311,14 @@ class TestColumnActions(test_engine.EngineTestCase):
Field(2, colRef=12), Field(2, colRef=12),
Field(3, colRef=13), Field(3, colRef=13),
]), ]),
Section(4, parentKey="record", tableRef=2, fields=[ Section(5, parentKey="record", tableRef=2, fields=[
Field(10, colRef=15), Field(13, colRef=15),
Field(12, colRef=17), Field(15, colRef=17),
]), ]),
Section(6, parentKey="record", tableRef=3, fields=[ Section(7, parentKey="record", tableRef=3, fields=[
Field(16, colRef=18), Field(19, colRef=18),
Field(17, colRef=20), Field(20, colRef=20),
Field(18, colRef=21), Field(21, colRef=21),
]), ]),
]), ]),
View(2, sections=[ View(2, sections=[
@ -368,13 +368,13 @@ class TestColumnActions(test_engine.EngineTestCase):
Section(1, parentKey="record", tableRef=1, fields=[ Section(1, parentKey="record", tableRef=1, fields=[
Field(3, colRef=13), Field(3, colRef=13),
]), ]),
Section(4, parentKey="record", tableRef=2, fields=[ Section(5, parentKey="record", tableRef=2, fields=[
Field(10, colRef=15), Field(13, colRef=15),
Field(12, colRef=17), Field(15, colRef=17),
]), ]),
Section(6, parentKey="record", tableRef=4, fields=[ Section(7, parentKey="record", tableRef=4, fields=[
Field(17, colRef=23), Field(20, colRef=23),
Field(18, colRef=24), Field(21, colRef=24),
]), ]),
]), ]),
View(2, sections=[ View(2, sections=[
@ -420,14 +420,14 @@ class TestColumnActions(test_engine.EngineTestCase):
self.init_sample_data() self.init_sample_data()
# Add sortSpecs to ViewSections. # Add sortSpecs to ViewSections.
self.apply_user_action(['BulkUpdateRecord', '_grist_Views_section', [2, 3, 4], self.apply_user_action(['BulkUpdateRecord', '_grist_Views_section', [2, 3, 5],
{'sortColRefs': ['[15, -16]', '[-15, 16, 17]', '[19]']} {'sortColRefs': ['[15, -16]', '[-15, 16, 17]', '[19]']}
]) ])
self.assertTableData('_grist_Views_section', cols="subset", rows="subset", data=[ self.assertTableData('_grist_Views_section', cols="subset", rows="subset", data=[
["id", "sortColRefs" ], ["id", "sortColRefs" ],
[2, '[15, -16]' ], [2, '[15, -16]' ],
[3, '[-15, 16, 17]'], [3, '[-15, 16, 17]'],
[4, '[19]' ], [5, '[19]' ],
]) ])
# Remove column, and check that the correct sortColRefs items are removed. # Remove column, and check that the correct sortColRefs items are removed.
@ -436,7 +436,7 @@ class TestColumnActions(test_engine.EngineTestCase):
["id", "sortColRefs"], ["id", "sortColRefs"],
[2, '[15]' ], [2, '[15]' ],
[3, '[-15, 17]' ], [3, '[-15, 17]' ],
[4, '[19]' ], [5, '[19]' ],
]) ])
# Update sortColRefs for next test. # Update sortColRefs for next test.
@ -450,5 +450,5 @@ class TestColumnActions(test_engine.EngineTestCase):
["id", "sortColRefs"], ["id", "sortColRefs"],
[2, '[]' ], [2, '[]' ],
[3, '[-16]' ], [3, '[-16]' ],
[4, '[]' ], [5, '[]' ],
]) ])

@ -53,6 +53,7 @@ class TestUserActions(test_engine.EngineTestCase):
["id", "colRef", "displayCol"], ["id", "colRef", "displayCol"],
[1, 25, 0], [1, 25, 0],
[2, 25, 0], [2, 25, 0],
[3, 25, 0],
]) ])
self.assertTableData("Favorites", cols="subset", data=[ self.assertTableData("Favorites", cols="subset", data=[
["id", "favorite"], ["id", "favorite"],
@ -70,6 +71,7 @@ class TestUserActions(test_engine.EngineTestCase):
[1, 25, 0], [1, 25, 0],
[2, 25, 0], [2, 25, 0],
[3, 25, 0], [3, 25, 0],
[4, 25, 0],
]) ])
# Set display formula for 'favorite' column. # Set display formula for 'favorite' column.
@ -86,7 +88,7 @@ class TestUserActions(test_engine.EngineTestCase):
# A single "gristHelper_Display2" column should be added with the requested formula, since both # A single "gristHelper_Display2" column should be added with the requested formula, since both
# require the same formula. The fields' colRefs should be set to the new column. # require the same formula. The fields' colRefs should be set to the new column.
self.apply_user_action(['SetDisplayFormula', 'Favorites', 1, None, '$favorite.network']) self.apply_user_action(['SetDisplayFormula', 'Favorites', 1, None, '$favorite.network'])
self.apply_user_action(['SetDisplayFormula', 'Favorites', 3, None, '$favorite.network']) self.apply_user_action(['SetDisplayFormula', 'Favorites', 4, None, '$favorite.network'])
self.assertTableData("_grist_Tables_column", cols="subset", rows=(lambda r: r.id >= 25), data=[ self.assertTableData("_grist_Tables_column", cols="subset", rows=(lambda r: r.id >= 25), data=[
["id", "colId", "parentId", "displayCol", "formula"], ["id", "colId", "parentId", "displayCol", "formula"],
[25, "favorite", 2, 26, ""], [25, "favorite", 2, 26, ""],
@ -97,13 +99,14 @@ class TestUserActions(test_engine.EngineTestCase):
["id", "colRef", "displayCol"], ["id", "colRef", "displayCol"],
[1, 25, 27], [1, 25, 27],
[2, 25, 0], [2, 25, 0],
[3, 25, 27], [3, 25, 0],
[4, 25, 27],
]) ])
# Change display formula for a field. # Change display formula for a field.
# Since the field is changing to use a formula not yet held by a display column, # Since the field is changing to use a formula not yet held by a display column,
# a new display column should be added with the desired formula. # a new display column should be added with the desired formula.
self.apply_user_action(['SetDisplayFormula', 'Favorites', 3, None, '$favorite.viewers']) self.apply_user_action(['SetDisplayFormula', 'Favorites', 4, None, '$favorite.viewers'])
self.assertTableData("_grist_Tables_column", cols="subset", rows=(lambda r: r.id >= 25), data=[ self.assertTableData("_grist_Tables_column", cols="subset", rows=(lambda r: r.id >= 25), data=[
["id", "colId", "parentId", "displayCol", "formula"], ["id", "colId", "parentId", "displayCol", "formula"],
[25, "favorite", 2, 26, ""], [25, "favorite", 2, 26, ""],
@ -115,13 +118,14 @@ class TestUserActions(test_engine.EngineTestCase):
["id", "colRef", "displayCol"], ["id", "colRef", "displayCol"],
[1, 25, 27], [1, 25, 27],
[2, 25, 0], [2, 25, 0],
[3, 25, 28], [3, 25, 0],
[4, 25, 28],
]) ])
# Remove a field. # Remove a field.
# This should also remove the display column used by that field, since it is not used # This should also remove the display column used by that field, since it is not used
# by any other fields. # by any other fields.
self.apply_user_action(['RemoveRecord', '_grist_Views_section_field', 3]) self.apply_user_action(['RemoveRecord', '_grist_Views_section_field', 4])
self.assertTableData("_grist_Tables_column", cols="subset", rows=(lambda r: r.id >= 25), data=[ self.assertTableData("_grist_Tables_column", cols="subset", rows=(lambda r: r.id >= 25), data=[
["id", "colId", "parentId", "displayCol", "formula"], ["id", "colId", "parentId", "displayCol", "formula"],
[25, "favorite", 2, 26, ""], [25, "favorite", 2, 26, ""],
@ -132,6 +136,7 @@ class TestUserActions(test_engine.EngineTestCase):
["id", "colRef", "displayCol"], ["id", "colRef", "displayCol"],
[1, 25, 27], [1, 25, 27],
[2, 25, 0], [2, 25, 0],
[3, 25, 0],
]) ])
# Add a new column with a formula. # Add a new column with a formula.
@ -145,7 +150,7 @@ class TestUserActions(test_engine.EngineTestCase):
'parentId': 3, 'parentId': 3,
'colRef': 25 'colRef': 25
}]) }])
self.apply_user_action(['SetDisplayFormula', 'Favorites', 6, None, '$favorite.viewers']) self.apply_user_action(['SetDisplayFormula', 'Favorites', 8, None, '$favorite.viewers'])
self.assertTableData("_grist_Tables_column", cols="subset", rows=(lambda r: r.id >= 25), data=[ self.assertTableData("_grist_Tables_column", cols="subset", rows=(lambda r: r.id >= 25), data=[
["id", "colId", "parentId", "displayCol", "formula"], ["id", "colId", "parentId", "displayCol", "formula"],
[25, "favorite", 2, 26, ""], [25, "favorite", 2, 26, ""],
@ -158,17 +163,19 @@ class TestUserActions(test_engine.EngineTestCase):
["id", "colRef", "displayCol"], ["id", "colRef", "displayCol"],
[1, 25, 27], [1, 25, 27],
[2, 25, 0], [2, 25, 0],
[3, 28, 0], # fav_viewers field [3, 25, 0],
[4, 28, 0], # fav_viewers field [4, 28, 0], # fav_viewers field
[5, 28, 0], # fav_viewers field [5, 28, 0], # fav_viewers field
[6, 25, 29] # re-added field w/ display col [6, 28, 0], # fav_viewers field
[7, 28, 0], # re-added field w/ display col
[8, 25, 29], # fav_viewers field
]) ])
# Change the display formula for a field to be the same as the other field, then remove # Change the display formula for a field to be the same as the other field, then remove
# the field. # the field.
# The display column should not be removed since it is still in use. # The display column should not be removed since it is still in use.
self.apply_user_action(['SetDisplayFormula', 'Favorites', 6, None, '$favorite.network']) self.apply_user_action(['SetDisplayFormula', 'Favorites', 8, None, '$favorite.network'])
self.apply_user_action(['RemoveRecord', '_grist_Views_section_field', 6]) self.apply_user_action(['RemoveRecord', '_grist_Views_section_field', 8])
self.assertTableData("_grist_Tables_column", cols="subset", rows=(lambda r: r.id >= 25), data=[ self.assertTableData("_grist_Tables_column", cols="subset", rows=(lambda r: r.id >= 25), data=[
["id", "colId", "parentId", "displayCol", "formula"], ["id", "colId", "parentId", "displayCol", "formula"],
[25, "favorite", 2, 26, ""], [25, "favorite", 2, 26, ""],
@ -180,9 +187,11 @@ class TestUserActions(test_engine.EngineTestCase):
["id", "colRef", "displayCol"], ["id", "colRef", "displayCol"],
[1, 25, 27], [1, 25, 27],
[2, 25, 0], [2, 25, 0],
[3, 28, 0], [3, 25, 0],
[4, 28, 0], [4, 28, 0],
[5, 28, 0], [5, 28, 0],
[6, 28, 0],
[7, 28, 0],
]) ])
# Clear field display formula, then set it again. # Clear field display formula, then set it again.
@ -199,9 +208,11 @@ class TestUserActions(test_engine.EngineTestCase):
["id", "colRef", "displayCol"], ["id", "colRef", "displayCol"],
[1, 25, 0], [1, 25, 0],
[2, 25, 0], [2, 25, 0],
[3, 28, 0], [3, 25, 0],
[4, 28, 0], [4, 28, 0],
[5, 28, 0], [5, 28, 0],
[6, 28, 0],
[7, 28, 0],
]) ])
# Setting the display formula should add another display column. # Setting the display formula should add another display column.
self.apply_user_action(['SetDisplayFormula', 'Favorites', 1, None, '$favorite.viewers']) self.apply_user_action(['SetDisplayFormula', 'Favorites', 1, None, '$favorite.viewers'])
@ -216,9 +227,11 @@ class TestUserActions(test_engine.EngineTestCase):
["id", "colRef", "displayCol"], ["id", "colRef", "displayCol"],
[1, 25, 29], [1, 25, 29],
[2, 25, 0], [2, 25, 0],
[3, 28, 0], [3, 25, 0],
[4, 28, 0], [4, 28, 0],
[5, 28, 0], [5, 28, 0],
[6, 28, 0],
[7, 28, 0],
]) ])
# Change column display formula. # Change column display formula.
@ -235,9 +248,11 @@ class TestUserActions(test_engine.EngineTestCase):
["id", "colRef", "displayCol"], ["id", "colRef", "displayCol"],
[1, 25, 29], [1, 25, 29],
[2, 25, 0], [2, 25, 0],
[3, 28, 0], [3, 25, 0],
[4, 28, 0], [4, 28, 0],
[5, 28, 0], [5, 28, 0],
[6, 28, 0],
[7, 28, 0],
]) ])
# Remove column. # Remove column.
@ -249,9 +264,10 @@ class TestUserActions(test_engine.EngineTestCase):
]) ])
self.assertTableData("_grist_Views_section_field", cols="subset", data=[ self.assertTableData("_grist_Views_section_field", cols="subset", data=[
["id", "colRef", "displayCol"], ["id", "colRef", "displayCol"],
[3, 28, 0],
[4, 28, 0], [4, 28, 0],
[5, 28, 0], [5, 28, 0],
[6, 28, 0],
[7, 28, 0],
]) ])
@ -381,7 +397,7 @@ class TestUserActions(test_engine.EngineTestCase):
# pylint:disable=line-too-long # pylint:disable=line-too-long
self.assertOutActions(out_actions, { self.assertOutActions(out_actions, {
"stored": [ "stored": [
["BulkRemoveRecord", "_grist_Views_section_field", [2, 4]], ["BulkRemoveRecord", "_grist_Views_section_field", [2, 4, 6]],
["BulkRemoveRecord", "_grist_Tables_column", [26, 27]], ["BulkRemoveRecord", "_grist_Tables_column", [26, 27]],
["RemoveColumn", "People", "favorite"], ["RemoveColumn", "People", "favorite"],
["RemoveColumn", "People", "gristHelper_Display"], ["RemoveColumn", "People", "gristHelper_Display"],
@ -392,7 +408,7 @@ class TestUserActions(test_engine.EngineTestCase):
"undo": [ "undo": [
["BulkUpdateRecord", "People", [1, 2, 3], {"gristHelper_Display2": ["Netflix", "HBO", "NBC"]}], ["BulkUpdateRecord", "People", [1, 2, 3], {"gristHelper_Display2": ["Netflix", "HBO", "NBC"]}],
["BulkUpdateRecord", "People", [1, 2, 3], {"gristHelper_Display": ["Narcos", "Game of Thrones", "Today"]}], ["BulkUpdateRecord", "People", [1, 2, 3], {"gristHelper_Display": ["Narcos", "Game of Thrones", "Today"]}],
["BulkAddRecord", "_grist_Views_section_field", [2, 4], {"colRef": [26, 26], "displayCol": [28, 0], "parentId": [1, 2], "parentPos": [2.0, 4.0]}], ["BulkAddRecord", "_grist_Views_section_field", [2, 4, 6], {"colRef": [26, 26, 26], "displayCol": [28, 0, 0], "parentId": [1, 2, 3], "parentPos": [2.0, 4.0, 6.0]}],
["BulkAddRecord", "_grist_Tables_column", [26, 27], {"colId": ["favorite", "gristHelper_Display"], "displayCol": [27, 0], "formula": ["", "$favorite.show"], "isFormula": [False, True], "label": ["favorite", "gristHelper_Display"], "parentId": [2, 2], "parentPos": [6.0, 7.0], "type": ["Ref:Television", "Any"], "widgetOptions": ["\"{\"alignment\":\"center\",\"visibleCol\":\"show\"}\"", ""]}], ["BulkAddRecord", "_grist_Tables_column", [26, 27], {"colId": ["favorite", "gristHelper_Display"], "displayCol": [27, 0], "formula": ["", "$favorite.show"], "isFormula": [False, True], "label": ["favorite", "gristHelper_Display"], "parentId": [2, 2], "parentPos": [6.0, 7.0], "type": ["Ref:Television", "Any"], "widgetOptions": ["\"{\"alignment\":\"center\",\"visibleCol\":\"show\"}\"", ""]}],
["BulkUpdateRecord", "People", [1, 2, 3], {"favorite": [12, 11, 13]}], ["BulkUpdateRecord", "People", [1, 2, 3], {"favorite": [12, 11, 13]}],
["AddColumn", "People", "favorite", {"formula": "", "isFormula": False, "type": "Ref:Television"}], ["AddColumn", "People", "favorite", {"formula": "", "isFormula": False, "type": "Ref:Television"}],

@ -141,35 +141,42 @@ class TestDocModel(test_engine.EngineTestCase):
self.assertPartialData('_grist_Views_section', ["id", "parentId", "tableRef"], [ self.assertPartialData('_grist_Views_section', ["id", "parentId", "tableRef"], [
[1, 1, 4], [1, 1, 4],
[2, 0, 4], [2, 0, 4],
[3, 2, 5], [3, 0, 4],
[4, 0, 5], [4, 2, 5],
[5, 1, 4], [5, 0, 5],
[6, 1, 5], [6, 0, 5],
[7, 1, 4],
[8, 1, 5],
]) ])
self.assertPartialData('_grist_Views_section_field', ["id", "parentId", "parentPos"], [ self.assertPartialData('_grist_Views_section_field', ["id", "parentId", "parentPos"], [
[1, 1, 1.0], [1, 1, 1.0],
[2, 1, 2.0], [2, 1, 2.0],
[3, 2, 3.0], [3, 2, 3.0],
[4, 2, 4.0], [4, 2, 4.0],
[5, 3, 5.0], [5, 3, 5.0],
[6, 3, 6.0], [6, 3, 6.0],
[7, 4, 7.0], [7, 4, 7.0],
[8, 4, 8.0], [8, 4, 8.0],
[9, 5, 9.0], [9, 5, 9.0],
[10, 5, 10.0], [10, 5, 10.0],
[11, 6, 11.0], [11, 6, 11.0],
[12, 6, 12.0], [12, 6, 12.0],
[13, 7, 13.0],
[14, 7, 14.0],
[15, 8, 15.0],
[16, 8, 16.0],
]) ])
table = self.engine.docmodel.tables.lookupOne(tableId='Test2') table = self.engine.docmodel.tables.lookupOne(tableId='Test2')
self.assertRecordSet(table.viewSections, [1, 2, 5]) self.assertRecordSet(table.viewSections, [1, 2, 3, 7])
self.assertRecordSet(list(table.viewSections)[0].fields, [1, 2]) self.assertRecordSet(list(table.viewSections)[0].fields, [1, 2])
self.assertRecordSet(list(table.viewSections)[2].fields, [9, 10]) self.assertRecordSet(list(table.viewSections)[3].fields, [13, 14])
view = self.engine.docmodel.views.lookupOne(id=1) view = self.engine.docmodel.views.lookupOne(id=1)
self.assertRecordSet(view.viewSections, [1, 5, 6]) self.assertRecordSet(view.viewSections, [1, 7, 8])
self.engine.docmodel.remove(set(table.viewSections) - {table.rawViewSectionRef}) self.engine.docmodel.remove(set(table.viewSections) -
self.assertRecordSet(view.viewSections, [6]) {table.rawViewSectionRef, table.recordCardViewSectionRef})
self.assertRecordSet(view.viewSections, [8])
def test_modifications(self): def test_modifications(self):

@ -54,10 +54,13 @@ class TestImportActions(test_engine.EngineTestCase):
self.assertPartialData("_grist_Views_section", ["id", "tableRef", 'fields'], [ self.assertPartialData("_grist_Views_section", ["id", "tableRef", 'fields'], [
[1, 1, [1, 2, 3]], # section for "Source" table [1, 1, [1, 2, 3]], # section for "Source" table
[2, 1, [4, 5, 6]], # section for "Source" table [2, 1, [4, 5, 6]], # section for "Source" table
[3, 2, [7, 8]], # section for "Destination1" table [3, 1, [7, 8, 9]], # section for "Source" table
[4, 2, [9, 10]], # section for "Destination1" table [4, 2, [10, 11]], # section for "Destination1" table
[5, 3, [11]], # section for "Destination2" table [5, 2, [12, 13]], # section for "Destination1" table
[6, 3, [12]], # section for "Destination2" table [6, 2, [14, 15]], # section for "Destination1" table
[7, 3, [16]], # section for "Destination2" table
[8, 3, [17]], # section for "Destination2" table
[9, 3, [18]], # section for "Destination2" table
]) ])
def test_transform(self): def test_transform(self):
@ -89,11 +92,14 @@ class TestImportActions(test_engine.EngineTestCase):
self.assertPartialData("_grist_Views_section", ["id", "tableRef", 'fields'], [ self.assertPartialData("_grist_Views_section", ["id", "tableRef", 'fields'], [
[1, 1, [1, 2, 3]], [1, 1, [1, 2, 3]],
[2, 1, [4, 5, 6]], [2, 1, [4, 5, 6]],
[3, 2, [7, 8]], [3, 1, [7, 8, 9]],
[4, 2, [9, 10]], [4, 2, [10, 11]],
[5, 3, [11]], [5, 2, [12, 13]],
[6, 3, [12]], [6, 2, [14, 15]],
[7, 1, [13, 14]], # new section for transform preview [7, 3, [16]],
[8, 3, [17]],
[9, 3, [18]],
[10, 1, [19, 20]], # new section for transform preview
]) ])
# Apply useraction again to verify that old columns and sections are removing # Apply useraction again to verify that old columns and sections are removing
@ -117,17 +123,20 @@ class TestImportActions(test_engine.EngineTestCase):
[2, "Alison", "Boston", 7003, "", 2.0], [2, "Alison", "Boston", 7003, "", 2.0],
]) ])
self.assertPartialData("_grist_Views_section", ["id", "tableRef", 'fields'], [ self.assertPartialData("_grist_Views_section", ["id", "tableRef", 'fields'], [
[1, 1, [1, 2, 3]], [1, 1, [1, 2, 3]],
[2, 1, [4, 5, 6]], [2, 1, [4, 5, 6]],
[3, 2, [7, 8]], [3, 1, [7, 8, 9]],
[4, 2, [9, 10]], [4, 2, [10, 11]],
[5, 3, [11]], [5, 2, [12, 13]],
[6, 3, [12]], [6, 2, [14, 15]],
[7, 1, [13]], # new section for transform preview [7, 3, [16]],
[8, 3, [17]],
[9, 3, [18]],
[10, 1, [19]], # new section for transform preview
]) ])
def test_regenereate_importer_view(self): def test_regenerate_importer_view(self):
# Generate without a destination table, and then with one. Ensure that we don't omit the # Generate without a destination table, and then with one. Ensure that we don't omit the
# actions needed to populate the table in the second call. # actions needed to populate the table in the second call.
self.init_state() self.init_state()
@ -135,8 +144,8 @@ class TestImportActions(test_engine.EngineTestCase):
out_actions = self.apply_user_action(['GenImporterView', 'Source', 'Destination1', None, {}]) out_actions = self.apply_user_action(['GenImporterView', 'Source', 'Destination1', None, {}])
self.assertPartialOutActions(out_actions, { self.assertPartialOutActions(out_actions, {
"stored": [ "stored": [
["BulkRemoveRecord", "_grist_Views_section_field", [13, 14, 15]], ["BulkRemoveRecord", "_grist_Views_section_field", [19, 20, 21]],
["RemoveRecord", "_grist_Views_section", 7], ["RemoveRecord", "_grist_Views_section", 10],
["BulkRemoveRecord", "_grist_Tables_column", [10, 11, 12]], ["BulkRemoveRecord", "_grist_Tables_column", [10, 11, 12]],
["RemoveColumn", "Source", "gristHelper_Import_Name"], ["RemoveColumn", "Source", "gristHelper_Import_Name"],
["RemoveColumn", "Source", "gristHelper_Import_City"], ["RemoveColumn", "Source", "gristHelper_Import_City"],
@ -145,8 +154,8 @@ class TestImportActions(test_engine.EngineTestCase):
["AddRecord", "_grist_Tables_column", 10, {"colId": "gristHelper_Import_Name", "formula": "$Name", "isFormula": True, "label": "Name", "parentId": 1, "parentPos": 10.0, "type": "Text", "widgetOptions": ""}], ["AddRecord", "_grist_Tables_column", 10, {"colId": "gristHelper_Import_Name", "formula": "$Name", "isFormula": True, "label": "Name", "parentId": 1, "parentPos": 10.0, "type": "Text", "widgetOptions": ""}],
["AddColumn", "Source", "gristHelper_Import_City", {"formula": "$City", "isFormula": True, "type": "Text"}], ["AddColumn", "Source", "gristHelper_Import_City", {"formula": "$City", "isFormula": True, "type": "Text"}],
["AddRecord", "_grist_Tables_column", 11, {"colId": "gristHelper_Import_City", "formula": "$City", "isFormula": True, "label": "City", "parentId": 1, "parentPos": 11.0, "type": "Text", "widgetOptions": ""}], ["AddRecord", "_grist_Tables_column", 11, {"colId": "gristHelper_Import_City", "formula": "$City", "isFormula": True, "label": "City", "parentId": 1, "parentPos": 11.0, "type": "Text", "widgetOptions": ""}],
["AddRecord", "_grist_Views_section", 7, {"borderWidth": 1, "defaultWidth": 100, "parentKey": "record", "sortColRefs": "[]", "tableRef": 1}], ["AddRecord", "_grist_Views_section", 10, {"borderWidth": 1, "defaultWidth": 100, "parentKey": "record", "sortColRefs": "[]", "tableRef": 1}],
["BulkAddRecord", "_grist_Views_section_field", [13, 14], {"colRef": [10, 11], "parentId": [7, 7], "parentPos": [13.0, 14.0]}], ["BulkAddRecord", "_grist_Views_section_field", [19, 20], {"colRef": [10, 11], "parentId": [10, 10], "parentPos": [19.0, 20.0]}],
# The actions to populate the removed and re-added columns should be there. # The actions to populate the removed and re-added columns should be there.
["BulkUpdateRecord", "Source", [1, 2], {"gristHelper_Import_City": ["New York", "Boston"]}], ["BulkUpdateRecord", "Source", [1, 2], {"gristHelper_Import_City": ["New York", "Boston"]}],
["BulkUpdateRecord", "Source", [1, 2], {"gristHelper_Import_Name": ["John", "Alison"]}], ["BulkUpdateRecord", "Source", [1, 2], {"gristHelper_Import_Name": ["John", "Alison"]}],
@ -181,11 +190,14 @@ class TestImportActions(test_engine.EngineTestCase):
[2, "Alison", "Boston", 7003, "Alison", "Boston", 7003, 2.0], [2, "Alison", "Boston", 7003, "Alison", "Boston", 7003, 2.0],
]) ])
self.assertPartialData("_grist_Views_section", ["id", "tableRef", 'fields'], [ self.assertPartialData("_grist_Views_section", ["id", "tableRef", 'fields'], [
[1, 1, [1, 2, 3]], [1, 1, [1, 2, 3]],
[2, 1, [4, 5, 6]], [2, 1, [4, 5, 6]],
[3, 2, [7, 8]], [3, 1, [7, 8, 9]],
[4, 2, [9, 10]], [4, 2, [10, 11]],
[5, 3, [11]], [5, 2, [12, 13]],
[6, 3, [12]], [6, 2, [14, 15]],
[7, 1, [13, 14, 15]], # new section for transform preview [7, 3, [16]],
[8, 3, [17]],
[9, 3, [18]],
[10, 1, [19, 20, 21]], # new section for transform preview
]) ])

@ -88,27 +88,27 @@ class TestTableActions(test_engine.EngineTestCase):
]), ]),
]), ]),
View(2, sections=[ View(2, sections=[
Section(3, parentKey="record", tableRef=2, fields=[ Section(4, parentKey="record", tableRef=2, fields=[
Field(7, colRef=6), Field(10, colRef=6),
Field(8, colRef=7), Field(11, colRef=7),
Field(9, colRef=8), Field(12, colRef=8),
]), ]),
]), ]),
View(3, sections=[ View(3, sections=[
Section(5, parentKey="record", tableRef=1, fields=[ Section(7, parentKey="record", tableRef=1, fields=[
Field(13, colRef=2), Field(19, colRef=2),
Field(14, colRef=3), Field(20, colRef=3),
Field(15, colRef=4), Field(21, colRef=4),
]), ]),
Section(7, parentKey="record", tableRef=3, fields=[ Section(9, parentKey="record", tableRef=3, fields=[
Field(19, colRef=9), Field(25, colRef=9),
Field(20, colRef=11), Field(26, colRef=11),
Field(21, colRef=12), Field(27, colRef=12),
]), ]),
Section(8, parentKey="record", tableRef=2, fields=[ Section(10, parentKey="record", tableRef=2, fields=[
Field(22, colRef=6), Field(28, colRef=6),
Field(23, colRef=7), Field(29, colRef=7),
Field(24, colRef=8), Field(30, colRef=8),
]), ]),
]), ]),
]) ])
@ -295,17 +295,17 @@ class TestTableActions(test_engine.EngineTestCase):
]) ])
self.assertViews([ self.assertViews([
View(2, sections=[ View(2, sections=[
Section(3, parentKey="record", tableRef=2, fields=[ Section(4, parentKey="record", tableRef=2, fields=[
Field(7, colRef=6), Field(10, colRef=6),
Field(8, colRef=7), Field(11, colRef=7),
Field(9, colRef=8), Field(12, colRef=8),
]), ]),
]), ]),
View(3, sections=[ View(3, sections=[
Section(8, parentKey="record", tableRef=2, fields=[ Section(10, parentKey="record", tableRef=2, fields=[
Field(22, colRef=6), Field(28, colRef=6),
Field(23, colRef=7), Field(29, colRef=7),
Field(24, colRef=8), Field(30, colRef=8),
]), ]),
]), ]),
]) ])

@ -58,6 +58,7 @@ class TestUserActions(test_engine.EngineTestCase):
self.assertPartialData("_grist_Views_section_field", ["id", "colRef", "widgetOptions"], [ self.assertPartialData("_grist_Views_section_field", ["id", "colRef", "widgetOptions"], [
[1, 23, ""], [1, 23, ""],
[2, 23, ""], [2, 23, ""],
[3, 23, ""],
]) ])
self.assertPartialData("Schools", ["id", "city"], [ self.assertPartialData("Schools", ["id", "city"], [
[1, "New York" ], [1, "New York" ],
@ -78,8 +79,11 @@ class TestUserActions(test_engine.EngineTestCase):
'grist_Transform', 'formula': 'return $city', 'label': 'grist_Transform', 'grist_Transform', 'formula': 'return $city', 'label': 'grist_Transform',
'type': 'Text' 'type': 'Text'
}], }],
["AddRecord", "_grist_Views_section_field", 3, { ["AddRecord", "_grist_Views_section_field", 4, {
"colRef": 24, "parentId": 2, "parentPos": 3.0 "colRef": 24, "parentId": 2, "parentPos": 4.0
}],
["AddRecord", "_grist_Views_section_field", 5, {
"colRef": 24, "parentId": 3, "parentPos": 5.0
}], }],
["BulkUpdateRecord", "Schools", [1, 2, 3], ["BulkUpdateRecord", "Schools", [1, 2, 3],
{"grist_Transform": ["New York", "Colombia", "New York"]}], {"grist_Transform": ["New York", "Colombia", "New York"]}],
@ -122,7 +126,7 @@ class TestUserActions(test_engine.EngineTestCase):
out_actions = self.remove_column('Schools', 'grist_Transform') out_actions = self.remove_column('Schools', 'grist_Transform')
self.assertPartialOutActions(out_actions, { "stored": [ self.assertPartialOutActions(out_actions, { "stored": [
["RemoveRecord", "_grist_Views_section_field", 3], ["BulkRemoveRecord", "_grist_Views_section_field", [4, 5]],
['RemoveRecord', '_grist_Tables_column', 24], ['RemoveRecord', '_grist_Tables_column', 24],
['RemoveColumn', 'Schools', 'grist_Transform'], ['RemoveColumn', 'Schools', 'grist_Transform'],
]}) ]})
@ -205,10 +209,10 @@ class TestUserActions(test_engine.EngineTestCase):
Column(25, "C", "Any", isFormula=True, formula="", summarySourceCol=0), Column(25, "C", "Any", isFormula=True, formula="", summarySourceCol=0),
]) ])
new_view = View(1, sections=[ new_view = View(1, sections=[
Section(2, parentKey="record", tableRef=2, fields=[ Section(3, parentKey="record", tableRef=2, fields=[
Field(4, colRef=23), Field(7, colRef=23),
Field(5, colRef=24), Field(8, colRef=24),
Field(6, colRef=25), Field(9, colRef=25),
]) ])
]) ])
self.assertTables([self.starting_table, new_table]) self.assertTables([self.starting_table, new_table])
@ -223,10 +227,10 @@ class TestUserActions(test_engine.EngineTestCase):
Column(29, "C", "Any", isFormula=True, formula="", summarySourceCol=0), Column(29, "C", "Any", isFormula=True, formula="", summarySourceCol=0),
]) ])
new_view.sections.append( new_view.sections.append(
Section(4, parentKey="record", tableRef=3, fields=[ Section(6, parentKey="record", tableRef=3, fields=[
Field(10, colRef=27), Field(16, colRef=27),
Field(11, colRef=28), Field(17, colRef=28),
Field(12, colRef=29), Field(18, colRef=29),
]) ])
) )
# Check that we have a new table, only the new view; and a new section. # Check that we have a new table, only the new view; and a new section.
@ -256,8 +260,8 @@ class TestUserActions(test_engine.EngineTestCase):
Column(35, "count", "Int", isFormula=True, formula="len($group)", summarySourceCol=0), Column(35, "count", "Int", isFormula=True, formula="len($group)", summarySourceCol=0),
]) ])
self.assertTables([self.starting_table, new_table, new_table2, new_table3, summary_table]) self.assertTables([self.starting_table, new_table, new_table2, new_table3, summary_table])
new_view.sections.append(Section(7, parentKey="record", tableRef=5, fields=[ new_view.sections.append(Section(10, parentKey="record", tableRef=5, fields=[
Field(17, colRef=35) Field(26, colRef=35)
])) ]))
self.assertViews([new_view]) self.assertViews([new_view])
@ -311,26 +315,26 @@ class TestUserActions(test_engine.EngineTestCase):
]), ]),
]), ]),
View(2, sections=[ View(2, sections=[
Section(3, parentKey="detail", tableRef=1, fields=[ Section(4, parentKey="detail", tableRef=1, fields=[
Field(7, colRef=2), Field(10, colRef=2),
Field(8, colRef=3), Field(11, colRef=3),
Field(9, colRef=4), Field(12, colRef=4),
]), ]),
Section(5, parentKey="record", tableRef=2, fields=[ Section(6, parentKey="record", tableRef=2, fields=[
Field(13, colRef=5), Field(16, colRef=5),
Field(14, colRef=7), Field(17, colRef=7),
Field(15, colRef=8), Field(18, colRef=8),
]), ]),
Section(8, parentKey='record', tableRef=3, fields=[ Section(10, parentKey='record', tableRef=3, fields=[
Field(21, colRef=10), Field(27, colRef=10),
Field(22, colRef=11), Field(28, colRef=11),
Field(23, colRef=12), Field(29, colRef=12),
]), ]),
]), ]),
View(3, sections=[ View(3, sections=[
Section(6, parentKey="chart", tableRef=1, fields=[ Section(7, parentKey="chart", tableRef=1, fields=[
Field(16, colRef=2), Field(19, colRef=2),
Field(17, colRef=3), Field(20, colRef=3),
]), ]),
]) ])
]) ])
@ -468,10 +472,10 @@ class TestUserActions(test_engine.EngineTestCase):
{'title': 'Z'}]) {'title': 'Z'}])
self.assertTableData('_grist_Tables', cols="subset", data=[ self.assertTableData('_grist_Tables', cols="subset", data=[
['id', 'tableId', 'primaryViewId', 'rawViewSectionRef'], ['id', 'tableId', 'primaryViewId', 'rawViewSectionRef', 'recordCardViewSectionRef'],
[1, 'Z', 1, 2], [1, 'Z', 1, 2, 3],
[2, 'Z_summary_state', 0, 4], [2, 'Z_summary_state', 0, 5, 0],
[3, 'Table1', 0, 7], [3, 'Table1', 0, 8, 9],
]) ])
self.assertTableData('_grist_Views', cols="subset", data=[ self.assertTableData('_grist_Views', cols="subset", data=[
['id', 'name'], ['id', 'name'],
@ -485,11 +489,11 @@ class TestUserActions(test_engine.EngineTestCase):
{'id': 'city', 'type': 'Text'}, {'id': 'city', 'type': 'Text'},
]]) ]])
self.assertTableData('_grist_Tables', cols="subset", data=[ self.assertTableData('_grist_Tables', cols="subset", data=[
['id', 'tableId', 'primaryViewId', 'rawViewSectionRef'], ['id', 'tableId', 'primaryViewId', 'rawViewSectionRef', 'recordCardViewSectionRef'],
[1, 'Z', 1, 2], [1, 'Z', 1, 2, 3],
[2, 'Z_summary_state', 0, 4], [2, 'Z_summary_state', 0, 5, 0],
[3, 'Table1', 0, 7], [3, 'Table1', 0, 8, 9],
[4, 'Stations', 4, 10], [4, 'Stations', 4, 12, 13],
]) ])
self.assertTableData('_grist_Views', cols="subset", data=[ self.assertTableData('_grist_Views', cols="subset", data=[
['id', 'name'], ['id', 'name'],
@ -542,32 +546,32 @@ class TestUserActions(test_engine.EngineTestCase):
]), ]),
]), ]),
View(2, sections=[ View(2, sections=[
Section(3, parentKey="detail", tableRef=1, fields=[ Section(4, parentKey="detail", tableRef=1, fields=[
Field(7, colRef=2), Field(10, colRef=2),
Field(8, colRef=3), Field(11, colRef=3),
Field(9, colRef=4), Field(12, colRef=4),
]), ]),
Section(5, parentKey="record", tableRef=2, fields=[ Section(6, parentKey="record", tableRef=2, fields=[
Field(13, colRef=5), Field(16, colRef=5),
Field(14, colRef=7), Field(17, colRef=7),
Field(15, colRef=8), Field(18, colRef=8),
]), ]),
Section(8, parentKey='record', tableRef=3, fields=[ Section(10, parentKey='record', tableRef=3, fields=[
Field(21, colRef=10), Field(27, colRef=10),
Field(22, colRef=11), Field(28, colRef=11),
Field(23, colRef=12), Field(29, colRef=12),
]), ]),
]), ]),
View(3, sections=[ View(3, sections=[
Section(6, parentKey="chart", tableRef=1, fields=[ Section(7, parentKey="chart", tableRef=1, fields=[
Field(16, colRef=2), Field(19, colRef=2),
Field(17, colRef=3), Field(20, colRef=3),
]), ]),
]) ])
]) ])
# Remove a couple of sections. Ensure their fields get removed. # Remove a couple of sections. Ensure their fields get removed.
self.apply_user_action(['BulkRemoveRecord', '_grist_Views_section', [5, 8]]) self.apply_user_action(['BulkRemoveRecord', '_grist_Views_section', [6, 10]])
self.assertViews([ self.assertViews([
View(1, sections=[ View(1, sections=[
@ -578,16 +582,16 @@ class TestUserActions(test_engine.EngineTestCase):
]), ]),
]), ]),
View(2, sections=[ View(2, sections=[
Section(3, parentKey="detail", tableRef=1, fields=[ Section(4, parentKey="detail", tableRef=1, fields=[
Field(7, colRef=2), Field(10, colRef=2),
Field(8, colRef=3), Field(11, colRef=3),
Field(9, colRef=4), Field(12, colRef=4),
]) ])
]), ]),
View(3, sections=[ View(3, sections=[
Section(6, parentKey="chart", tableRef=1, fields=[ Section(7, parentKey="chart", tableRef=1, fields=[
Field(16, colRef=2), Field(19, colRef=2),
Field(17, colRef=3), Field(20, colRef=3),
]), ]),
]) ])
]) ])
@ -613,8 +617,8 @@ class TestUserActions(test_engine.EngineTestCase):
self.assertEqual(count_calls[0], 0) self.assertEqual(count_calls[0], 0)
# Do a schema action to ensure it gets called: this causes a table rename. # 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 # 8 is id of raw view section for the Table1 table
self.apply_user_action(['UpdateRecord', '_grist_Views_section', 7, {'title': 'C'}]) self.apply_user_action(['UpdateRecord', '_grist_Views_section', 8, {'title': 'C'}])
self.assertEqual(count_calls[0], 1) self.assertEqual(count_calls[0], 1)
self.assertTableData('_grist_Tables', cols="subset", data=[ self.assertTableData('_grist_Tables', cols="subset", data=[
@ -1403,6 +1407,7 @@ class TestUserActions(test_engine.EngineTestCase):
["id", "parentId", "tableRef"], ["id", "parentId", "tableRef"],
[1, 1, 2], [1, 1, 2],
[2, 0, 2], # the raw view section [2, 0, 2], # the raw view section
[3, 0, 2], # the record card view section
]) ])
self.assertTableData('_grist_Views_section_field', cols="subset", data=[ self.assertTableData('_grist_Views_section_field', cols="subset", data=[
["id", "parentId"], ["id", "parentId"],
@ -1414,6 +1419,11 @@ class TestUserActions(test_engine.EngineTestCase):
[4, 2], [4, 2],
[5, 2], [5, 2],
[6, 2], [6, 2],
# the record card view section
[7, 3],
[8, 3],
[9, 3],
]) ])
# Test that the records cannot be removed by normal user actions # Test that the records cannot be removed by normal user actions
@ -1433,6 +1443,7 @@ class TestUserActions(test_engine.EngineTestCase):
["id", "parentId", "tableRef"], ["id", "parentId", "tableRef"],
[1, 1, 2], [1, 1, 2],
[2, 0, 2], # the raw view section [2, 0, 2], # the raw view section
[3, 0, 2], # the record card view section
]) ])
self.assertTableData('_grist_Views_section_field', cols="subset", data=[ self.assertTableData('_grist_Views_section_field', cols="subset", data=[
["id", "parentId"], ["id", "parentId"],
@ -1444,6 +1455,45 @@ class TestUserActions(test_engine.EngineTestCase):
[4, 2], [4, 2],
[5, 2], [5, 2],
[6, 2], [6, 2],
# the record card view section
[7, 3],
[8, 3],
[9, 3],
])
def test_record_card_view_section_restrictions(self):
self.load_sample(self.sample)
self.apply_user_action(["AddEmptyTable", None])
# Check that record card view sections cannot be removed by normal user actions.
with self.assertRaisesRegex(ValueError, "Cannot remove record card view section$"):
self.apply_user_action(["RemoveRecord", '_grist_Views_section', 3])
# Check that most of their column values can't be changed.
with self.assertRaisesRegex(ValueError, "Cannot modify record card view section$"):
self.apply_user_action(["UpdateRecord", '_grist_Views_section', 3, {"parentId": 1}])
with self.assertRaisesRegex(ValueError, "Cannot modify record card view section fields$"):
self.apply_user_action(["UpdateRecord", '_grist_Views_section_field', 9, {"parentId": 1}])
# Make sure nothing got removed or updated.
self.assertTableData('_grist_Views_section', cols="subset", data=[
["id", "parentId", "tableRef"],
[1, 1, 2],
[2, 0, 2],
[3, 0, 2],
])
self.assertTableData('_grist_Views_section_field', cols="subset", data=[
["id", "parentId"],
[1, 1],
[2, 1],
[3, 1],
[4, 2],
[5, 2],
[6, 2],
[7, 3],
[8, 3],
[9, 3],
]) ])
def test_update_current_time(self): def test_update_current_time(self):

@ -918,7 +918,12 @@
// Raw data widget // Raw data widget
["AddRecord", "_grist_Views_section", 2, {"borderWidth": 1, "defaultWidth": 100, "parentKey": "record", "tableRef": 4, "title": ""}], ["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}], ["AddRecord", "_grist_Views_section_field", 2, {"colRef": 34, "parentId": 2, "parentPos": 2.0}],
["UpdateRecord", "_grist_Tables", 4, {"primaryViewId": 1, "rawViewSectionRef": 2}],
// Record card widget
["AddRecord", "_grist_Views_section", 3, {"borderWidth": 1, "defaultWidth": 100, "parentKey": "single", "tableRef": 4, "title": ""}],
["AddRecord", "_grist_Views_section_field", 3, {"colRef": 34, "parentId": 3, "parentPos": 3.0}],
["UpdateRecord", "_grist_Tables", 4, {"primaryViewId": 1, "rawViewSectionRef": 2, "recordCardViewSectionRef": 3}],
// Actions generated from AddColumn. // Actions generated from AddColumn.
["AddColumn", "Bar", "world", ["AddColumn", "Bar", "world",
@ -927,9 +932,10 @@
{"colId": "world", "parentPos": 13.0, {"colId": "world", "parentPos": 13.0,
"formula": "rec.hello.upper()", "parentId": 4, "type": "Text", "formula": "rec.hello.upper()", "parentId": 4, "type": "Text",
"isFormula": true, "label": "world", "widgetOptions": ""}], "isFormula": true, "label": "world", "widgetOptions": ""}],
["AddRecord", "_grist_Views_section_field", 3, {"colRef": 35, "parentId": 2, "parentPos": 3.0}] ["AddRecord", "_grist_Views_section_field", 4, {"colRef": 35, "parentId": 2, "parentPos": 4.0}],
["AddRecord", "_grist_Views_section_field", 5, {"colRef": 35, "parentId": 3, "parentPos": 5.0}]
], ],
"direct": [true, true, true, true, "direct": [true, true, true, true, true, true, true,
true, true, true, true, true, true, true, true, true, true, true, true, true, true,
true, true, true], true, true, true],
"undo": [ "undo": [
@ -943,10 +949,13 @@
["RemoveRecord", "_grist_Views_section_field", 1], ["RemoveRecord", "_grist_Views_section_field", 1],
["RemoveRecord", "_grist_Views_section", 2], ["RemoveRecord", "_grist_Views_section", 2],
["RemoveRecord", "_grist_Views_section_field", 2], ["RemoveRecord", "_grist_Views_section_field", 2],
["UpdateRecord", "_grist_Tables", 4, {"primaryViewId": 0, "rawViewSectionRef": 0}], ["RemoveRecord", "_grist_Views_section", 3],
["RemoveRecord", "_grist_Views_section_field", 3],
["UpdateRecord", "_grist_Tables", 4, {"primaryViewId": 0, "rawViewSectionRef": 0, "recordCardViewSectionRef": 0}],
["RemoveColumn", "Bar", "world"], ["RemoveColumn", "Bar", "world"],
["RemoveRecord", "_grist_Tables_column", 35], ["RemoveRecord", "_grist_Tables_column", 35],
["RemoveRecord", "_grist_Views_section_field", 3] ["RemoveRecord", "_grist_Views_section_field", 4],
["RemoveRecord", "_grist_Views_section_field", 5]
], ],
"retValue": [ "retValue": [
{ {
@ -1257,15 +1266,16 @@
{"parentId": [1,1], "colRef": [31,32], "parentPos": [1.0,2.0]}], {"parentId": [1,1], "colRef": [31,32], "parentPos": [1.0,2.0]}],
["AddRecord", "_grist_Views_section", 2, {"borderWidth": 1, "defaultWidth": 100, "parentKey": "record", "tableRef": 4, "title": ""}], ["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]}], ["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}], ["AddRecord", "_grist_Views_section", 3, {"borderWidth": 1, "defaultWidth": 100, "parentKey": "single", "tableRef": 4, "title": ""}],
["BulkRemoveRecord", "_grist_Views_section_field", [1, 3]], ["BulkAddRecord", "_grist_Views_section_field", [5, 6], {"colRef": [31, 32], "parentId": [3, 3], "parentPos": [5.0, 6.0]}],
["UpdateRecord", "_grist_Tables", 4, {"primaryViewId": 1, "rawViewSectionRef": 2, "recordCardViewSectionRef": 3}],
["BulkRemoveRecord", "_grist_Views_section_field", [1, 3, 5]],
["RemoveRecord", "_grist_Tables_column", 31], ["RemoveRecord", "_grist_Tables_column", 31],
["RemoveColumn", "ViewTest", "hello"] ["RemoveColumn", "ViewTest", "hello"]
], ],
"direct": [true, true, true, true, true, true, true, true, true, "direct": [true, true, true, true, true, true, true, true, true,
true, true, true, true, true, true, true, true, true],
true, true, true],
"undo": [ "undo": [
["RemoveTable", "ViewTest"], ["RemoveTable", "ViewTest"],
["RemoveRecord", "_grist_Tables", 4], ["RemoveRecord", "_grist_Tables", 4],
@ -1277,8 +1287,11 @@
["BulkRemoveRecord", "_grist_Views_section_field", [1,2]], ["BulkRemoveRecord", "_grist_Views_section_field", [1,2]],
["RemoveRecord", "_grist_Views_section", 2], ["RemoveRecord", "_grist_Views_section", 2],
["BulkRemoveRecord", "_grist_Views_section_field", [3, 4]], ["BulkRemoveRecord", "_grist_Views_section_field", [3, 4]],
["UpdateRecord", "_grist_Tables", 4, {"primaryViewId": 0, "rawViewSectionRef": 0}], ["RemoveRecord", "_grist_Views_section", 3],
["BulkAddRecord", "_grist_Views_section_field", [1, 3], {"colRef": [31, 31], "parentId": [1, 2], "parentPos": [1.0, 3.0]}], ["BulkRemoveRecord", "_grist_Views_section_field", [5, 6]],
["UpdateRecord", "_grist_Tables", 4, {"primaryViewId": 0, "rawViewSectionRef": 0, "recordCardViewSectionRef": 0}],
["BulkAddRecord", "_grist_Views_section_field", [1, 3, 5],
{"colRef": [31, 31, 31], "parentId": [1, 2, 3], "parentPos": [1.0, 3.0, 5.0]}],
["AddRecord", "_grist_Tables_column", 31, ["AddRecord", "_grist_Tables_column", 31,
{"colId": "hello", "parentPos": 9.0, {"colId": "hello", "parentPos": 9.0,
"parentId": 4, "type": "Text" "parentId": 4, "type": "Text"
@ -2199,7 +2212,8 @@
{"tableRef": 4, "defaultWidth": 100, "borderWidth": 1, {"tableRef": 4, "defaultWidth": 100, "borderWidth": 1,
"parentId": 1, "parentKey": "record", "sortColRefs": "[]", "title": ""}], "parentId": 1, "parentKey": "record", "sortColRefs": "[]", "title": ""}],
["AddRecord", "_grist_Views_section", 2, {"borderWidth": 1, "defaultWidth": 100, "parentKey": "record", "tableRef": 4, "title": ""}], ["AddRecord", "_grist_Views_section", 2, {"borderWidth": 1, "defaultWidth": 100, "parentKey": "record", "tableRef": 4, "title": ""}],
["UpdateRecord", "_grist_Tables", 4, {"primaryViewId": 1, "rawViewSectionRef": 2}], ["AddRecord", "_grist_Views_section", 3, {"borderWidth": 1, "defaultWidth": 100, "parentKey": "single", "tableRef": 4, "title": ""}],
["UpdateRecord", "_grist_Tables", 4, {"primaryViewId": 1, "rawViewSectionRef": 2, "recordCardViewSectionRef": 3}],
["AddTable", "Bar", [ ["AddTable", "Bar", [
{"id": "manualSort", "formula": "", "isFormula": false, "type": "ManualSortPos"}, {"id": "manualSort", "formula": "", "isFormula": false, "type": "ManualSortPos"},
{"isFormula": false, "formula": "", "type": "Text", "id": "hello"}, {"isFormula": false, "formula": "", "type": "Text", "id": "hello"},
@ -2223,21 +2237,23 @@
{"type": "raw_data", "name": "Bar"}], {"type": "raw_data", "name": "Bar"}],
["AddRecord", "_grist_TabBar", 2, {"tabPos": 2.0, "viewRef": 2}], ["AddRecord", "_grist_TabBar", 2, {"tabPos": 2.0, "viewRef": 2}],
["AddRecord", "_grist_Pages", 2, {"pagePos": 2.0, "viewRef": 2, "indentation": 0}], ["AddRecord", "_grist_Pages", 2, {"pagePos": 2.0, "viewRef": 2, "indentation": 0}],
["AddRecord", "_grist_Views_section", 3, ["AddRecord", "_grist_Views_section", 4,
{"tableRef": 5, "defaultWidth": 100, "borderWidth": 1, {"tableRef": 5, "defaultWidth": 100, "borderWidth": 1,
"parentId": 2, "parentKey": "record", "sortColRefs": "[]", "title": ""}], "parentId": 2, "parentKey": "record", "sortColRefs": "[]", "title": ""}],
["BulkAddRecord", "_grist_Views_section_field", [1,2,3], ["BulkAddRecord", "_grist_Views_section_field", [1,2,3],
{"parentId": [3,3,3], "colRef": [32,33,34], "parentPos": [1.0,2.0,3.0]}], {"parentId": [4,4,4], "colRef": [32,33,34], "parentPos": [1.0,2.0,3.0]}],
["AddRecord", "_grist_Views_section", 4, {"borderWidth": 1, "defaultWidth": 100, "parentKey": "record", "tableRef": 5, "title": ""}], ["AddRecord", "_grist_Views_section", 5, {"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]}], ["BulkAddRecord", "_grist_Views_section_field", [4, 5, 6], {"colRef": [32, 33, 34], "parentId": [5, 5, 5], "parentPos": [4.0, 5.0, 6.0]}],
["UpdateRecord", "_grist_Tables", 5, {"primaryViewId": 2, "rawViewSectionRef": 4}], ["AddRecord", "_grist_Views_section", 6, {"borderWidth": 1, "defaultWidth": 100, "parentKey": "single", "tableRef": 5, "title": ""}],
["BulkAddRecord", "_grist_Views_section_field", [7, 8, 9], {"colRef": [32, 33, 34], "parentId": [6, 6, 6], "parentPos": [7.0, 8.0, 9.0]}],
["UpdateRecord", "_grist_Tables", 5, {"primaryViewId": 2, "rawViewSectionRef": 5, "recordCardViewSectionRef": 6}],
["AddRecord", "Bar", 1, {"foo": 0, "hello": "a", "manualSort": 1.0}], ["AddRecord", "Bar", 1, {"foo": 0, "hello": "a", "manualSort": 1.0}],
["AddRecord", "Bar", 2, {"foo": 1, "hello": "b", "manualSort": 2.0}], ["AddRecord", "Bar", 2, {"foo": 1, "hello": "b", "manualSort": 2.0}],
["AddRecord", "Bar", 3, {"foo": 1, "hello": "c", "manualSort": 3.0}], ["AddRecord", "Bar", 3, {"foo": 1, "hello": "c", "manualSort": 3.0}],
["BulkUpdateRecord", "Bar", [1, 2, 3], {"world": ["A", "B", "C"]}] ["BulkUpdateRecord", "Bar", [1, 2, 3], {"world": ["A", "B", "C"]}]
], ],
"direct": [true, true, true, true, true, true, true, true, "direct": [true, true, true, true, true, true, true, true,
true, true, true, true, true, true, true, true, true,
true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true,
true, true, true, false], true, true, true, false],
"undo": [ "undo": [
@ -2249,18 +2265,21 @@
["RemoveRecord", "_grist_Pages", 1], ["RemoveRecord", "_grist_Pages", 1],
["RemoveRecord", "_grist_Views_section", 1], ["RemoveRecord", "_grist_Views_section", 1],
["RemoveRecord", "_grist_Views_section", 2], ["RemoveRecord", "_grist_Views_section", 2],
["UpdateRecord", "_grist_Tables", 4, {"primaryViewId": 0, "rawViewSectionRef": 0}], ["RemoveRecord", "_grist_Views_section", 3],
["UpdateRecord", "_grist_Tables", 4, {"primaryViewId": 0, "rawViewSectionRef": 0, "recordCardViewSectionRef": 0}],
["RemoveTable", "Bar"], ["RemoveTable", "Bar"],
["RemoveRecord", "_grist_Tables", 5], ["RemoveRecord", "_grist_Tables", 5],
["BulkRemoveRecord", "_grist_Tables_column", [31,32,33,34]], ["BulkRemoveRecord", "_grist_Tables_column", [31,32,33,34]],
["RemoveRecord", "_grist_Views", 2], ["RemoveRecord", "_grist_Views", 2],
["RemoveRecord", "_grist_TabBar", 2], ["RemoveRecord", "_grist_TabBar", 2],
["RemoveRecord", "_grist_Pages", 2], ["RemoveRecord", "_grist_Pages", 2],
["RemoveRecord", "_grist_Views_section", 3],
["BulkRemoveRecord", "_grist_Views_section_field", [1,2,3]],
["RemoveRecord", "_grist_Views_section", 4], ["RemoveRecord", "_grist_Views_section", 4],
["BulkRemoveRecord", "_grist_Views_section_field", [1,2,3]],
["RemoveRecord", "_grist_Views_section", 5],
["BulkRemoveRecord", "_grist_Views_section_field", [4, 5, 6]], ["BulkRemoveRecord", "_grist_Views_section_field", [4, 5, 6]],
["UpdateRecord", "_grist_Tables", 5, {"primaryViewId": 0, "rawViewSectionRef": 0}], ["RemoveRecord", "_grist_Views_section", 6],
["BulkRemoveRecord", "_grist_Views_section_field", [7, 8, 9]],
["UpdateRecord", "_grist_Tables", 5, {"primaryViewId": 0, "rawViewSectionRef": 0, "recordCardViewSectionRef": 0}],
["RemoveRecord", "Bar", 1], ["RemoveRecord", "Bar", 1],
["RemoveRecord", "Bar", 2], ["RemoveRecord", "Bar", 2],
["RemoveRecord", "Bar", 3] ["RemoveRecord", "Bar", 3]
@ -2281,7 +2300,7 @@
"id": 5, "id": 5,
"columns": ["hello", "world", "foo"], "columns": ["hello", "world", "foo"],
"views": [ "views": [
{ "sections": [ 3 ], "id": 2 } { "sections": [ 4 ], "id": 2 }
] ]
}, },
// AddRecord retValues // AddRecord retValues
@ -2333,10 +2352,12 @@
"parentId": 1, "parentKey": "record", "sortColRefs": "[]", "title": ""}], "parentId": 1, "parentKey": "record", "sortColRefs": "[]", "title": ""}],
// Raw data widget // Raw data widget
["AddRecord", "_grist_Views_section", 2, {"borderWidth": 1, "defaultWidth": 100, "parentKey": "record", "tableRef": 4, "title": ""}], ["AddRecord", "_grist_Views_section", 2, {"borderWidth": 1, "defaultWidth": 100, "parentKey": "record", "tableRef": 4, "title": ""}],
// Record card widget
["AddRecord", "_grist_Views_section", 3, {"borderWidth": 1, "defaultWidth": 100, "parentKey": "single", "tableRef": 4, "title": ""}],
// As part of adding a table, we also set the primaryViewId. // As part of adding a table, we also set the primaryViewId.
["UpdateRecord", "_grist_Tables", 4, {"primaryViewId": 1, "rawViewSectionRef": 2}] ["UpdateRecord", "_grist_Tables", 4, {"primaryViewId": 1, "rawViewSectionRef": 2, "recordCardViewSectionRef": 3}]
], ],
"direct": [true, true, true, true, true, true, true, true, true], "direct": [true, true, true, true, true, true, true, true, true, true],
"undo": [ "undo": [
["RemoveTable", "Foo"], ["RemoveTable", "Foo"],
["RemoveRecord", "_grist_Tables", 4], ["RemoveRecord", "_grist_Tables", 4],
@ -2346,7 +2367,8 @@
["RemoveRecord", "_grist_Pages", 1], ["RemoveRecord", "_grist_Pages", 1],
["RemoveRecord", "_grist_Views_section", 1], ["RemoveRecord", "_grist_Views_section", 1],
["RemoveRecord", "_grist_Views_section", 2], ["RemoveRecord", "_grist_Views_section", 2],
["UpdateRecord", "_grist_Tables", 4, {"primaryViewId": 0, "rawViewSectionRef": 0}] ["RemoveRecord", "_grist_Views_section", 3],
["UpdateRecord", "_grist_Tables", 4, {"primaryViewId": 0, "rawViewSectionRef": 0, "recordCardViewSectionRef": 0}]
] ]
} }
}], }],
@ -2359,7 +2381,8 @@
"USER_ACTION": ["RemoveTable", "Foo"], "USER_ACTION": ["RemoveTable", "Foo"],
"ACTIONS": { "ACTIONS": {
"stored": [ "stored": [
["BulkRemoveRecord", "_grist_Views_section", [1, 2]], ["BulkRemoveRecord", "_grist_Views_section", [1, 2, 3]],
["UpdateRecord", "_grist_Tables", 4, {"recordCardViewSectionRef": 0}],
["UpdateRecord", "_grist_Tables", 4, {"rawViewSectionRef": 0}], ["UpdateRecord", "_grist_Tables", 4, {"rawViewSectionRef": 0}],
["RemoveRecord", "_grist_TabBar", 1], ["RemoveRecord", "_grist_TabBar", 1],
["RemoveRecord", "_grist_Pages", 1], ["RemoveRecord", "_grist_Pages", 1],
@ -2369,11 +2392,12 @@
["RemoveRecord", "_grist_Tables", 4], ["RemoveRecord", "_grist_Tables", 4],
["RemoveTable", "Foo"] ["RemoveTable", "Foo"]
], ],
"direct": [true, true, true, true, true, true, true, true, true], "direct": [true, true, true, true, true, true, true, true, true, true],
"undo": [ "undo": [
["BulkAddRecord", "_grist_Views_section", [1, 2], ["BulkAddRecord", "_grist_Views_section", [1, 2, 3],
{"borderWidth": [1, 1], "defaultWidth": [100, 100], "parentId": [1, 0], {"borderWidth": [1, 1, 1], "defaultWidth": [100, 100, 100], "parentId": [1, 0, 0],
"parentKey": ["record", "record"], "sortColRefs": ["[]", ""], "tableRef": [4, 4]}], "parentKey": ["record", "record", "single"], "sortColRefs": ["[]", "", ""], "tableRef": [4, 4, 4]}],
["UpdateRecord", "_grist_Tables", 4, {"recordCardViewSectionRef": 3}],
["UpdateRecord", "_grist_Tables", 4, {"rawViewSectionRef": 2}], ["UpdateRecord", "_grist_Tables", 4, {"rawViewSectionRef": 2}],
["AddRecord", "_grist_TabBar", 1, {"tabPos": 1.0, "viewRef": 1}], ["AddRecord", "_grist_TabBar", 1, {"tabPos": 1.0, "viewRef": 1}],
["AddRecord", "_grist_Pages", 1, {"pagePos": 1.0, "viewRef": 1}], ["AddRecord", "_grist_Pages", 1, {"pagePos": 1.0, "viewRef": 1}],

@ -479,6 +479,24 @@ class UserActions(object):
): ):
raise ValueError("Cannot modify raw view section fields") raise ValueError("Cannot modify raw view section fields")
# Prevent modifying record card widgets and their fields.
if (
table_id == "_grist_Views_section"
and any(rec.isRecordCard for i, rec in self._bulk_action_iter(table_id, row_ids))
):
allowed_fields = {"layoutSpec", "options", "theme"}
if not set(column_values) <= allowed_fields:
raise ValueError("Cannot modify record card view section")
if (
table_id == "_grist_Views_section_field"
and any(rec.parentId.isRecordCard for i, rec in self._bulk_action_iter(table_id, row_ids))
and not set(column_values) <= {
"displayCol", "parentPos", "rules", "visibleCol", "widgetOptions"
}
):
raise ValueError("Cannot modify record card view section fields")
# If any extra actions were generated (e.g. to adjust positions), apply them. # If any extra actions were generated (e.g. to adjust positions), apply them.
for a in extra_actions: for a in extra_actions:
self._do_doc_action(a) self._do_doc_action(a)
@ -1300,13 +1318,15 @@ class UserActions(object):
def _removeViewSectionRecords(self, table_id, row_ids): def _removeViewSectionRecords(self, table_id, row_ids):
""" """
Remove view sections, including their fields. Remove view sections, including their fields.
Raises an error if trying to remove a table's rawViewSectionRef. Raises an error if trying to remove a table's rawViewSectionRef or recordCardViewSectionRef.
To bypass that check, call _doRemoveViewSectionRecords. To bypass that check, call _doRemoveViewSectionRecords.
""" """
recs = [rec for i, rec in self._bulk_action_iter(table_id, row_ids)] recs = [rec for i, rec in self._bulk_action_iter(table_id, row_ids)]
for rec in recs: for rec in recs:
if rec.isRaw: if rec.isRaw:
raise ValueError("Cannot remove raw view section") raise ValueError("Cannot remove raw view section")
if rec.isRecordCard:
raise ValueError("Cannot remove record card view section")
self._doRemoveViewSectionRecords(recs) self._doRemoveViewSectionRecords(recs)
def _doRemoveViewSectionRecords(self, recs): def _doRemoveViewSectionRecords(self, recs):
@ -1355,16 +1375,35 @@ class UserActions(object):
ret = self.doAddColumn(table_id, col_id, col_info) ret = self.doAddColumn(table_id, col_id, col_info)
if not transform and table_rec.rawViewSectionRef: if not transform:
# Add a field for this column to the "raw_data" section for this table. if table_rec.rawViewSectionRef:
# TODO: the position of the inserted field or of the inserted column will often be # Add a field for this column to the "raw_data" section for this table.
# bogus, since fields and columns are not the same. This requires better coordination # TODO: the position of the inserted field or of the inserted column will often be
# with the client-side. # bogus, since fields and columns are not the same. This requires better coordination
self._docmodel.insert( # with the client-side.
table_rec.rawViewSectionRef.fields, self._docmodel.insert(
col_info.get('_position'), table_rec.rawViewSectionRef.fields,
colRef=ret['colRef'] col_info.get('_position'),
) colRef=ret['colRef']
)
if table_rec.recordCardViewSectionRef:
# If the record card section or one of its fields hasn't yet been modified,
# add a field for this column.
section = table_rec.recordCardViewSectionRef
modified = (
section.layoutSpec or
section.options or
section.rules or
section.theme or
any(f.widgetOptions for f in section.fields)
)
if not modified:
self._docmodel.insert(
table_rec.recordCardViewSectionRef.fields,
col_info.get('_position'),
colRef=ret['colRef']
)
return ret return ret
@ -1807,7 +1846,8 @@ class UserActions(object):
columns, columns,
manual_sort=True, manual_sort=True,
primary_view=True, primary_view=True,
raw_section=True) raw_section=True,
record_card_section=True)
@useraction @useraction
@ -1821,12 +1861,14 @@ class UserActions(object):
columns, columns,
manual_sort=True, manual_sort=True,
primary_view=False, primary_view=False,
raw_section=True raw_section=True,
record_card_section=True
) )
def doAddTable(self, table_id, columns, manual_sort=False, primary_view=False, def doAddTable(self, table_id, columns, manual_sort=False, primary_view=False,
raw_section=False, summarySourceTableRef=0): raw_section=False, record_card_section=False,
summarySourceTableRef=0):
""" """
Add the given table with columns with or without additional views. Add the given table with columns with or without additional views.
""" """
@ -1887,10 +1929,20 @@ class UserActions(object):
table_title if not summarySourceTableRef else "" table_title if not summarySourceTableRef else ""
) )
if primary_view or raw_section: if record_card_section:
record_card_section = self.create_plain_view_section(
result["id"],
table_id,
self._docmodel.view_sections,
"single",
""
)
if primary_view or raw_section or record_card_section:
self.UpdateRecord('_grist_Tables', result["id"], { self.UpdateRecord('_grist_Tables', result["id"], {
'primaryViewId': primary_view["id"] if primary_view else 0, 'primaryViewId': primary_view["id"] if primary_view else 0,
'rawViewSectionRef': raw_section.id if raw_section else 0, 'rawViewSectionRef': raw_section.id if raw_section else 0,
'recordCardViewSectionRef': record_card_section.id if record_card_section else 0,
}) })
return result return result
@ -1924,6 +1976,7 @@ class UserActions(object):
# Copy the columns from the raw view section to a new table. # Copy the columns from the raw view section to a new table.
raw_section = existing_table.rawViewSectionRef raw_section = existing_table.rawViewSectionRef
record_card_section = existing_table.recordCardViewSectionRef
raw_section_cols = [f.colRef for f in raw_section.fields] raw_section_cols = [f.colRef for f in raw_section.fields]
col_info = [summary.make_col_info(col=c) for c in raw_section_cols] col_info = [summary.make_col_info(col=c) for c in raw_section_cols]
columns = [summary.get_colinfo_dict(ci, with_id=True) for ci in col_info] columns = [summary.get_colinfo_dict(ci, with_id=True) for ci in col_info]
@ -1933,13 +1986,19 @@ class UserActions(object):
manual_sort=True, manual_sort=True,
primary_view=False, primary_view=False,
raw_section=True, raw_section=True,
record_card_section=True,
) )
new_table_id = result['table_id'] new_table_id = result['table_id']
new_raw_section = self._docmodel.get_table_rec(new_table_id).rawViewSectionRef new_table = self._docmodel.get_table_rec(new_table_id)
new_raw_section = new_table.rawViewSectionRef
# Copy view section options to the new raw view section. new_record_card_section = new_table.recordCardViewSectionRef
self._docmodel.update([new_raw_section], options=raw_section.options)
# Copy view section options to the new raw and record card view sections.
self._docmodel.update(
[new_raw_section, new_record_card_section],
options=[raw_section.options, record_card_section.options]
)
old_to_new_col_refs = {} old_to_new_col_refs = {}
for existing_field, new_field in zip(raw_section.fields, new_raw_section.fields): for existing_field, new_field in zip(raw_section.fields, new_raw_section.fields):

Binary file not shown.

Binary file not shown.

@ -76,11 +76,11 @@ describe("LinkingErrors", function() {
const planetsTable = tables.filterRecords({tableId: 'Planets'})[0]; const planetsTable = tables.filterRecords({tableId: 'Planets'})[0];
assert.isOk(planetsTable); assert.isOk(planetsTable);
const planetsSections = sections.filterRecords({tableRef: planetsTable.id}); const planetsSections = sections.filterRecords({tableRef: planetsTable.id});
assert.lengthOf(planetsSections, 3); assert.lengthOf(planetsSections, 4);
assert.equal(planetsSections[0].parentId, planetsSections[2].parentId); assert.equal(planetsSections[0].parentId, planetsSections[3].parentId);
assert.deepEqual(planetsSections.map(s => s.linkTargetColRef), [0, 0, 0]); assert.deepEqual(planetsSections.map(s => s.linkTargetColRef), [0, 0, 0, 0]);
assert.deepEqual(planetsSections.map(s => s.linkSrcSectionRef), [0, 0, 0]); assert.deepEqual(planetsSections.map(s => s.linkSrcSectionRef), [0, 0, 0, 0]);
assert.deepEqual(planetsSections.map(s => s.linkSrcColRef), [0, 0, 0]); assert.deepEqual(planetsSections.map(s => s.linkSrcColRef), [0, 0, 0, 0]);
// Switch to another page and back and check that there are no errors. // Switch to another page and back and check that there are no errors.
await gu.getPageItem('Moons').click(); await gu.getPageItem('Moons').click();
@ -151,8 +151,8 @@ describe("LinkingErrors", function() {
['AddEmptyTable', null], ['AddEmptyTable', null],
['UpdateRecord', '_grist_Tables_column', 6, {type: 'Ref:Table1'}], ['UpdateRecord', '_grist_Tables_column', 6, {type: 'Ref:Table1'}],
['CreateViewSection', 2, 1, 'record', null, null], ['CreateViewSection', 2, 1, 'record', null, null],
['UpdateRecord', '_grist_Views_section', 3, {linkSrcSectionRef: 1, linkSrcColRef: 0, linkTargetColRef: 0}], ['UpdateRecord', '_grist_Views_section', 4, {linkSrcSectionRef: 1, linkSrcColRef: 0, linkTargetColRef: 0}],
['UpdateRecord', '_grist_Views_section', 6, {linkSrcSectionRef: 1, linkSrcColRef: 0, linkTargetColRef: 6}], ['UpdateRecord', '_grist_Views_section', 8, {linkSrcSectionRef: 1, linkSrcColRef: 0, linkTargetColRef: 6}],
[ [
'UpdateRecord', 'UpdateRecord',
'_grist_Views', '_grist_Views',

@ -374,7 +374,7 @@ describe('RawData', function () {
// The last table should have disabled remove button. // The last table should have disabled remove button.
await openMenu(allTables[0]); await openMenu(allTables[0]);
assert.isTrue(await driver.find('.test-raw-data-menu-remove.disabled').isDisplayed()); assert.isTrue(await driver.find('.test-raw-data-menu-remove-table.disabled').isDisplayed());
await gu.sendKeys(Key.ESCAPE); await gu.sendKeys(Key.ESCAPE);
}); });
@ -542,8 +542,8 @@ describe('RawData', function () {
await gu.selectSectionByTitle("COUNTRY Card List"); await gu.selectSectionByTitle("COUNTRY Card List");
await gu.getDetailCell('Code', 1).click(); await gu.getDetailCell('Code', 1).click();
await gu.addNewSection(/Chart/, /CountryLanguage/); await gu.addNewSection(/Chart/, /CountryLanguage/);
// s19 is the new section id, we also strip row/column. // s22 is the new section id, we also strip row/column.
let chartLink = replaceAnchor(await gu.getAnchor(), {s: '19', a: '2'}); let chartLink = replaceAnchor(await gu.getAnchor(), {s: '22', a: '2'});
await gu.getPageItem('City').click(); await gu.getPageItem('City').click();
chartLink = (await driver.getCurrentUrl()) + '#' + chartLink.split('#')[1]; chartLink = (await driver.getCurrentUrl()) + '#' + chartLink.split('#')[1];
await waitForAnchorPopup(chartLink); await waitForAnchorPopup(chartLink);
@ -623,7 +623,7 @@ async function clickDuplicateTable() {
} }
async function clickRemove() { async function clickRemove() {
await driver.find('.test-raw-data-menu-remove').click(); await driver.find('.test-raw-data-menu-remove-table').click();
} }
async function removeRawTable(tableId: string) { async function removeRawTable(tableId: string) {
@ -681,7 +681,7 @@ async function waitForRawData() {
async function isRemovable(tableId: string){ async function isRemovable(tableId: string){
await openMenu(tableId); await openMenu(tableId);
const disabledItems = await driver.findAll('.test-raw-data-menu-remove.disabled'); const disabledItems = await driver.findAll('.test-raw-data-menu-remove-table.disabled');
await gu.sendKeys(Key.ESCAPE); await gu.sendKeys(Key.ESCAPE);
return disabledItems.length === 0; return disabledItems.length === 0;
} }

@ -164,7 +164,8 @@ describe('ReferenceColumns', function() {
it('should open to correct item selected, and leave it unchanged on Enter', async function() { it('should open to correct item selected, and leave it unchanged on Enter', async function() {
const checkRefCell = stackWrapFunc(async (col: string, rowNum: number, expValue: string) => { const checkRefCell = stackWrapFunc(async (col: string, rowNum: number, expValue: string) => {
// Click cell and open for editing. // Click cell and open for editing.
const cell = await gu.getCell({section: 'References', col, rowNum}).doClick(); const cell = await gu.getCell({section: 'References', col, rowNum})
.find('.test-ref-text').doClick();
assert.equal(await cell.getText(), expValue); assert.equal(await cell.getText(), expValue);
await driver.sendKeys(Key.ENTER); await driver.sendKeys(Key.ENTER);
// Wait for expected value to appear in the list; check that it's selected. // Wait for expected value to appear in the list; check that it's selected.
@ -453,7 +454,8 @@ describe('ReferenceColumns', function() {
}); });
it('should update choices as user types into textbox', async function() { it('should update choices as user types into textbox', async function() {
let cell = await gu.getCell({section: 'References', col: 'School', rowNum: 1}).doClick(); let cell = await gu.getCell({section: 'References', col: 'School', rowNum: 1})
.find('.test-ref-text').doClick();
assert.equal(await cell.getText(), 'TECHNOLOGY, ARTS AND SCIENCES STUDIO'); assert.equal(await cell.getText(), 'TECHNOLOGY, ARTS AND SCIENCES STUDIO');
await driver.sendKeys(Key.ENTER); await driver.sendKeys(Key.ENTER);
assert.deepEqual(await getACOptions(3), [ assert.deepEqual(await getACOptions(3), [
@ -493,7 +495,8 @@ describe('ReferenceColumns', function() {
it('should highlight matching parts of items', async function() { it('should highlight matching parts of items', async function() {
await driver.sendKeys(Key.HOME); await driver.sendKeys(Key.HOME);
let cell = await gu.getCell({section: 'References', col: 'Color', rowNum: 2}).doClick(); let cell = await gu.getCell({section: 'References', col: 'Color', rowNum: 2})
.find('.test-ref-text').doClick();
assert.equal(await cell.getText(), 'Red'); assert.equal(await cell.getText(), 'Red');
await driver.sendKeys(Key.ENTER); await driver.sendKeys(Key.ENTER);
await driver.findWait('.test-ref-editor-item', 1000); await driver.findWait('.test-ref-editor-item', 1000);
@ -505,7 +508,8 @@ describe('ReferenceColumns', function() {
['Re']); ['Re']);
await driver.sendKeys(Key.ESCAPE); await driver.sendKeys(Key.ESCAPE);
cell = await gu.getCell({section: 'References', col: 'School', rowNum: 1}).doClick(); cell = await gu.getCell({section: 'References', col: 'School', rowNum: 1})
.find('.test-ref-text').doClick();
await driver.sendKeys('br tech'); await driver.sendKeys('br tech');
assert.deepEqual( assert.deepEqual(
await driver.findContentWait('.test-ref-editor-item', /BROOKLYN TECH/, 1000).findAll('span', e => e.getText()), await driver.findContentWait('.test-ref-editor-item', /BROOKLYN TECH/, 1000).findAll('span', e => e.getText()),

@ -141,7 +141,7 @@ describe('TypeChange.ntest', function() {
// Prepare new table and section // Prepare new table and section
await gu.actions.addNewSection('New', 'Table'); await gu.actions.addNewSection('New', 'Table');
await gu.waitForServer(); await gu.waitForServer();
await $('.test-viewlayout-section-4').click(); await $('.test-viewlayout-section-6').click();
await gu.addRecord(['green']); await gu.addRecord(['green']);
await gu.addRecord(['blue']); await gu.addRecord(['blue']);

@ -1260,7 +1260,7 @@ export async function removeTable(tableId: string, options: {dismissTips?: boole
const menus = await driver.findAll(".test-raw-data-table .test-raw-data-table-menu"); const menus = await driver.findAll(".test-raw-data-table .test-raw-data-table-menu");
assert.equal(menus.length, tableIdList.length); assert.equal(menus.length, tableIdList.length);
await menus[tableIndex].click(); await menus[tableIndex].click();
await driver.find(".test-raw-data-menu-remove").click(); await driver.find(".test-raw-data-menu-remove-table").click();
await driver.find(".test-modal-confirm").click(); await driver.find(".test-modal-confirm").click();
await waitForServer(); await waitForServer();
} }
@ -1521,8 +1521,9 @@ export async function openRawTable(tableId: string) {
export async function renameRawTable(tableId: string, newName: string) { export async function renameRawTable(tableId: string, newName: string) {
await driver.find(`.test-raw-data-table .test-raw-data-table-id-${tableId}`) await driver.find(`.test-raw-data-table .test-raw-data-table-id-${tableId}`)
.findClosest('.test-raw-data-table') .findClosest('.test-raw-data-table')
.find('.test-widget-title-text') .find('.test-raw-data-table-menu')
.click(); .click();
await driver.find('.test-raw-data-menu-rename-table').click();
const input = await driver.find(".test-widget-title-table-name-input"); const input = await driver.find(".test-widget-title-table-name-input");
await input.doClear(); await input.doClear();
await input.click(); await input.click();

@ -982,6 +982,7 @@ function testDocApi() {
"id": "Table1", "id": "Table1",
"fields": { "fields": {
"rawViewSectionRef": 2, "rawViewSectionRef": 2,
"recordCardViewSectionRef": 3,
"primaryViewId": 1, "primaryViewId": 1,
"onDemand": false, "onDemand": false,
"summarySourceTable": 0, "summarySourceTable": 0,
@ -992,7 +993,8 @@ function testDocApi() {
{ {
"id": "Table2", "id": "Table2",
"fields": { "fields": {
"rawViewSectionRef": 4, "rawViewSectionRef": 5,
"recordCardViewSectionRef": 6,
"primaryViewId": 2, "primaryViewId": 2,
"onDemand": false, "onDemand": false,
"summarySourceTable": 0, "summarySourceTable": 0,
@ -1002,7 +1004,8 @@ function testDocApi() {
{ {
"id": "Table3_Renamed", "id": "Table3_Renamed",
"fields": { "fields": {
"rawViewSectionRef": 6, "rawViewSectionRef": 8,
"recordCardViewSectionRef": 9,
"primaryViewId": 3, "primaryViewId": 3,
"onDemand": false, "onDemand": false,
"summarySourceTable": 0, "summarySourceTable": 0,
@ -1012,7 +1015,8 @@ function testDocApi() {
{ {
"id": "NewTable1", "id": "NewTable1",
"fields": { "fields": {
"rawViewSectionRef": 8, "rawViewSectionRef": 11,
"recordCardViewSectionRef": 12,
"primaryViewId": 4, "primaryViewId": 4,
"onDemand": false, "onDemand": false,
"summarySourceTable": 0, "summarySourceTable": 0,
@ -1022,7 +1026,8 @@ function testDocApi() {
{ {
"id": "NewTable2", "id": "NewTable2",
"fields": { "fields": {
"rawViewSectionRef": 10, "rawViewSectionRef": 14,
"recordCardViewSectionRef": 15,
"primaryViewId": 5, "primaryViewId": 5,
"onDemand": false, "onDemand": false,
"summarySourceTable": 0, "summarySourceTable": 0,

Loading…
Cancel
Save