diff --git a/app/client/components/DataTables.ts b/app/client/components/DataTables.ts index c606464f..c75cca09 100644 --- a/app/client/components/DataTables.ts +++ b/app/client/components/DataTables.ts @@ -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; @@ -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) { 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) { 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; +`); diff --git a/app/client/components/DetailView.js b/app/client/components/DetailView.js index 99058ea4..9e7e4f7a 100644 --- a/app/client/components/DetailView.js +++ b/app/client/components/DetailView.js @@ -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()), }; } diff --git a/app/client/components/GridView.js b/app/client/components/GridView.js index 13b5262c..e32cf73e 100644 --- a/app/client/components/GridView.js +++ b/app/client/components/GridView.js @@ -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) { diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index 1026014b..f2fc59cc 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -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 = 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; 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 = Observable.create(this, null); - private _activeContent: Computed; + private _popupSectionOptions: Observable = Observable.create(this, null); + private _activeContent: Computed; private _docTutorialHolder = Holder.create(this); private _isRickRowing: Observable = Observable.create(this, false); private _showBackgroundVideoPlayer: Observable = Observable.create(this, false); private _backgroundVideoPlayerHolder: Holder = 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()) { diff --git a/app/client/components/RawDataPage.ts b/app/client/components/RawDataPage.ts index e4e05e43..703ed75b 100644 --- a/app/client/components/RawDataPage.ts +++ b/app/client/components/RawDataPage.ts @@ -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; diff --git a/app/client/components/RecordCardPopup.ts b/app/client/components/RecordCardPopup.ts new file mode 100644 index 00000000..bdfe9967 --- /dev/null +++ b/app/client/components/RecordCardPopup.ts @@ -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; + } +`); diff --git a/app/client/components/RecordLayout.js b/app/client/components/RecordLayout.js index 17941327..ad087353 100644 --- a/app/client/components/RecordLayout.js +++ b/app/client/components/RecordLayout.js @@ -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') ) ), diff --git a/app/client/components/RefSelect.ts b/app/client/components/RefSelect.ts index dade783a..08af4e8b 100644 --- a/app/client/components/RefSelect.ts +++ b/app/client/components/RefSelect.ts @@ -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()]); diff --git a/app/client/components/ViewConfigTab.js b/app/client/components/ViewConfigTab.js index 409e05bd..2aac898a 100644 --- a/app/client/components/ViewConfigTab.js +++ b/app/client/components/ViewConfigTab.js @@ -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) ); })); diff --git a/app/client/components/ViewLayout.ts b/app/client/components/ViewLayout.ts index 75b24453..b1ed218e 100644 --- a/app/client/components/ViewLayout.ts +++ b/app/client/components/ViewLayout.ts @@ -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(); diff --git a/app/client/components/buildViewSectionDom.ts b/app/client/components/buildViewSectionDom.ts index e498e108..fc45cf7d 100644 --- a/app/client/components/buildViewSectionDom.ts +++ b/app/client/components/buildViewSectionDom.ts @@ -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) ) diff --git a/app/client/components/commandList.ts b/app/client/components/commandList.ts index 54a972df..08f2bb0f 100644 --- a/app/client/components/commandList.ts +++ b/app/client/components/commandList.ts @@ -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', diff --git a/app/client/models/entities/TableRec.ts b/app/client/models/entities/TableRec.ts index c247b64f..4cb2297b 100644 --- a/app/client/models/entities/TableRec.ts +++ b/app/client/models/entities/TableRec.ts @@ -15,6 +15,7 @@ export interface TableRec extends IRowModel<"_grist_Tables"> { primaryView: ko.Computed; rawViewSection: ko.Computed; + recordCardViewSection: ko.Computed; summarySource: ko.Computed; // 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. diff --git a/app/client/models/entities/ViewSectionRec.ts b/app/client/models/entities/ViewSectionRec.ts index 56791faf..bc170587 100644 --- a/app/client/models/entities/ViewSectionRec.ts +++ b/app/client/models/entities/ViewSectionRec.ts @@ -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; + + tableRecordCard: ko.Computed + isRecordCard: ko.Computed; + + /** True if this section is disabled. Currently only used by Record Card sections. */ + disabled: modelUtil.KoSaveableObservable; + + /** + * True if the Record Card section of this section's table is disabled. Shortcut for + * `this.tableRecordCard().disabled()`. + */ + isTableRecordCardDisabled: ko.Computed; + isVirtual: ko.Computed; isCollapsed: ko.Computed; @@ -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(), diff --git a/app/client/models/features.ts b/app/client/models/features.ts index 353fe0a3..a1bcc03b 100644 --- a/app/client/models/features.ts +++ b/app/client/models/features.ts @@ -33,3 +33,7 @@ export function PERMITTED_CUSTOM_WIDGETS(): Observable { } return G.window.PERMITTED_CUSTOM_WIDGETS; } + +export function RECORD_CARDS() { + return Boolean(getGristConfig().experimentalPlugins); +} diff --git a/app/client/ui/CardContextMenu.ts b/app/client/ui/CardContextMenu.ts new file mode 100644 index 00000000..f5ad6fd7 --- /dev/null +++ b/app/client/ui/CardContextMenu.ts @@ -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; +} diff --git a/app/client/ui/CellContextMenu.ts b/app/client/ui/CellContextMenu.ts index 7952cd82..9bb13702 100644 --- a/app/client/ui/CellContextMenu.ts +++ b/app/client/ui/CellContextMenu.ts @@ -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 = []; @@ -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 ] ), diff --git a/app/client/ui/FieldContextMenu.ts b/app/client/ui/FieldContextMenu.ts index 68b3735f..ddeeccd1 100644 --- a/app/client/ui/FieldContextMenu.ts +++ b/app/client/ui/FieldContextMenu.ts @@ -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 [ diff --git a/app/client/ui/Pages.ts b/app/client/ui/Pages.ts index 3d13fd7a..47ccfd42 100644 --- a/app/client/ui/Pages.ts +++ b/app/client/ui/Pages.ts @@ -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. diff --git a/app/client/ui/RightPanel.ts b/app/client/ui/RightPanel.ts index 27232392..da91ffb4 100644 --- a/app/client/ui/RightPanel.ts +++ b/app/client/ui/RightPanel.ts @@ -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', ` diff --git a/app/client/ui/RowContextMenu.ts b/app/client/ui/RowContextMenu.ts index f05cb255..87b75e17 100644 --- a/app/client/ui/RowContextMenu.ts +++ b/app/client/ui/RowContextMenu.ts @@ -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 diff --git a/app/client/ui/ShareMenu.ts b/app/client/ui/ShareMenu.ts index 1e922c0a..7386523f 100644 --- a/app/client/ui/ShareMenu.ts +++ b/app/client/ui/ShareMenu.ts @@ -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}; } `); diff --git a/app/client/ui/ViewLayoutMenu.ts b/app/client/ui/ViewLayoutMenu.ts index 8a39ad7f..a42f1c13 100644 --- a/app/client/ui/ViewLayoutMenu.ts +++ b/app/client/ui/ViewLayoutMenu.ts @@ -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')), ]; } diff --git a/app/client/ui/ViewSectionMenu.ts b/app/client/ui/ViewSectionMenu.ts index e7241167..6329eb87 100644 --- a/app/client/ui/ViewSectionMenu.ts +++ b/app/client/ui/ViewSectionMenu.ts @@ -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'), diff --git a/app/client/ui/WidgetTitle.ts b/app/client/ui/WidgetTitle.ts index fb13bf3f..bb08d16f 100644 --- a/app/client/ui/WidgetTitle.ts +++ b/app/client/ui/WidgetTitle.ts @@ -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, + 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, + disabled?: boolean, +} + +function buildRenamableTitle( vs: ViewSectionRec, title: Observable, description: Observable, - 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 { diff --git a/app/client/ui2018/menus.ts b/app/client/ui2018/menus.ts index 9650ce13..ba2e508c 100644 --- a/app/client/ui2018/menus.ts +++ b/app/client/ui2018/menus.ts @@ -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}; } `); diff --git a/app/client/widgets/CellStyle.ts b/app/client/widgets/CellStyle.ts index a7192baa..6699dd4d 100644 --- a/app/client/widgets/CellStyle.ts +++ b/app/client/widgets/CellStyle.ts @@ -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'), diff --git a/app/client/widgets/Reference.ts b/app/client/widgets/Reference.ts index f060222e..296b39a7 100644 --- a/app/client/widgets/Reference.ts +++ b/app/client/widgets/Reference.ts @@ -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; private _visibleColRef: Computed; private _validCols: Computed>>; @@ -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', ` diff --git a/app/common/gristUrls.ts b/app/common/gristUrls.ts index dc2a5a27..cbc479ed 100644 --- a/app/common/gristUrls.ts +++ b/app/common/gristUrls.ts @@ -290,9 +290,15 @@ export function encodeUrl(gristConfig: Partial, 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) { const partValue = hash[key]; if (partValue) { @@ -480,13 +486,13 @@ export function decodeUrl(gristConfig: Partial, 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>; + ] as Array>; for (const key of keys) { let ch: string; if (key === 'rowId' && hashMap.has('rr')) { @@ -504,7 +510,9 @@ export function decodeUrl(gristConfig: Partial, 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 diff --git a/app/common/schema.ts b/app/common/schema.ts index dfe7268f..853141a6 100644 --- a/app/common/schema.ts +++ b/app/common/schema.ts @@ -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": { diff --git a/app/server/lib/initialDocSql.ts b/app/server/lib/initialDocSql.ts index 0508dec2..d853adcc 100644 --- a/app/server/lib/initialDocSql.ts +++ b/app/server/lib/initialDocSql.ts @@ -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); diff --git a/app/server/lib/sendAppPage.ts b/app/server/lib/sendAppPage.ts index 6215efc9..991b39fb 100644 --- a/app/server/lib/sendAppPage.ts +++ b/app/server/lib/sendAppPage.ts @@ -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, }; } diff --git a/buildtools/fly-template.toml b/buildtools/fly-template.toml index 2345f059..4eba32ff 100644 --- a/buildtools/fly-template.toml +++ b/buildtools/fly-template.toml @@ -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}" diff --git a/sandbox/grist/docmodel.py b/sandbox/grist/docmodel.py index 4197c1bb..9083368e 100644 --- a/sandbox/grist/docmodel.py +++ b/sandbox/grist/docmodel.py @@ -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.""" diff --git a/sandbox/grist/migrations.py b/sandbox/grist/migrations.py index 419b9f1b..b971e0e5 100644 --- a/sandbox/grist/migrations.py +++ b/sandbox/grist/migrations.py @@ -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) diff --git a/sandbox/grist/schema.py b/sandbox/grist/schema.py index 76897f0b..0e4ecdde 100644 --- a/sandbox/grist/schema.py +++ b/sandbox/grist/schema.py @@ -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. diff --git a/sandbox/grist/summary.py b/sandbox/grist/summary.py index 9df76a7f..178e0215 100644 --- a/sandbox/grist/summary.py +++ b/sandbox/grist/summary.py @@ -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, diff --git a/sandbox/grist/test_column_actions.py b/sandbox/grist/test_column_actions.py index b69b48e9..dde2363a 100644 --- a/sandbox/grist/test_column_actions.py +++ b/sandbox/grist/test_column_actions.py @@ -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, '[]' ], ]) diff --git a/sandbox/grist/test_display_cols.py b/sandbox/grist/test_display_cols.py index 7729e042..789f567e 100644 --- a/sandbox/grist/test_display_cols.py +++ b/sandbox/grist/test_display_cols.py @@ -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"}], diff --git a/sandbox/grist/test_docmodel.py b/sandbox/grist/test_docmodel.py index 4f78598c..f49e1c81 100644 --- a/sandbox/grist/test_docmodel.py +++ b/sandbox/grist/test_docmodel.py @@ -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): diff --git a/sandbox/grist/test_import_actions.py b/sandbox/grist/test_import_actions.py index c7e919ed..a456562c 100644 --- a/sandbox/grist/test_import_actions.py +++ b/sandbox/grist/test_import_actions.py @@ -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 ]) diff --git a/sandbox/grist/test_table_actions.py b/sandbox/grist/test_table_actions.py index 42f98724..71557c3c 100644 --- a/sandbox/grist/test_table_actions.py +++ b/sandbox/grist/test_table_actions.py @@ -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), ]), ]), ]) diff --git a/sandbox/grist/test_useractions.py b/sandbox/grist/test_useractions.py index 5fd54f41..7ed2ee5a 100644 --- a/sandbox/grist/test_useractions.py +++ b/sandbox/grist/test_useractions.py @@ -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): diff --git a/sandbox/grist/testscript.json b/sandbox/grist/testscript.json index 5c5b7cf5..65774ca3 100644 --- a/sandbox/grist/testscript.json +++ b/sandbox/grist/testscript.json @@ -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}], diff --git a/sandbox/grist/useractions.py b/sandbox/grist/useractions.py index 21f4c31e..5acc2c55 100644 --- a/sandbox/grist/useractions.py +++ b/sandbox/grist/useractions.py @@ -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): diff --git a/test/fixtures/docs/Hello.grist b/test/fixtures/docs/Hello.grist index 6effd2ba..20ba9037 100644 Binary files a/test/fixtures/docs/Hello.grist and b/test/fixtures/docs/Hello.grist differ diff --git a/test/fixtures/docs/World-v39.grist b/test/fixtures/docs/World-v39.grist new file mode 100644 index 00000000..ec0e91b3 Binary files /dev/null and b/test/fixtures/docs/World-v39.grist differ diff --git a/test/nbrowser/LinkingErrors.ts b/test/nbrowser/LinkingErrors.ts index 5d5d4aa6..ebbdee29 100644 --- a/test/nbrowser/LinkingErrors.ts +++ b/test/nbrowser/LinkingErrors.ts @@ -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', diff --git a/test/nbrowser/RawData.ts b/test/nbrowser/RawData.ts index 091c90f2..815d5664 100644 --- a/test/nbrowser/RawData.ts +++ b/test/nbrowser/RawData.ts @@ -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; } diff --git a/test/nbrowser/ReferenceColumns.ts b/test/nbrowser/ReferenceColumns.ts index 485019f6..bbeafd06 100644 --- a/test/nbrowser/ReferenceColumns.ts +++ b/test/nbrowser/ReferenceColumns.ts @@ -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()), diff --git a/test/nbrowser/TypeChange.ntest.js b/test/nbrowser/TypeChange.ntest.js index 1f677d88..1e70bf1d 100644 --- a/test/nbrowser/TypeChange.ntest.js +++ b/test/nbrowser/TypeChange.ntest.js @@ -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']); diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index 37172a1f..cfc574a3 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -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(); diff --git a/test/server/lib/DocApi.ts b/test/server/lib/DocApi.ts index 07798345..1075079c 100644 --- a/test/server/lib/DocApi.ts +++ b/test/server/lib/DocApi.ts @@ -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,