(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 {copyToClipboard} from 'app/client/lib/clipboardUtils';
import {setTestState} from 'app/client/lib/testState';
import {TableRec} from 'app/client/models/DocModel';
import {RECORD_CARDS} from 'app/client/models/features';
import {docListHeader, docMenuTrigger} from 'app/client/ui/DocMenuCss';
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 * as css from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
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 {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 * as weasel from 'popweasel';
const testId = makeTestId('test-raw-data-');
const t = makeT('DataTables');
const DATA_TABLES_TOOLTIP_KEY = 'dataTablesTooltip';
export class DataTables extends Disposable {
private _tables: Observable<TableRec[]>;
@ -47,17 +52,19 @@ export class DataTables extends Disposable {
testId('list'),
cssHeader(t("Raw Data Tables")),
cssList(
dom.forEach(this._tables, tableRec =>
cssItem(
dom.forEach(this._tables, tableRec => {
const isEditingName = observable(false);
return cssTable(
dom.autoDispose(isEditingName),
testId('table'),
cssLeft(
cssTableIcon(
dom.domComputed((use) => cssTableTypeIcon(
use(tableRec.summarySourceTable) !== 0 ? 'PivotLight' : 'TypeTable',
testId(`table-id-${use(tableRec.tableId)}`)
)),
),
cssMiddle(
cssTitleRow(cssTableTitle(this._tableTitle(tableRec), testId('table-title'))),
cssTableNameAndId(
cssTitleRow(cssTableTitle(this._tableTitle(tableRec, isEditingName), testId('table-title'))),
cssDetailsRow(
cssTableIdWrapper(cssHoverWrapper(
cssUpperCase("Table ID: "),
@ -76,14 +83,34 @@ export class DataTables extends Disposable {
setTestState({clipboard: tableRec.tableId.peek()});
})
)),
this._tableRows(tableRec),
),
),
cssRight(
docMenuTrigger(
this._tableRows(tableRec),
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'),
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(); }),
)
),
@ -94,14 +121,14 @@ export class DataTables extends Disposable {
}
this._gristDoc.viewModel.activeSectionId(sectionId);
})
)
)
);
})
),
),
);
}
private _tableTitle(table: TableRec) {
private _tableTitle(table: TableRec, isEditing: Observable<boolean>) {
return dom.domComputed((use) => {
const rawViewSectionRef = use(fromKo(table.rawViewSectionRef));
const isSummaryTable = use(table.summarySourceTable) !== 0;
@ -113,37 +140,75 @@ export class DataTables extends Disposable {
].filter(p => Boolean(p?.trim())).join(' ');
return cssTableName(tableName);
} else {
return dom('div', // to disable flex grow in the widget
return cssFlexRow(
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;
return [
menuItem(
() => { isEditingName.set(true); },
t("Rename Table"),
dom.cls('disabled', use => use(isReadonly) || use(table.summarySourceTable) !== 0),
testId('menu-rename-table'),
),
menuItem(
() => this._duplicateTable(table),
t("Duplicate Table"),
testId('menu-duplicate-table'),
dom.cls('disabled', use =>
use(isReadonly) ||
use(table.isHidden) ||
use(table.summarySourceTable) !== 0
),
testId('menu-duplicate-table'),
),
menuItem(
() => this._removeTable(table),
'Remove',
testId('menu-remove'),
t("Remove Table"),
dom.cls('disabled', use => use(isReadonly) || (
// Can't delete last visible table, unless it is a hidden table.
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"))),
];
}
@ -166,6 +231,24 @@ export class DataTables extends Disposable {
), '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) {
return dom.maybe(this._rowCount, (rowCounts) => {
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', `
overflow-y: auto;
position: relative;
@ -198,42 +293,37 @@ const cssList = styled('div', `
gap: 12px;
`);
const cssItem = styled('div', `
display: flex;
align-items: center;
const cssTable = styled('div', `
display: grid;
grid-template-columns: 16px auto 100px 56px;
grid-template-rows: 1fr;
grid-column-gap: 8px;
cursor: pointer;
border-radius: 3px;
width: 100%;
height: calc(1em * 56/13); /* 56px for 13px font */
max-width: 750px;
padding: 0px 12px 0px 12px;
border: 1px solid ${css.theme.rawDataTableBorder};
&:hover {
border-color: ${css.theme.rawDataTableBorderHover};
}
`);
// Holds icon in top left corner
const cssLeft = styled('div', `
const cssTableIcon = styled('div', `
padding-top: 11px;
padding-left: 12px;
margin-right: 8px;
align-self: flex-start;
display: flex;
flex: none;
`);
const cssMiddle = styled('div', `
flex-grow: 1;
const cssTableNameAndId = styled('div', `
min-width: 0px;
display: flex;
flex-wrap: wrap;
margin-top: 6px;
margin-bottom: 4px;
flex-direction: column;
margin-top: 8px;
`);
const cssTitleRow = styled('div', `
min-width: 100%;
margin-right: 4px;
`);
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)
const cssRight = styled('div', `
padding-right: 8px;
margin-left: 8px;
align-self: center;
// Holds dots menu (which is 24px x 24px)
const cssTableButtons = styled('div', `
display: flex;
flex: none;
align-items: center;
justify-content: flex-end;
column-gap: 8px;
`);
const cssTableTypeIcon = styled(icon, `
@ -270,13 +359,10 @@ const cssTableIdWrapper = styled('div', `
const cssTableRowsWrapper = styled('div', `
display: flex;
flex-shrink: 0;
min-width: 100px;
overflow: hidden;
align-items: baseline;
align-items: center;
color: ${css.theme.lightText};
line-height: 18px;
padding: 0px 2px;
`);
const cssHoverWrapper = styled('div', `
@ -301,6 +387,8 @@ const cssTableRows = cssTableId;
const cssTableTitle = styled('div', `
color: ${css.theme.text};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`);
@ -327,3 +415,66 @@ const cssLoadingDots = styled(loadingDots, `
const cssTableName = styled('span', `
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 commands = require('./commands');
const tableUtil = require('../lib/tableUtil');
const {CardContextMenu} = require('../ui/CardContextMenu');
const {FieldContextMenu} = require('../ui/FieldContextMenu');
const {RowContextMenu} = require('../ui/RowContextMenu');
const {parsePasteForView} = require("./BaseView2");
const {descriptionInfoTooltip} = require("../ui/tooltips");
@ -39,7 +39,7 @@ function DetailView(gristDoc, viewSectionModel) {
this.recordLayout = this.autoDispose(RecordLayout.create({
viewSection: this.viewSection,
buildFieldDom: this.buildFieldDom.bind(this),
buildRowContextMenu : this.buildRowContextMenu.bind(this),
buildCardContextMenu : this.buildCardContextMenu.bind(this),
buildFieldContextMenu : this.buildFieldContextMenu.bind(this),
resizeCallback: () => {
if (!this._isSingle) {
@ -246,15 +246,14 @@ DetailView.prototype.getSelection = function() {
);
};
DetailView.prototype.buildRowContextMenu = function(row) {
const rowOptions = this._getRowContextMenuOptions(row);
return RowContextMenu(rowOptions);
DetailView.prototype.buildCardContextMenu = function(row) {
const cardOptions = this._getCardContextMenuOptions(row);
return CardContextMenu(cardOptions);
}
DetailView.prototype.buildFieldContextMenu = function(row) {
const rowOptions = this._getRowContextMenuOptions(row);
DetailView.prototype.buildFieldContextMenu = function() {
const fieldOptions = this._getFieldContextMenuOptions();
return FieldContextMenu(rowOptions, fieldOptions);
return FieldContextMenu(fieldOptions);
}
/**
@ -490,8 +489,9 @@ DetailView.prototype._canSingleClick = function(field) {
};
DetailView.prototype._clearCardFields = function() {
const {isFormula} = this._getFieldContextMenuOptions();
if (isFormula === true) {
const selection = this.getSelection();
const isFormula = Boolean(selection.fields[0]?.column.peek().isRealFormula.peek());
if (isFormula) {
this.activateEditorAtCursor({init: ''});
} else {
const clearAction = tableUtil.makeDeleteAction(this.getSelection());
@ -520,7 +520,7 @@ DetailView.prototype._clearCopySelection = function() {
this.copySelection(null);
};
DetailView.prototype._getRowContextMenuOptions = function(row) {
DetailView.prototype._getCardContextMenuOptions = function(row) {
return {
disableInsert: Boolean(
this.gristDoc.isReadonly.get() ||
@ -542,7 +542,6 @@ DetailView.prototype._getFieldContextMenuOptions = function() {
return {
disableModify: Boolean(selection.fields[0]?.disableModify.peek()),
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 {buildRenameColumn} = require('app/client/ui/ColumnTitle');
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');
@ -370,7 +373,17 @@ GridView.gridCommands = {
return;
}
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() {
@ -1909,20 +1922,41 @@ GridView.prototype.rowContextMenu = function() {
GridView.prototype._getRowContextMenuOptions = 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,
...this._getCellContextMenuOptions(),
disableShowRecordCard: this._isRecordCardDisabled(),
};
};
GridView.prototype._isRecordCardDisabled = function() {
return this.getSelection().onlyAddRowSelected() ||
this.viewSection.isTableRecordCardDisabled() ||
this.viewSection.table().summarySourceTable() !== 0;
}
GridView.prototype.cellContextMenu = function() {
return CellContextMenu(
this._getRowContextMenuOptions(),
this._getCellContextMenuOptions(),
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
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 {importFromFile, selectAndImport} from 'app/client/components/Importer';
import {RawDataPage, RawDataPopup} from 'app/client/components/RawDataPage';
import {RecordCardPopup} from 'app/client/components/RecordCardPopup';
import {ActionGroupWithCursorPos, UndoStack} from 'app/client/components/UndoStack';
import {ViewLayout} from 'app/client/components/ViewLayout';
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
@ -125,7 +126,7 @@ export interface IExtraTool {
content: TabContent[] | IDomComponent;
}
interface RawSectionOptions {
interface PopupSectionOptions {
viewSection: ViewSectionRec;
hash: HashLink;
close: () => void;
@ -179,7 +180,7 @@ export class GristDoc extends DisposableWithEvents {
// the space.
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
// 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 viewLayout: ViewLayout | null = null;
@ -201,15 +202,14 @@ export class GristDoc extends DisposableWithEvents {
private _rightPanelTool = createSessionObs(this, "rightPanelTool", "none", RightPanelTool.guard);
private _showGristTour = getUserOrgPrefObs(this.userOrgPrefs, 'showGristTour');
private _seenDocTours = getUserOrgPrefObs(this.userOrgPrefs, 'seenDocTours');
private _rawSectionOptions: Observable<RawSectionOptions | null> = Observable.create(this, null);
private _activeContent: Computed<IDocPage | RawSectionOptions>;
private _popupSectionOptions: Observable<PopupSectionOptions | null> = Observable.create(this, null);
private _activeContent: Computed<IDocPage | PopupSectionOptions>;
private _docTutorialHolder = Holder.create<DocTutorial>(this);
private _isRickRowing: Observable<boolean> = Observable.create(this, false);
private _showBackgroundVideoPlayer: Observable<boolean> = Observable.create(this, false);
private _backgroundVideoPlayerHolder: Holder<YouTubePlayer> = Holder.create(this);
private _disableAutoStartingTours: boolean = false;
constructor(
public readonly app: App,
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);
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 => {
const externalContent = use(this._rawSectionOptions);
const externalContent = use(this._popupSectionOptions);
return externalContent ? use(externalContent.viewSection.id) : null;
});
// This viewModel reflects the currently active view, relying on the fact that
@ -302,7 +302,7 @@ export class GristDoc extends DisposableWithEvents {
try {
if (state.hash.popup) {
if (state.hash.popup || state.hash.recordCard) {
await this.openPopup(state.hash);
} else {
// 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));
// In case the section is removed, close the popup.
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) => {
this.viewLayout = ViewLayout.create(owner, this, content);
@ -671,7 +681,11 @@ export class GristDoc extends DisposableWithEvents {
return;
}
// 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();
// 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.
@ -1215,7 +1229,8 @@ export class GristDoc extends DisposableWithEvents {
}, false, silent, visitedSections.concat([section.id.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()) {
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
// 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
// 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);
this._rawSectionOptions.set({
this._popupSectionOptions.set({
hash,
viewSection: popupSection,
close: () => {
// In case we are already close, do nothing.
if (!this._rawSectionOptions.get()) {
// In case we are already closed, do nothing.
if (!this._popupSectionOptions.get()) {
return;
}
if (popupSection !== prevSection) {
// We need to blur raw view section. Otherwise it will automatically be opened
// on raw data view. Note: raw data section doesn't have its own view, it uses
// empty row model as a parent (which feels like a hack).
// We need to blur the popup section. Otherwise it will automatically be opened
// on raw data view. Note: raw data and record card sections don't have parent views;
// they use the empty row model as a parent (which feels like a hack).
if (!popupSection.isDisposed()) {
popupSection.hasFocus(false);
}
@ -1328,17 +1344,21 @@ export class GristDoc extends DisposableWithEvents {
prevSection.hasFocus(true);
}
}
// Clearing popup data will close this popup.
this._rawSectionOptions.set(null);
// Clearing popup section data will close this popup.
this._popupSectionOptions.set(null);
}
});
// If the anchor link is valid, set the cursor.
if (hash.colRef && hash.rowId) {
const fieldIndex = popupSection.viewFields.peek().all().findIndex(f => f.colRef.peek() === hash.colRef);
if (fieldIndex >= 0) {
const view = await this._waitForView(popupSection);
view?.setCursorPos({rowId: hash.rowId, fieldIndex});
if (hash.rowId || hash.colRef) {
const {rowId} = hash;
let fieldIndex;
if (hash.colRef) {
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) {
const section: ViewSectionRec = this.docModel.viewSections.getRowModel(sectionId);
if (section.isRaw.peek()) {
// This is raw data view
if (section.isRaw.peek() || section.isRecordCard.peek()) {
// This is a raw data or record card view.
await urlState().pushUrl({docPage: 'data'});
this.viewModel.activeSectionId(sectionId);
} else if (section.isVirtual.peek()) {

@ -23,7 +23,7 @@ export class RawDataPage extends Disposable {
this.autoDispose(commands.createGroup(commandGroup, this, true));
this._lightboxVisible = Computed.create(this, use => {
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)
// 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 **********/
dom.domComputed(fromKo(this._gristDoc.viewModel.activeSection), (viewSection) => {
const sectionId = viewSection.getRowId();
if (!sectionId || !viewSection.isRaw.peek()) {
if (!sectionId || (!viewSection.isRaw.peek() && !viewSection.isRecordCard.peek())) {
return null;
}
return dom.create(RawDataPopup, this._gristDoc, viewSection, () => this._close());
@ -97,7 +97,9 @@ export class RawDataPopup extends Disposable {
sectionRowId: this._viewSection.getRowId(),
draggable: 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',
@ -127,7 +129,7 @@ const cssPage = styled('div', `
}
`);
const cssOverlay = styled('div', `
export const cssOverlay = styled('div', `
background-color: ${theme.modalBackdrop};
inset: 0px;
height: 100%;
@ -162,7 +164,7 @@ const cssSectionWrapper = styled('div', `
}
`);
const cssCloseButton = styled(icon, `
export const cssCloseButton = styled(icon, `
position: absolute;
top: 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) {
this.viewSection = options.viewSection;
this.buildFieldDom = options.buildFieldDom;
this.buildRowContextMenu = options.buildRowContextMenu;
this.buildCardContextMenu = options.buildCardContextMenu;
this.buildFieldContextMenu = options.buildFieldContextMenu;
this.isEditingLayout = ko.observable(false);
this.editIndex = ko.observable(0);
@ -342,7 +342,7 @@ RecordLayout.prototype.buildLayoutDom = function(row, optCreateEditor) {
this.layoutEditor(null);
}) : null,
// enables field context menu anywhere on the card
contextMenu(() => this.buildFieldContextMenu(row)),
contextMenu(() => this.buildFieldContextMenu()),
dom('div.detail_row_num',
kd.text(() => (row._index() + 1)),
dom.on('contextmenu', ev => {
@ -358,7 +358,7 @@ RecordLayout.prototype.buildLayoutDom = function(row, optCreateEditor) {
this.viewSection.hasFocus(true);
commands.allCommands.setCursor.run(row);
}),
menu(() => this.buildRowContextMenu(row)),
menu(() => this.buildCardContextMenu(row)),
testId('card-menu-trigger')
)
),

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

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

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

@ -65,9 +65,21 @@ export function buildViewSectionDom(options: {
focusable?: boolean, /* defaults to true */
tableNameHidden?: boolean,
widgetNameHidden?: boolean,
renamable?: boolean,
hideTitleControls?: boolean,
}) {
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
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), () =>
cssSigmaIcon('Pivot', testId('sigma'))),
buildWidgetTitle(vs, options, testId('viewsection-title'), cssTestClick(testId("viewsection-blank"))),
viewInstance.buildTitleControls(),
buildWidgetTitle(
vs,
{tableNameHidden, widgetNameHidden, disabled: !renamable},
testId('viewsection-title'),
cssTestClick(testId("viewsection-blank")),
),
hideTitleControls ? null : viewInstance.buildTitleControls(),
dom('div.viewsection_buttons',
dom.create(viewSectionMenu, gristDoc, vs)
)

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

@ -15,6 +15,7 @@ export interface TableRec extends IRowModel<"_grist_Tables"> {
primaryView: ko.Computed<ViewRec>;
rawViewSection: ko.Computed<ViewSectionRec>;
recordCardViewSection: ko.Computed<ViewSectionRec>;
summarySource: ko.Computed<TableRec>;
// 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.rawViewSection = refRecord(docModel.viewSections, this.rawViewSectionRef);
this.recordCardViewSection = refRecord(docModel.viewSections, this.recordCardViewSectionRef);
this.summarySource = refRecord(docModel.tables, this.summarySourceTable);
this.isHidden = this.autoDispose(
// 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'
// in which case the UI prevents various things like hiding columns or changing the widget type.
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>;
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'
// 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'));
@ -818,7 +837,7 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
let newColInfo: NewColInfo;
await docModel.docData.bundleActions('Insert column', async () => {
newColInfo = await docModel.dataTables[this.tableId.peek()].sendTableAction(action);
if (!this.isRaw.peek()) {
if (!this.isRaw.peek() && !this.isRecordCard.peek()) {
const fieldInfo = {
colRef: newColInfo.colRef,
parentId: this.id.peek(),

@ -33,3 +33,7 @@ export function PERMITTED_CUSTOM_WIDGETS(): Observable<string[]> {
}
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 { menuDivider, menuItemCmd } from 'app/client/ui2018/menus';
import { IMultiColumnContextMenu } from 'app/client/ui/GridViewMenus';
import { IRowContextMenu } from 'app/client/ui/RowContextMenu';
import { COMMENTS } from 'app/client/models/features';
import { dom } from 'grainjs';
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 { disableModify, isReadonly } = colOptions;
const { disableInsert, disableDelete, isViewSorted, numRows } = cellOptions;
const { numColumns, disableModify, isReadonly, isFiltered } = colOptions;
// disableModify is true if the column is a summary column or is being transformed.
// isReadonly is true for readonly mode.
const disableForReadonlyColumn = dom.cls('disabled', Boolean(disableModify) || isReadonly);
const disableForReadonlyView = dom.cls('disabled', isReadonly);
const numCols: number = colOptions.numColumns;
const nameClearColumns = colOptions.isFiltered ?
t("Reset {{count}} entire columns", {count: numCols}) :
t("Reset {{count}} columns", {count: numCols});
const nameDeleteColumns = t("Delete {{count}} columns", {count: numCols});
const nameClearColumns = isFiltered ?
t("Reset {{count}} entire columns", {count: numColumns}) :
t("Reset {{count}} columns", {count: numColumns});
const nameDeleteColumns = t("Delete {{count}} columns", {count: numColumns});
const numRows: number = rowOptions.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> = [];
@ -42,13 +46,13 @@ export function CellContextMenu(rowOptions: IRowContextMenu, colOptions: IMultiC
menuItemCmd(allCommands.clearColumns, nameClearColumns, disableForReadonlyColumn),
...(
(numCols > 1 || numRows > 1) ? [] : [
(numColumns > 1 || numRows > 1) ? [] : [
menuDivider(),
menuItemCmd(allCommands.copyLink, t("Copy anchor link")),
menuDivider(),
menuItemCmd(allCommands.filterByThisCellValue, t("Filter by this value")),
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
]
),

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

@ -86,7 +86,7 @@ function removeView(activeDoc: GristDoc, viewId: number, pageName: string) {
const docData = activeDoc.docData;
// Create a set with tables on other pages (but not on this one).
const tablesOnOtherViews = new Set(activeDoc.docModel.viewSections.rowModels
.filter(vs => !vs.isRaw.peek() && vs.parentId.peek() !== viewId)
.filter(vs => !vs.isRaw.peek() && !vs.isRecordCard.peek() && vs.parentId.peek() !== viewId)
.map(vs => vs.tableRef.peek()));
// 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) => (
buildConfigContainer(
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) :
null
)
@ -397,33 +400,35 @@ export class RightPanel extends Disposable {
return dom.maybe(viewConfigTab, (vct) => [
this._disableIfReadonly(),
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)),
val => activeSection.titleDef.saveOnly(val),
dom.boolAttr('disabled', use => {
const isRawTable = use(activeSection.isRaw);
const isSummaryTable = use(use(activeSection.table).summarySourceTable) !== 0;
return isRawTable && isSummaryTable;
}),
testId('right-widget-title')
)),
cssSection(
dom.create(buildDescriptionConfig, activeSection.description, { cursor, "testPrefix": "right-widget" }),
),
dom.maybe(use => !use(activeSection.isRecordCard), () => [
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)),
val => activeSection.titleDef.saveOnly(val),
dom.boolAttr('disabled', use => {
const isRawTable = use(activeSection.isRaw);
const isSummaryTable = use(use(activeSection.table).summarySourceTable) !== 0;
return isRawTable && isSummaryTable;
}),
testId('right-widget-title')
)),
cssSection(
dom.create(buildDescriptionConfig, activeSection.description, { cursor, "testPrefix": "right-widget" }),
),
]),
dom.maybe(
(use) => !use(activeSection.isRaw),
(use) => !use(activeSection.isRaw) && !use(activeSection.isRecordCard),
() => cssRow(
primaryButton(t("Change Widget"), this._createPageWidgetPicker()),
cssRow.cls('-top-space')
),
),
cssSeparator(),
cssSeparator(dom.hide(activeSection.isRecordCard)),
dom.maybe((use) => ['detail', 'single'].includes(use(this._pageWidgetType)!), () => [
cssLabel(t("Theme")),
@ -744,7 +749,7 @@ export class RightPanel extends Disposable {
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(),
testId('pwc-editDataSelection')),
dom.maybe(
@ -764,9 +769,9 @@ export class RightPanel extends Disposable {
dom.maybe(viewConfigTab, (vct) => cssRow(
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")),
cssRow(
dom.update(
@ -1033,6 +1038,10 @@ const cssConfigContainer = styled('div.test-config-container', `
& .fieldbuilder_settings {
margin: 16px 0 0 0;
}
&-disabled {
opacity: 0.4;
pointer-events: none;
}
`);
const cssDataLabel = styled('div', `

@ -1,6 +1,7 @@
import { allCommands } from 'app/client/components/commands';
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';
const t = makeT('RowContextMenu');
@ -8,12 +9,29 @@ const t = makeT('RowContextMenu');
export interface IRowContextMenu {
disableInsert: boolean;
disableDelete: boolean;
disableShowRecordCard: boolean;
isViewSorted: boolean;
numRows: number;
}
export function RowContextMenu({ disableInsert, disableDelete, isViewSorted, numRows }: IRowContextMenu) {
export function RowContextMenu({
disableInsert,
disableDelete,
disableShowRecordCard,
isViewSorted,
numRows
}: IRowContextMenu) {
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) {
// 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

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

@ -58,6 +58,7 @@ export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: bool
const showRawData = (use: UseCB) => {
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.
;
};
@ -88,20 +89,22 @@ export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: bool
dom.maybe(!isSinglePage, () => [
menuDivider(),
menuItemCmd(allCommands.viewTabOpen, t("Widget options"), testId('widget-options')),
menuItemCmd(allCommands.sortFilterTabOpen, t("Advanced Sort & Filter")),
menuItemCmd(allCommands.dataSelectionTabOpen, t("Data selection")),
menuItemCmd(allCommands.sortFilterTabOpen, t("Advanced Sort & Filter"), dom.hide(viewSection.isRecordCard)),
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), () =>
menuItemCmd(allCommands.openWidgetConfiguration, t("Open configuration"),
testId('section-open-configuration')),
),
menuItemCmd(allCommands.collapseSection, t("Collapse widget"),
dom.cls('disabled', dontCollapseSection()),
dom.hide(viewSection.isRecordCard),
testId('section-collapse')),
menuItemCmd(allCommands.deleteSection, t("Delete widget"),
dom.cls('disabled', dontRemoveSection()),
dom.hide(viewSection.isRecordCard),
testId('section-delete')),
];
}

@ -69,6 +69,7 @@ export function viewSectionMenu(
&& 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(viewSection.isRaw) // not in raw mode
&& !use(viewSection.isRecordCard)
&& !use(singleVisible) // not in single section
;
});
@ -145,6 +146,7 @@ export function viewSectionMenu(
ctl.close();
}),
]}),
dom.hide(viewSection.isRecordCard),
),
cssMenu(
testId('viewLayout'),

@ -7,7 +7,7 @@ import { theme } from 'app/client/ui2018/cssVars';
import {menuCssClass} from 'app/client/ui2018/menus';
import {ModalControl} from 'app/client/ui2018/modals';
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 { autoGrow } from './forms';
import { cssInput, cssLabel, cssRenamePopup, cssTextArea } from 'app/client/ui/RenamePopupStyles';
@ -18,41 +18,105 @@ const t = makeT('WidgetTitle');
interface WidgetTitleOptions {
tableNameHidden?: boolean,
widgetNameHidden?: boolean,
disabled?: boolean,
}
export function buildWidgetTitle(vs: ViewSectionRec, options: WidgetTitleOptions, ...args: DomElementArg[]) {
const title = Computed.create(null, use => use(vs.titleDef));
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 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,
title: Observable<string>,
description: Observable<string>,
options: WidgetTitleOptions,
...args: DomElementArg[]) {
options: RenamableTitleOptions,
...args: DomElementArg[]
) {
const {openOnClick = true, disabled = false, isEditing, ...renameTitleOptions} = options;
let popupControl: PopupControl | undefined;
return cssTitleContainer(
cssTitle(
testId('text'),
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.
cssTitle.cls("-empty", use => !use(title)?.trim()),
cssTitle.cls("-open-on-click", openOnClick),
cssTitle.cls("-disabled", disabled),
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',
trigger: ['click'],
trigger,
attach: 'body',
boundaries: 'viewport',
});
},
dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }),
openOnClick ? dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }) : null,
),
dom.maybe(description, () => [
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();
// If the table is a summary table.
const isSummary = Boolean(tableRec.summarySourceTable.peek());
@ -279,14 +343,16 @@ const cssTitleContainer = styled('div', `
`);
const cssTitle = styled('div', `
cursor: pointer;
overflow: hidden;
border-radius: 3px;
margin: -4px;
padding: 4px;
text-overflow: ellipsis;
align-self: start;
&:hover {
&-open-on-click:not(&-disabled) {
cursor: pointer;
}
&-open-on-click:not(&-disabled):hover {
background-color: ${theme.hover};
}
&-empty {

@ -9,7 +9,7 @@ import { IconName } from 'app/client/ui2018/IconList';
import { icon } from 'app/client/ui2018/icons';
import { cssSelectBtn } from 'app/client/ui2018/select';
import {
BindableValue, Computed, dom, DomElementArg, DomElementMethod, IDomArgs,
BindableValue, Computed, dom, DomContents, DomElementArg, DomElementMethod, IDomArgs,
MaybeObsArray, MutableObsArray, Observable, styled
} from 'grainjs';
import debounce from 'lodash/debounce';
@ -574,16 +574,27 @@ export const menuItemAsync: typeof weasel.menuItem = function(action, ...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(
() => 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,
cssMenuItemCmd.cls(''), // overrides some menu item styles
...args
);
}
export const menuItemCmdLabel = styled('div', `
display: flex;
align-items: center;
`);
export function menuAnnotate(text: string, ...args: DomElementArg[]) {
return cssAnnotateMenuItem(text, ...args);
}
@ -701,6 +712,15 @@ const cssInputButtonMenuElem = styled(cssMenuElem, `
const cssMenuItemCmd = styled('div', `
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', `
@ -712,7 +732,7 @@ const cssCmdKey = styled('span', `
color: ${theme.menuItemIconSelectedFg};
}
.${weasel.cssMenuItem.className}.disabled > & {
.${weasel.cssMenuItem.className}.disabled & {
color: ${theme.menuItemDisabledFg};
}
`);

@ -77,7 +77,11 @@ export class CellStyle extends Disposable {
}),
cssLine(
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(
testId('cell-color-select'),

@ -1,12 +1,16 @@
import {makeT} from 'app/client/lib/localization';
import {DataRowModel} from 'app/client/models/DataRowModel';
import {TableRec} from 'app/client/models/DocModel';
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 {hideInPrintView, testId, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {IOptionFull, select} from 'app/client/ui2018/menus';
import {NTextBox} from 'app/client/widgets/NTextBox';
import {isFullReferencingType, isVersions} from 'app/common/gristTypes';
import {UIRowId} from 'app/plugin/GristAPI';
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.
*/
export class Reference extends NTextBox {
private _refTable: Computed<TableRec | null>;
private _visibleColRef: Computed<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.
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) => {
const refTable = use(use(this.field.column).refTable);
const refTable = use(this._refTable);
if (!refTable) { return []; }
return use(use(refTable.columns).getObservable())
.filter(col => !use(col.isHiddenCol))
@ -75,16 +82,16 @@ export class Reference extends NTextBox {
return id && use(id);
});
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()) {
// Work around JS errors during certain changes (noticed when visibleCol field gets removed
// for a column using per-field settings).
return {value, hasBlankReference};
return {value, hasBlankReference, hasRecordCard};
}
const displayValueObs = row.cells[use(use(this.field.displayColModel).colId)];
if (!displayValueObs) {
return {value, hasBlankReference};
return {value, hasBlankReference, hasRecordCard};
}
const displayValue = use(displayValueObs);
@ -97,8 +104,12 @@ export class Reference extends NTextBox {
use(this.field.formatter).formatAny(displayValue);
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(
@ -107,12 +118,39 @@ export class Reference extends NTextBox {
cssRef.cls('-blank', use => use(formattedValue).hasBlankReference),
dom.style('text-align', this.alignment),
dom.cls('text_wrapping', this.wrapping),
cssRefIcon('FieldReference', testId('ref-link-icon'), hideInPrintView()),
dom.text(use => {
if (use(referenceId) === 0) { return ''; }
if (use(formattedValue).hasBlankReference) { return '[Blank]'; }
return use(formattedValue).value;
})
cssRefIcon('FieldReference',
cssRefIcon.cls('-view-as-card', use =>
RECORD_CARDS() && use(referenceId) !== 0 && use(formattedValue).hasRecordCard),
dom.on('click', async () => {
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;
--icon-color: ${theme.lightText};
margin: -1px 2px 2px 0;
&-view-as-card {
cursor: pointer;
}
&-view-as-card:hover {
--icon-color: ${theme.controlFg};
}
`);
const cssRef = styled('div.field_clip', `

@ -290,9 +290,15 @@ export function encodeUrl(gristConfig: Partial<GristLoadConfig>,
queryParams[`${k}_`] = v;
}
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;
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>) {
const partValue = hash[key];
if (partValue) {
@ -480,13 +486,13 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
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 keys = [
'sectionId',
'rowId',
'colRef',
] as Array<Exclude<keyof HashLink, 'popup' | 'rickRow'>>;
] as Array<Exclude<keyof HashLink, 'popup' | 'rickRow' | 'recordCard'>>;
for (const key of keys) {
let ch: string;
if (key === 'rowId' && hashMap.has('rr')) {
@ -504,7 +510,9 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
}
}
if (hashMap.get('#') === 'a2') {
link.popup = true;
link.popup = true;
} else if (hashMap.get('#') === 'a3') {
link.recordCard = true;
}
state.hash = link;
}
@ -722,6 +730,8 @@ export interface GristLoadConfig {
// Whether to show the "Delete Account" button in the account page.
canCloseAccount?: boolean;
experimentalPlugins?: boolean;
}
export const Features = StringUnion(
@ -966,6 +976,7 @@ export interface HashLink {
colRef?: number;
popup?: boolean;
rickRow?: boolean;
recordCard?: boolean;
}
// 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
export const SCHEMA_VERSION = 39;
export const SCHEMA_VERSION = 40;
export const schema = {
@ -23,6 +23,7 @@ export const schema = {
summarySourceTable : "Ref:_grist_Tables",
onDemand : "Bool",
rawViewSectionRef : "Ref:_grist_Views_section",
recordCardViewSectionRef: "Ref:_grist_Views_section",
},
"_grist_Tables_column": {
@ -234,6 +235,7 @@ export interface SchemaTypes {
summarySourceTable: number;
onDemand: boolean;
rawViewSectionRef: number;
recordCardViewSectionRef: number;
};
"_grist_Tables_column": {

@ -6,8 +6,8 @@ export const GRIST_DOC_SQL = `
PRAGMA foreign_keys=OFF;
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 '');
INSERT INTO _grist_DocInfo VALUES(1,'','','',39,'','');
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);
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, "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_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 '');
@ -43,9 +43,9 @@ export const GRIST_DOC_WITH_TABLE1_SQL = `
PRAGMA foreign_keys=OFF;
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 '');
INSERT INTO _grist_DocInfo VALUES(1,'','','',39,'','');
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);
INSERT INTO _grist_Tables VALUES(1,'Table1',1,0,0,2);
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, "recordCardViewSectionRef" INTEGER DEFAULT 0);
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);
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);
@ -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);
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(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);
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);
@ -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(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(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_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);

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

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

@ -141,6 +141,9 @@ class MetaTableExtras(object):
def isRaw(rec, table):
return rec.tableRef.rawViewSectionRef == rec
def isRecordCard(rec, table):
return rec.tableRef.recordCardViewSectionRef == rec
class _grist_Filters(object):
def setAutoRemove(rec, table):
"""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:
doc_actions.append(add_column('_grist_Views_section', 'description', 'Text'))
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
SCHEMA_VERSION = 39
SCHEMA_VERSION = 40
def make_column(col_id, col_type, formula='', isFormula=False):
return {
@ -58,6 +58,7 @@ def schema_create_actions():
make_column("onDemand", "Bool"),
make_column("rawViewSectionRef", "Ref:_grist_Views_section"),
make_column("recordCardViewSectionRef", "Ref:_grist_Views_section"),
]),
# All columns in all user tables.

@ -202,7 +202,8 @@ class SummaryActions(object):
encode_summary_table_name(source_table.tableId, groupby_col_ids),
[get_colinfo_dict(ci, with_id=True) for ci in groupby_colinfo + formula_colinfo],
summarySourceTableRef=source_table.id,
raw_section=True)
raw_section=True,
record_card_section=False)
summary_table = self.docmodel.tables.table.get_record(result['id'])
created = True
# 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(3, colRef=13),
]),
Section(4, parentKey="record", tableRef=2, fields=[
Field(10, colRef=15),
Field(11, colRef=16),
Field(12, colRef=17),
Section(5, parentKey="record", tableRef=2, fields=[
Field(13, colRef=15),
Field(14, colRef=16),
Field(15, colRef=17),
]),
Section(6, parentKey="record", tableRef=3, fields=[
Field(16, colRef=18),
Field(17, colRef=20),
Field(18, colRef=21),
Section(7, parentKey="record", tableRef=3, fields=[
Field(19, colRef=18),
Field(20, colRef=20),
Field(21, colRef=21),
]),
]),
View(2, sections=[
@ -311,14 +311,14 @@ class TestColumnActions(test_engine.EngineTestCase):
Field(2, colRef=12),
Field(3, colRef=13),
]),
Section(4, parentKey="record", tableRef=2, fields=[
Field(10, colRef=15),
Field(12, colRef=17),
Section(5, parentKey="record", tableRef=2, fields=[
Field(13, colRef=15),
Field(15, colRef=17),
]),
Section(6, parentKey="record", tableRef=3, fields=[
Field(16, colRef=18),
Field(17, colRef=20),
Field(18, colRef=21),
Section(7, parentKey="record", tableRef=3, fields=[
Field(19, colRef=18),
Field(20, colRef=20),
Field(21, colRef=21),
]),
]),
View(2, sections=[
@ -368,13 +368,13 @@ class TestColumnActions(test_engine.EngineTestCase):
Section(1, parentKey="record", tableRef=1, fields=[
Field(3, colRef=13),
]),
Section(4, parentKey="record", tableRef=2, fields=[
Field(10, colRef=15),
Field(12, colRef=17),
Section(5, parentKey="record", tableRef=2, fields=[
Field(13, colRef=15),
Field(15, colRef=17),
]),
Section(6, parentKey="record", tableRef=4, fields=[
Field(17, colRef=23),
Field(18, colRef=24),
Section(7, parentKey="record", tableRef=4, fields=[
Field(20, colRef=23),
Field(21, colRef=24),
]),
]),
View(2, sections=[
@ -420,14 +420,14 @@ class TestColumnActions(test_engine.EngineTestCase):
self.init_sample_data()
# 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]']}
])
self.assertTableData('_grist_Views_section', cols="subset", rows="subset", data=[
["id", "sortColRefs" ],
[2, '[15, -16]' ],
[3, '[-15, 16, 17]'],
[4, '[19]' ],
[5, '[19]' ],
])
# Remove column, and check that the correct sortColRefs items are removed.
@ -436,7 +436,7 @@ class TestColumnActions(test_engine.EngineTestCase):
["id", "sortColRefs"],
[2, '[15]' ],
[3, '[-15, 17]' ],
[4, '[19]' ],
[5, '[19]' ],
])
# Update sortColRefs for next test.
@ -450,5 +450,5 @@ class TestColumnActions(test_engine.EngineTestCase):
["id", "sortColRefs"],
[2, '[]' ],
[3, '[-16]' ],
[4, '[]' ],
[5, '[]' ],
])

@ -53,6 +53,7 @@ class TestUserActions(test_engine.EngineTestCase):
["id", "colRef", "displayCol"],
[1, 25, 0],
[2, 25, 0],
[3, 25, 0],
])
self.assertTableData("Favorites", cols="subset", data=[
["id", "favorite"],
@ -70,6 +71,7 @@ class TestUserActions(test_engine.EngineTestCase):
[1, 25, 0],
[2, 25, 0],
[3, 25, 0],
[4, 25, 0],
])
# 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
# 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', 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=[
["id", "colId", "parentId", "displayCol", "formula"],
[25, "favorite", 2, 26, ""],
@ -97,13 +99,14 @@ class TestUserActions(test_engine.EngineTestCase):
["id", "colRef", "displayCol"],
[1, 25, 27],
[2, 25, 0],
[3, 25, 27],
[3, 25, 0],
[4, 25, 27],
])
# Change display formula for a field.
# 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.
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=[
["id", "colId", "parentId", "displayCol", "formula"],
[25, "favorite", 2, 26, ""],
@ -115,13 +118,14 @@ class TestUserActions(test_engine.EngineTestCase):
["id", "colRef", "displayCol"],
[1, 25, 27],
[2, 25, 0],
[3, 25, 28],
[3, 25, 0],
[4, 25, 28],
])
# Remove a field.
# This should also remove the display column used by that field, since it is not used
# 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=[
["id", "colId", "parentId", "displayCol", "formula"],
[25, "favorite", 2, 26, ""],
@ -132,6 +136,7 @@ class TestUserActions(test_engine.EngineTestCase):
["id", "colRef", "displayCol"],
[1, 25, 27],
[2, 25, 0],
[3, 25, 0],
])
# Add a new column with a formula.
@ -145,7 +150,7 @@ class TestUserActions(test_engine.EngineTestCase):
'parentId': 3,
'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=[
["id", "colId", "parentId", "displayCol", "formula"],
[25, "favorite", 2, 26, ""],
@ -158,17 +163,19 @@ class TestUserActions(test_engine.EngineTestCase):
["id", "colRef", "displayCol"],
[1, 25, 27],
[2, 25, 0],
[3, 28, 0], # fav_viewers field
[4, 28, 0], # fav_viewers field
[5, 28, 0], # fav_viewers field
[6, 25, 29] # re-added field w/ display col
[3, 25, 0],
[4, 28, 0], # fav_viewers field
[5, 28, 0], # fav_viewers field
[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
# the field.
# 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(['RemoveRecord', '_grist_Views_section_field', 6])
self.apply_user_action(['SetDisplayFormula', 'Favorites', 8, None, '$favorite.network'])
self.apply_user_action(['RemoveRecord', '_grist_Views_section_field', 8])
self.assertTableData("_grist_Tables_column", cols="subset", rows=(lambda r: r.id >= 25), data=[
["id", "colId", "parentId", "displayCol", "formula"],
[25, "favorite", 2, 26, ""],
@ -180,9 +187,11 @@ class TestUserActions(test_engine.EngineTestCase):
["id", "colRef", "displayCol"],
[1, 25, 27],
[2, 25, 0],
[3, 28, 0],
[3, 25, 0],
[4, 28, 0],
[5, 28, 0],
[6, 28, 0],
[7, 28, 0],
])
# Clear field display formula, then set it again.
@ -199,9 +208,11 @@ class TestUserActions(test_engine.EngineTestCase):
["id", "colRef", "displayCol"],
[1, 25, 0],
[2, 25, 0],
[3, 28, 0],
[3, 25, 0],
[4, 28, 0],
[5, 28, 0],
[6, 28, 0],
[7, 28, 0],
])
# Setting the display formula should add another display column.
self.apply_user_action(['SetDisplayFormula', 'Favorites', 1, None, '$favorite.viewers'])
@ -216,9 +227,11 @@ class TestUserActions(test_engine.EngineTestCase):
["id", "colRef", "displayCol"],
[1, 25, 29],
[2, 25, 0],
[3, 28, 0],
[3, 25, 0],
[4, 28, 0],
[5, 28, 0],
[6, 28, 0],
[7, 28, 0],
])
# Change column display formula.
@ -235,9 +248,11 @@ class TestUserActions(test_engine.EngineTestCase):
["id", "colRef", "displayCol"],
[1, 25, 29],
[2, 25, 0],
[3, 28, 0],
[3, 25, 0],
[4, 28, 0],
[5, 28, 0],
[6, 28, 0],
[7, 28, 0],
])
# Remove column.
@ -249,9 +264,10 @@ class TestUserActions(test_engine.EngineTestCase):
])
self.assertTableData("_grist_Views_section_field", cols="subset", data=[
["id", "colRef", "displayCol"],
[3, 28, 0],
[4, 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
self.assertOutActions(out_actions, {
"stored": [
["BulkRemoveRecord", "_grist_Views_section_field", [2, 4]],
["BulkRemoveRecord", "_grist_Views_section_field", [2, 4, 6]],
["BulkRemoveRecord", "_grist_Tables_column", [26, 27]],
["RemoveColumn", "People", "favorite"],
["RemoveColumn", "People", "gristHelper_Display"],
@ -392,7 +408,7 @@ class TestUserActions(test_engine.EngineTestCase):
"undo": [
["BulkUpdateRecord", "People", [1, 2, 3], {"gristHelper_Display2": ["Netflix", "HBO", "NBC"]}],
["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\"}\"", ""]}],
["BulkUpdateRecord", "People", [1, 2, 3], {"favorite": [12, 11, 13]}],
["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"], [
[1, 1, 4],
[2, 0, 4],
[3, 2, 5],
[4, 0, 5],
[5, 1, 4],
[6, 1, 5],
[3, 0, 4],
[4, 2, 5],
[5, 0, 5],
[6, 0, 5],
[7, 1, 4],
[8, 1, 5],
])
self.assertPartialData('_grist_Views_section_field', ["id", "parentId", "parentPos"], [
[1, 1, 1.0],
[2, 1, 2.0],
[3, 2, 3.0],
[4, 2, 4.0],
[5, 3, 5.0],
[6, 3, 6.0],
[7, 4, 7.0],
[8, 4, 8.0],
[9, 5, 9.0],
[1, 1, 1.0],
[2, 1, 2.0],
[3, 2, 3.0],
[4, 2, 4.0],
[5, 3, 5.0],
[6, 3, 6.0],
[7, 4, 7.0],
[8, 4, 8.0],
[9, 5, 9.0],
[10, 5, 10.0],
[11, 6, 11.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')
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)[2].fields, [9, 10])
self.assertRecordSet(list(table.viewSections)[3].fields, [13, 14])
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.assertRecordSet(view.viewSections, [6])
self.engine.docmodel.remove(set(table.viewSections) -
{table.rawViewSectionRef, table.recordCardViewSectionRef})
self.assertRecordSet(view.viewSections, [8])
def test_modifications(self):

@ -54,10 +54,13 @@ class TestImportActions(test_engine.EngineTestCase):
self.assertPartialData("_grist_Views_section", ["id", "tableRef", 'fields'], [
[1, 1, [1, 2, 3]], # section for "Source" table
[2, 1, [4, 5, 6]], # section for "Source" table
[3, 2, [7, 8]], # section for "Destination1" table
[4, 2, [9, 10]], # section for "Destination1" table
[5, 3, [11]], # section for "Destination2" table
[6, 3, [12]], # section for "Destination2" table
[3, 1, [7, 8, 9]], # section for "Source" table
[4, 2, [10, 11]], # section for "Destination1" table
[5, 2, [12, 13]], # section for "Destination1" 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):
@ -89,11 +92,14 @@ class TestImportActions(test_engine.EngineTestCase):
self.assertPartialData("_grist_Views_section", ["id", "tableRef", 'fields'], [
[1, 1, [1, 2, 3]],
[2, 1, [4, 5, 6]],
[3, 2, [7, 8]],
[4, 2, [9, 10]],
[5, 3, [11]],
[6, 3, [12]],
[7, 1, [13, 14]], # new section for transform preview
[3, 1, [7, 8, 9]],
[4, 2, [10, 11]],
[5, 2, [12, 13]],
[6, 2, [14, 15]],
[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
@ -117,17 +123,20 @@ class TestImportActions(test_engine.EngineTestCase):
[2, "Alison", "Boston", 7003, "", 2.0],
])
self.assertPartialData("_grist_Views_section", ["id", "tableRef", 'fields'], [
[1, 1, [1, 2, 3]],
[2, 1, [4, 5, 6]],
[3, 2, [7, 8]],
[4, 2, [9, 10]],
[5, 3, [11]],
[6, 3, [12]],
[7, 1, [13]], # new section for transform preview
[1, 1, [1, 2, 3]],
[2, 1, [4, 5, 6]],
[3, 1, [7, 8, 9]],
[4, 2, [10, 11]],
[5, 2, [12, 13]],
[6, 2, [14, 15]],
[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
# actions needed to populate the table in the second call.
self.init_state()
@ -135,8 +144,8 @@ class TestImportActions(test_engine.EngineTestCase):
out_actions = self.apply_user_action(['GenImporterView', 'Source', 'Destination1', None, {}])
self.assertPartialOutActions(out_actions, {
"stored": [
["BulkRemoveRecord", "_grist_Views_section_field", [13, 14, 15]],
["RemoveRecord", "_grist_Views_section", 7],
["BulkRemoveRecord", "_grist_Views_section_field", [19, 20, 21]],
["RemoveRecord", "_grist_Views_section", 10],
["BulkRemoveRecord", "_grist_Tables_column", [10, 11, 12]],
["RemoveColumn", "Source", "gristHelper_Import_Name"],
["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": ""}],
["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_Views_section", 7, {"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]}],
["AddRecord", "_grist_Views_section", 10, {"borderWidth": 1, "defaultWidth": 100, "parentKey": "record", "sortColRefs": "[]", "tableRef": 1}],
["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.
["BulkUpdateRecord", "Source", [1, 2], {"gristHelper_Import_City": ["New York", "Boston"]}],
["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],
])
self.assertPartialData("_grist_Views_section", ["id", "tableRef", 'fields'], [
[1, 1, [1, 2, 3]],
[2, 1, [4, 5, 6]],
[3, 2, [7, 8]],
[4, 2, [9, 10]],
[5, 3, [11]],
[6, 3, [12]],
[7, 1, [13, 14, 15]], # new section for transform preview
[1, 1, [1, 2, 3]],
[2, 1, [4, 5, 6]],
[3, 1, [7, 8, 9]],
[4, 2, [10, 11]],
[5, 2, [12, 13]],
[6, 2, [14, 15]],
[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=[
Section(3, parentKey="record", tableRef=2, fields=[
Field(7, colRef=6),
Field(8, colRef=7),
Field(9, colRef=8),
Section(4, parentKey="record", tableRef=2, fields=[
Field(10, colRef=6),
Field(11, colRef=7),
Field(12, colRef=8),
]),
]),
View(3, sections=[
Section(5, parentKey="record", tableRef=1, fields=[
Field(13, colRef=2),
Field(14, colRef=3),
Field(15, colRef=4),
Section(7, parentKey="record", tableRef=1, fields=[
Field(19, colRef=2),
Field(20, colRef=3),
Field(21, colRef=4),
]),
Section(7, parentKey="record", tableRef=3, fields=[
Field(19, colRef=9),
Field(20, colRef=11),
Field(21, colRef=12),
Section(9, parentKey="record", tableRef=3, fields=[
Field(25, colRef=9),
Field(26, colRef=11),
Field(27, colRef=12),
]),
Section(8, parentKey="record", tableRef=2, fields=[
Field(22, colRef=6),
Field(23, colRef=7),
Field(24, colRef=8),
Section(10, parentKey="record", tableRef=2, fields=[
Field(28, colRef=6),
Field(29, colRef=7),
Field(30, colRef=8),
]),
]),
])
@ -295,17 +295,17 @@ class TestTableActions(test_engine.EngineTestCase):
])
self.assertViews([
View(2, sections=[
Section(3, parentKey="record", tableRef=2, fields=[
Field(7, colRef=6),
Field(8, colRef=7),
Field(9, colRef=8),
Section(4, parentKey="record", tableRef=2, fields=[
Field(10, colRef=6),
Field(11, colRef=7),
Field(12, colRef=8),
]),
]),
View(3, sections=[
Section(8, parentKey="record", tableRef=2, fields=[
Field(22, colRef=6),
Field(23, colRef=7),
Field(24, colRef=8),
Section(10, parentKey="record", tableRef=2, fields=[
Field(28, colRef=6),
Field(29, colRef=7),
Field(30, colRef=8),
]),
]),
])

@ -58,6 +58,7 @@ class TestUserActions(test_engine.EngineTestCase):
self.assertPartialData("_grist_Views_section_field", ["id", "colRef", "widgetOptions"], [
[1, 23, ""],
[2, 23, ""],
[3, 23, ""],
])
self.assertPartialData("Schools", ["id", "city"], [
[1, "New York" ],
@ -78,8 +79,11 @@ class TestUserActions(test_engine.EngineTestCase):
'grist_Transform', 'formula': 'return $city', 'label': 'grist_Transform',
'type': 'Text'
}],
["AddRecord", "_grist_Views_section_field", 3, {
"colRef": 24, "parentId": 2, "parentPos": 3.0
["AddRecord", "_grist_Views_section_field", 4, {
"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],
{"grist_Transform": ["New York", "Colombia", "New York"]}],
@ -122,7 +126,7 @@ class TestUserActions(test_engine.EngineTestCase):
out_actions = self.remove_column('Schools', 'grist_Transform')
self.assertPartialOutActions(out_actions, { "stored": [
["RemoveRecord", "_grist_Views_section_field", 3],
["BulkRemoveRecord", "_grist_Views_section_field", [4, 5]],
['RemoveRecord', '_grist_Tables_column', 24],
['RemoveColumn', 'Schools', 'grist_Transform'],
]})
@ -205,10 +209,10 @@ class TestUserActions(test_engine.EngineTestCase):
Column(25, "C", "Any", isFormula=True, formula="", summarySourceCol=0),
])
new_view = View(1, sections=[
Section(2, parentKey="record", tableRef=2, fields=[
Field(4, colRef=23),
Field(5, colRef=24),
Field(6, colRef=25),
Section(3, parentKey="record", tableRef=2, fields=[
Field(7, colRef=23),
Field(8, colRef=24),
Field(9, colRef=25),
])
])
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),
])
new_view.sections.append(
Section(4, parentKey="record", tableRef=3, fields=[
Field(10, colRef=27),
Field(11, colRef=28),
Field(12, colRef=29),
Section(6, parentKey="record", tableRef=3, fields=[
Field(16, colRef=27),
Field(17, colRef=28),
Field(18, colRef=29),
])
)
# Check that we have a new table, only the 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),
])
self.assertTables([self.starting_table, new_table, new_table2, new_table3, summary_table])
new_view.sections.append(Section(7, parentKey="record", tableRef=5, fields=[
Field(17, colRef=35)
new_view.sections.append(Section(10, parentKey="record", tableRef=5, fields=[
Field(26, colRef=35)
]))
self.assertViews([new_view])
@ -311,26 +315,26 @@ class TestUserActions(test_engine.EngineTestCase):
]),
]),
View(2, sections=[
Section(3, parentKey="detail", tableRef=1, fields=[
Field(7, colRef=2),
Field(8, colRef=3),
Field(9, colRef=4),
Section(4, parentKey="detail", tableRef=1, fields=[
Field(10, colRef=2),
Field(11, colRef=3),
Field(12, colRef=4),
]),
Section(5, parentKey="record", tableRef=2, fields=[
Field(13, colRef=5),
Field(14, colRef=7),
Field(15, colRef=8),
Section(6, parentKey="record", tableRef=2, fields=[
Field(16, colRef=5),
Field(17, colRef=7),
Field(18, colRef=8),
]),
Section(8, parentKey='record', tableRef=3, fields=[
Field(21, colRef=10),
Field(22, colRef=11),
Field(23, colRef=12),
Section(10, parentKey='record', tableRef=3, fields=[
Field(27, colRef=10),
Field(28, colRef=11),
Field(29, colRef=12),
]),
]),
View(3, sections=[
Section(6, parentKey="chart", tableRef=1, fields=[
Field(16, colRef=2),
Field(17, colRef=3),
Section(7, parentKey="chart", tableRef=1, fields=[
Field(19, colRef=2),
Field(20, colRef=3),
]),
])
])
@ -468,10 +472,10 @@ class TestUserActions(test_engine.EngineTestCase):
{'title': 'Z'}])
self.assertTableData('_grist_Tables', cols="subset", data=[
['id', 'tableId', 'primaryViewId', 'rawViewSectionRef'],
[1, 'Z', 1, 2],
[2, 'Z_summary_state', 0, 4],
[3, 'Table1', 0, 7],
['id', 'tableId', 'primaryViewId', 'rawViewSectionRef', 'recordCardViewSectionRef'],
[1, 'Z', 1, 2, 3],
[2, 'Z_summary_state', 0, 5, 0],
[3, 'Table1', 0, 8, 9],
])
self.assertTableData('_grist_Views', cols="subset", data=[
['id', 'name'],
@ -485,11 +489,11 @@ class TestUserActions(test_engine.EngineTestCase):
{'id': 'city', 'type': 'Text'},
]])
self.assertTableData('_grist_Tables', cols="subset", data=[
['id', 'tableId', 'primaryViewId', 'rawViewSectionRef'],
[1, 'Z', 1, 2],
[2, 'Z_summary_state', 0, 4],
[3, 'Table1', 0, 7],
[4, 'Stations', 4, 10],
['id', 'tableId', 'primaryViewId', 'rawViewSectionRef', 'recordCardViewSectionRef'],
[1, 'Z', 1, 2, 3],
[2, 'Z_summary_state', 0, 5, 0],
[3, 'Table1', 0, 8, 9],
[4, 'Stations', 4, 12, 13],
])
self.assertTableData('_grist_Views', cols="subset", data=[
['id', 'name'],
@ -542,32 +546,32 @@ class TestUserActions(test_engine.EngineTestCase):
]),
]),
View(2, sections=[
Section(3, parentKey="detail", tableRef=1, fields=[
Field(7, colRef=2),
Field(8, colRef=3),
Field(9, colRef=4),
Section(4, parentKey="detail", tableRef=1, fields=[
Field(10, colRef=2),
Field(11, colRef=3),
Field(12, colRef=4),
]),
Section(5, parentKey="record", tableRef=2, fields=[
Field(13, colRef=5),
Field(14, colRef=7),
Field(15, colRef=8),
Section(6, parentKey="record", tableRef=2, fields=[
Field(16, colRef=5),
Field(17, colRef=7),
Field(18, colRef=8),
]),
Section(8, parentKey='record', tableRef=3, fields=[
Field(21, colRef=10),
Field(22, colRef=11),
Field(23, colRef=12),
Section(10, parentKey='record', tableRef=3, fields=[
Field(27, colRef=10),
Field(28, colRef=11),
Field(29, colRef=12),
]),
]),
View(3, sections=[
Section(6, parentKey="chart", tableRef=1, fields=[
Field(16, colRef=2),
Field(17, colRef=3),
Section(7, parentKey="chart", tableRef=1, fields=[
Field(19, colRef=2),
Field(20, colRef=3),
]),
])
])
# 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([
View(1, sections=[
@ -578,16 +582,16 @@ class TestUserActions(test_engine.EngineTestCase):
]),
]),
View(2, sections=[
Section(3, parentKey="detail", tableRef=1, fields=[
Field(7, colRef=2),
Field(8, colRef=3),
Field(9, colRef=4),
Section(4, parentKey="detail", tableRef=1, fields=[
Field(10, colRef=2),
Field(11, colRef=3),
Field(12, colRef=4),
])
]),
View(3, sections=[
Section(6, parentKey="chart", tableRef=1, fields=[
Field(16, colRef=2),
Field(17, colRef=3),
Section(7, parentKey="chart", tableRef=1, fields=[
Field(19, colRef=2),
Field(20, colRef=3),
]),
])
])
@ -613,8 +617,8 @@ class TestUserActions(test_engine.EngineTestCase):
self.assertEqual(count_calls[0], 0)
# Do a schema action to ensure it gets called: this causes a table rename.
# 7 is id of raw view section for the Tabl1 table
self.apply_user_action(['UpdateRecord', '_grist_Views_section', 7, {'title': 'C'}])
# 8 is id of raw view section for the Table1 table
self.apply_user_action(['UpdateRecord', '_grist_Views_section', 8, {'title': 'C'}])
self.assertEqual(count_calls[0], 1)
self.assertTableData('_grist_Tables', cols="subset", data=[
@ -1403,6 +1407,7 @@ class TestUserActions(test_engine.EngineTestCase):
["id", "parentId", "tableRef"],
[1, 1, 2],
[2, 0, 2], # the raw view section
[3, 0, 2], # the record card view section
])
self.assertTableData('_grist_Views_section_field', cols="subset", data=[
["id", "parentId"],
@ -1414,6 +1419,11 @@ class TestUserActions(test_engine.EngineTestCase):
[4, 2],
[5, 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
@ -1433,6 +1443,7 @@ class TestUserActions(test_engine.EngineTestCase):
["id", "parentId", "tableRef"],
[1, 1, 2],
[2, 0, 2], # the raw view section
[3, 0, 2], # the record card view section
])
self.assertTableData('_grist_Views_section_field', cols="subset", data=[
["id", "parentId"],
@ -1444,6 +1455,45 @@ class TestUserActions(test_engine.EngineTestCase):
[4, 2],
[5, 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):

@ -918,7 +918,12 @@
// Raw data widget
["AddRecord", "_grist_Views_section", 2, {"borderWidth": 1, "defaultWidth": 100, "parentKey": "record", "tableRef": 4, "title": ""}],
["AddRecord", "_grist_Views_section_field", 2, {"colRef": 34, "parentId": 2, "parentPos": 2.0}],
["UpdateRecord", "_grist_Tables", 4, {"primaryViewId": 1, "rawViewSectionRef": 2}],
// 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.
["AddColumn", "Bar", "world",
@ -927,9 +932,10 @@
{"colId": "world", "parentPos": 13.0,
"formula": "rec.hello.upper()", "parentId": 4, "type": "Text",
"isFormula": true, "label": "world", "widgetOptions": ""}],
["AddRecord", "_grist_Views_section_field", 3, {"colRef": 35, "parentId": 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],
"undo": [
@ -943,10 +949,13 @@
["RemoveRecord", "_grist_Views_section_field", 1],
["RemoveRecord", "_grist_Views_section", 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"],
["RemoveRecord", "_grist_Tables_column", 35],
["RemoveRecord", "_grist_Views_section_field", 3]
["RemoveRecord", "_grist_Views_section_field", 4],
["RemoveRecord", "_grist_Views_section_field", 5]
],
"retValue": [
{
@ -1257,15 +1266,16 @@
{"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": ""}],
["BulkAddRecord", "_grist_Views_section_field", [3, 4], {"colRef": [31, 32], "parentId": [2, 2], "parentPos": [3.0, 4.0]}],
["UpdateRecord", "_grist_Tables", 4, {"primaryViewId": 1, "rawViewSectionRef": 2}],
["BulkRemoveRecord", "_grist_Views_section_field", [1, 3]],
["AddRecord", "_grist_Views_section", 3, {"borderWidth": 1, "defaultWidth": 100, "parentKey": "single", "tableRef": 4, "title": ""}],
["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],
["RemoveColumn", "ViewTest", "hello"]
],
"direct": [true, true, true, true, true, true, true, true, true,
true, true,
true, true, true],
true, true, true, true, true, true, true],
"undo": [
["RemoveTable", "ViewTest"],
["RemoveRecord", "_grist_Tables", 4],
@ -1277,8 +1287,11 @@
["BulkRemoveRecord", "_grist_Views_section_field", [1,2]],
["RemoveRecord", "_grist_Views_section", 2],
["BulkRemoveRecord", "_grist_Views_section_field", [3, 4]],
["UpdateRecord", "_grist_Tables", 4, {"primaryViewId": 0, "rawViewSectionRef": 0}],
["BulkAddRecord", "_grist_Views_section_field", [1, 3], {"colRef": [31, 31], "parentId": [1, 2], "parentPos": [1.0, 3.0]}],
["RemoveRecord", "_grist_Views_section", 3],
["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,
{"colId": "hello", "parentPos": 9.0,
"parentId": 4, "type": "Text"
@ -2199,7 +2212,8 @@
{"tableRef": 4, "defaultWidth": 100, "borderWidth": 1,
"parentId": 1, "parentKey": "record", "sortColRefs": "[]", "title": ""}],
["AddRecord", "_grist_Views_section", 2, {"borderWidth": 1, "defaultWidth": 100, "parentKey": "record", "tableRef": 4, "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", [
{"id": "manualSort", "formula": "", "isFormula": false, "type": "ManualSortPos"},
{"isFormula": false, "formula": "", "type": "Text", "id": "hello"},
@ -2223,21 +2237,23 @@
{"type": "raw_data", "name": "Bar"}],
["AddRecord", "_grist_TabBar", 2, {"tabPos": 2.0, "viewRef": 2}],
["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,
"parentId": 2, "parentKey": "record", "sortColRefs": "[]", "title": ""}],
["BulkAddRecord", "_grist_Views_section_field", [1,2,3],
{"parentId": [3,3,3], "colRef": [32,33,34], "parentPos": [1.0,2.0,3.0]}],
["AddRecord", "_grist_Views_section", 4, {"borderWidth": 1, "defaultWidth": 100, "parentKey": "record", "tableRef": 5, "title": ""}],
["BulkAddRecord", "_grist_Views_section_field", [4, 5, 6], {"colRef": [32, 33, 34], "parentId": [4, 4, 4], "parentPos": [4.0, 5.0, 6.0]}],
["UpdateRecord", "_grist_Tables", 5, {"primaryViewId": 2, "rawViewSectionRef": 4}],
{"parentId": [4,4,4], "colRef": [32,33,34], "parentPos": [1.0,2.0,3.0]}],
["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": [5, 5, 5], "parentPos": [4.0, 5.0, 6.0]}],
["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", 2, {"foo": 1, "hello": "b", "manualSort": 2.0}],
["AddRecord", "Bar", 3, {"foo": 1, "hello": "c", "manualSort": 3.0}],
["BulkUpdateRecord", "Bar", [1, 2, 3], {"world": ["A", "B", "C"]}]
],
"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, false],
"undo": [
@ -2249,18 +2265,21 @@
["RemoveRecord", "_grist_Pages", 1],
["RemoveRecord", "_grist_Views_section", 1],
["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"],
["RemoveRecord", "_grist_Tables", 5],
["BulkRemoveRecord", "_grist_Tables_column", [31,32,33,34]],
["RemoveRecord", "_grist_Views", 2],
["RemoveRecord", "_grist_TabBar", 2],
["RemoveRecord", "_grist_Pages", 2],
["RemoveRecord", "_grist_Views_section", 3],
["BulkRemoveRecord", "_grist_Views_section_field", [1,2,3]],
["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]],
["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", 2],
["RemoveRecord", "Bar", 3]
@ -2281,7 +2300,7 @@
"id": 5,
"columns": ["hello", "world", "foo"],
"views": [
{ "sections": [ 3 ], "id": 2 }
{ "sections": [ 4 ], "id": 2 }
]
},
// AddRecord retValues
@ -2333,10 +2352,12 @@
"parentId": 1, "parentKey": "record", "sortColRefs": "[]", "title": ""}],
// Raw data widget
["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.
["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": [
["RemoveTable", "Foo"],
["RemoveRecord", "_grist_Tables", 4],
@ -2346,7 +2367,8 @@
["RemoveRecord", "_grist_Pages", 1],
["RemoveRecord", "_grist_Views_section", 1],
["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"],
"ACTIONS": {
"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}],
["RemoveRecord", "_grist_TabBar", 1],
["RemoveRecord", "_grist_Pages", 1],
@ -2369,11 +2392,12 @@
["RemoveRecord", "_grist_Tables", 4],
["RemoveTable", "Foo"]
],
"direct": [true, true, true, true, true, true, true, true, true],
"direct": [true, true, true, true, true, true, true, true, true, true],
"undo": [
["BulkAddRecord", "_grist_Views_section", [1, 2],
{"borderWidth": [1, 1], "defaultWidth": [100, 100], "parentId": [1, 0],
"parentKey": ["record", "record"], "sortColRefs": ["[]", ""], "tableRef": [4, 4]}],
["BulkAddRecord", "_grist_Views_section", [1, 2, 3],
{"borderWidth": [1, 1, 1], "defaultWidth": [100, 100, 100], "parentId": [1, 0, 0],
"parentKey": ["record", "record", "single"], "sortColRefs": ["[]", "", ""], "tableRef": [4, 4, 4]}],
["UpdateRecord", "_grist_Tables", 4, {"recordCardViewSectionRef": 3}],
["UpdateRecord", "_grist_Tables", 4, {"rawViewSectionRef": 2}],
["AddRecord", "_grist_TabBar", 1, {"tabPos": 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")
# 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.
for a in extra_actions:
self._do_doc_action(a)
@ -1300,13 +1318,15 @@ class UserActions(object):
def _removeViewSectionRecords(self, table_id, row_ids):
"""
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.
"""
recs = [rec for i, rec in self._bulk_action_iter(table_id, row_ids)]
for rec in recs:
if rec.isRaw:
raise ValueError("Cannot remove raw view section")
if rec.isRecordCard:
raise ValueError("Cannot remove record card view section")
self._doRemoveViewSectionRecords(recs)
def _doRemoveViewSectionRecords(self, recs):
@ -1355,16 +1375,35 @@ class UserActions(object):
ret = self.doAddColumn(table_id, col_id, col_info)
if not transform and table_rec.rawViewSectionRef:
# Add a field for this column to the "raw_data" section for this table.
# TODO: the position of the inserted field or of the inserted column will often be
# bogus, since fields and columns are not the same. This requires better coordination
# with the client-side.
self._docmodel.insert(
table_rec.rawViewSectionRef.fields,
col_info.get('_position'),
colRef=ret['colRef']
)
if not transform:
if table_rec.rawViewSectionRef:
# Add a field for this column to the "raw_data" section for this table.
# TODO: the position of the inserted field or of the inserted column will often be
# bogus, since fields and columns are not the same. This requires better coordination
# with the client-side.
self._docmodel.insert(
table_rec.rawViewSectionRef.fields,
col_info.get('_position'),
colRef=ret['colRef']
)
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
@ -1807,7 +1846,8 @@ class UserActions(object):
columns,
manual_sort=True,
primary_view=True,
raw_section=True)
raw_section=True,
record_card_section=True)
@useraction
@ -1821,12 +1861,14 @@ class UserActions(object):
columns,
manual_sort=True,
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,
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.
"""
@ -1887,10 +1929,20 @@ class UserActions(object):
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"], {
'primaryViewId': primary_view["id"] if primary_view else 0,
'rawViewSectionRef': raw_section.id if raw_section else 0,
'recordCardViewSectionRef': record_card_section.id if record_card_section else 0,
})
return result
@ -1924,6 +1976,7 @@ class UserActions(object):
# Copy the columns from the raw view section to a new table.
raw_section = existing_table.rawViewSectionRef
record_card_section = existing_table.recordCardViewSectionRef
raw_section_cols = [f.colRef for f in raw_section.fields]
col_info = [summary.make_col_info(col=c) for c in raw_section_cols]
columns = [summary.get_colinfo_dict(ci, with_id=True) for ci in col_info]
@ -1933,13 +1986,19 @@ class UserActions(object):
manual_sort=True,
primary_view=False,
raw_section=True,
record_card_section=True,
)
new_table_id = result['table_id']
new_raw_section = self._docmodel.get_table_rec(new_table_id).rawViewSectionRef
# Copy view section options to the new raw view section.
self._docmodel.update([new_raw_section], options=raw_section.options)
new_table = self._docmodel.get_table_rec(new_table_id)
new_raw_section = new_table.rawViewSectionRef
new_record_card_section = new_table.recordCardViewSectionRef
# 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 = {}
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];
assert.isOk(planetsTable);
const planetsSections = sections.filterRecords({tableRef: planetsTable.id});
assert.lengthOf(planetsSections, 3);
assert.equal(planetsSections[0].parentId, planetsSections[2].parentId);
assert.deepEqual(planetsSections.map(s => s.linkTargetColRef), [0, 0, 0]);
assert.deepEqual(planetsSections.map(s => s.linkSrcSectionRef), [0, 0, 0]);
assert.deepEqual(planetsSections.map(s => s.linkSrcColRef), [0, 0, 0]);
assert.lengthOf(planetsSections, 4);
assert.equal(planetsSections[0].parentId, planetsSections[3].parentId);
assert.deepEqual(planetsSections.map(s => s.linkTargetColRef), [0, 0, 0, 0]);
assert.deepEqual(planetsSections.map(s => s.linkSrcSectionRef), [0, 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.
await gu.getPageItem('Moons').click();
@ -151,8 +151,8 @@ describe("LinkingErrors", function() {
['AddEmptyTable', null],
['UpdateRecord', '_grist_Tables_column', 6, {type: 'Ref:Table1'}],
['CreateViewSection', 2, 1, 'record', null, null],
['UpdateRecord', '_grist_Views_section', 3, {linkSrcSectionRef: 1, linkSrcColRef: 0, linkTargetColRef: 0}],
['UpdateRecord', '_grist_Views_section', 6, {linkSrcSectionRef: 1, linkSrcColRef: 0, linkTargetColRef: 6}],
['UpdateRecord', '_grist_Views_section', 4, {linkSrcSectionRef: 1, linkSrcColRef: 0, linkTargetColRef: 0}],
['UpdateRecord', '_grist_Views_section', 8, {linkSrcSectionRef: 1, linkSrcColRef: 0, linkTargetColRef: 6}],
[
'UpdateRecord',
'_grist_Views',

@ -374,7 +374,7 @@ describe('RawData', function () {
// The last table should have disabled remove button.
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);
});
@ -542,8 +542,8 @@ describe('RawData', function () {
await gu.selectSectionByTitle("COUNTRY Card List");
await gu.getDetailCell('Code', 1).click();
await gu.addNewSection(/Chart/, /CountryLanguage/);
// s19 is the new section id, we also strip row/column.
let chartLink = replaceAnchor(await gu.getAnchor(), {s: '19', a: '2'});
// s22 is the new section id, we also strip row/column.
let chartLink = replaceAnchor(await gu.getAnchor(), {s: '22', a: '2'});
await gu.getPageItem('City').click();
chartLink = (await driver.getCurrentUrl()) + '#' + chartLink.split('#')[1];
await waitForAnchorPopup(chartLink);
@ -623,7 +623,7 @@ async function clickDuplicateTable() {
}
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) {
@ -681,7 +681,7 @@ async function waitForRawData() {
async function isRemovable(tableId: string){
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);
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() {
const checkRefCell = stackWrapFunc(async (col: string, rowNum: number, expValue: string) => {
// 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);
await driver.sendKeys(Key.ENTER);
// 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() {
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');
await driver.sendKeys(Key.ENTER);
assert.deepEqual(await getACOptions(3), [
@ -493,7 +495,8 @@ describe('ReferenceColumns', function() {
it('should highlight matching parts of items', async function() {
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');
await driver.sendKeys(Key.ENTER);
await driver.findWait('.test-ref-editor-item', 1000);
@ -505,7 +508,8 @@ describe('ReferenceColumns', function() {
['Re']);
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');
assert.deepEqual(
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
await gu.actions.addNewSection('New', 'Table');
await gu.waitForServer();
await $('.test-viewlayout-section-4').click();
await $('.test-viewlayout-section-6').click();
await gu.addRecord(['green']);
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");
assert.equal(menus.length, tableIdList.length);
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 waitForServer();
}
@ -1521,8 +1521,9 @@ export async function openRawTable(tableId: string) {
export async function renameRawTable(tableId: string, newName: string) {
await driver.find(`.test-raw-data-table .test-raw-data-table-id-${tableId}`)
.findClosest('.test-raw-data-table')
.find('.test-widget-title-text')
.find('.test-raw-data-table-menu')
.click();
await driver.find('.test-raw-data-menu-rename-table').click();
const input = await driver.find(".test-widget-title-table-name-input");
await input.doClear();
await input.click();

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

Loading…
Cancel
Save