diff --git a/app/client/components/BaseView.js b/app/client/components/BaseView.js index a3bddccd..f1e3aab2 100644 --- a/app/client/components/BaseView.js +++ b/app/client/components/BaseView.js @@ -62,7 +62,7 @@ function BaseView(gristDoc, viewSectionModel, options) { if (this.viewSection.table().summarySourceTable()) { const groupGetter = this.tableModel.tableData.getRowPropFunc('group'); this._mainRowSource = rowset.BaseFilteredRowSource.create(this, - rowId => !gristTypes.isEmptyList(groupGetter(rowId))); + rowId => !groupGetter || !gristTypes.isEmptyList(groupGetter(rowId))); this._mainRowSource.subscribeTo(this._queryRowSource); } else { this._mainRowSource = this._queryRowSource; diff --git a/app/client/components/CodeEditorPanel.js b/app/client/components/CodeEditorPanel.js deleted file mode 100644 index d6b80301..00000000 --- a/app/client/components/CodeEditorPanel.js +++ /dev/null @@ -1,71 +0,0 @@ -var _ = require('underscore'); -var ko = require('knockout'); -var BackboneEvents = require('backbone').Events; - -var dispose = require('../lib/dispose'); -var dom = require('../lib/dom'); -var kd = require('../lib/koDom'); - -// Rather than require the whole of highlight.js, require just the core with the one language we -// need, to keep our bundle smaller and the build faster. -var hljs = require('highlight.js/lib/highlight'); -hljs.registerLanguage('python', require('highlight.js/lib/languages/python')); - -function CodeEditorPanel(gristDoc) { - this._gristDoc = gristDoc; - this._schema = ko.observable(''); - this._denied = ko.observable(false); - - this.listenTo(this._gristDoc, 'schemaUpdateAction', this.onSchemaAction); - this.onSchemaAction(); // Fetch the schema to initialize -} -dispose.makeDisposable(CodeEditorPanel); -_.extend(CodeEditorPanel.prototype, BackboneEvents); - -CodeEditorPanel.prototype.buildDom = function() { - // The tabIndex enables the element to gain focus, and the .clipboard class prevents the - // Clipboard module from re-grabbing it. This is a quick fix for the issue where clipboard - // interferes with text selection. TODO it should be possible for the Clipboard to never - // interfere with text selection even for un-focusable elements. - return dom('div.g-code-panel.clipboard', - {tabIndex: "-1"}, - kd.maybe(this._denied, () => dom('div.g-code-panel-denied', - dom('h2', kd.text('Access denied')), - dom('div', kd.text('Code View is available only when you have full document access.')), - )), - kd.scope(this._schema, function(schema) { - // The reason to scope and rebuild instead of using `kd.text(schema)` is because - // hljs.highlightBlock(elem) replaces `elem` with a whole new dom tree. - if (!schema) { return null; } - return dom( - 'code.g-code-viewer.python', - schema, - dom.hide, - dom.defer(function(elem) { - hljs.highlightBlock(elem); - dom.show(elem); - }) - ); - }) - ); -}; - -CodeEditorPanel.prototype.onSchemaAction = async function(actions) { - try { - const schema = await this._gristDoc.docComm.fetchTableSchema(); - if (!this.isDisposed()) { - this._schema(schema); - this._denied(false); - } - } catch (err) { - if (!String(err).match(/Cannot view code/)) { - throw err; - } - if (!this.isDisposed()) { - this._schema(''); - this._denied(true); - } - } -}; - -module.exports = CodeEditorPanel; diff --git a/app/client/components/CodeEditorPanel.ts b/app/client/components/CodeEditorPanel.ts new file mode 100644 index 00000000..fa46132e --- /dev/null +++ b/app/client/components/CodeEditorPanel.ts @@ -0,0 +1,64 @@ +import {GristDoc} from 'app/client/components/GristDoc'; +import {reportError} from 'app/client/models/errors'; +import {DisposableWithEvents} from 'app/common/DisposableWithEvents'; +import {dom, Observable} from 'grainjs'; + +// Rather than require the whole of highlight.js, require just the core with the one language we +// need, to keep our bundle smaller and the build faster. +const hljs = require('highlight.js/lib/highlight'); +hljs.registerLanguage('python', require('highlight.js/lib/languages/python')); + +export class CodeEditorPanel extends DisposableWithEvents { + private _schema = Observable.create(this, ''); + private _denied = Observable.create(this, false); + constructor(private _gristDoc: GristDoc) { + super(); + this.listenTo(_gristDoc, 'schemaUpdateAction', this._onSchemaAction.bind(this)); + this._onSchemaAction().catch(reportError); // Fetch the schema to initialize + } + + public buildDom() { + // The tabIndex enables the element to gain focus, and the .clipboard class prevents the + // Clipboard module from re-grabbing it. This is a quick fix for the issue where clipboard + // interferes with text selection. TODO it should be possible for the Clipboard to never + // interfere with text selection even for un-focusable elements. + return dom('div.g-code-panel.clipboard', + {tabIndex: "-1"}, + dom.maybe(this._denied, () => dom('div.g-code-panel-denied', + dom('h2', dom.text('Access denied')), + dom('div', dom.text('Code View is available only when you have full document access.')), + )), + dom.maybe(this._schema, (schema) => { + // The reason to scope and rebuild instead of using `kd.text(schema)` is because + // hljs.highlightBlock(elem) replaces `elem` with a whole new dom tree. + const elem = dom('code.g-code-viewer', + dom.text(schema), + dom.hide(true) + ); + setTimeout(() => { + hljs.highlightBlock(elem); + dom.showElem(elem, true); + }); + return elem; + }) + ); + } + + private async _onSchemaAction() { + try { + const schema = await this._gristDoc.docComm.fetchTableSchema(); + if (!this.isDisposed()) { + this._schema.set(schema); + this._denied.set(false); + } + } catch (err) { + if (!String(err).match(/Cannot view code/)) { + throw err; + } + if (!this.isDisposed()) { + this._schema.set(''); + this._denied.set(true); + } + } + } +} diff --git a/app/client/components/CursorMonitor.ts b/app/client/components/CursorMonitor.ts index f777e39d..408b3c7c 100644 --- a/app/client/components/CursorMonitor.ts +++ b/app/client/components/CursorMonitor.ts @@ -48,6 +48,10 @@ export class CursorMonitor extends Disposable { this._whenCursorHasChangedStoreInMemory(doc); } + public clear() { + this._store.clear(this._key); + } + private _whenCursorHasChangedStoreInMemory(doc: GristDoc) { // whenever current position changes, store it in the memory this.autoDispose(doc.cursorPosition.addListener(pos => { @@ -62,8 +66,7 @@ export class CursorMonitor extends Disposable { private _whenDocumentLoadsRestorePosition(doc: GristDoc) { // if doc was opened with a hash link, don't restore last position if (doc.hasCustomNav.get()) { - this._restored = true; - return; + return this._abortRestore(); } // if we are on raw data view, we need to set the position manually @@ -85,7 +88,9 @@ export class CursorMonitor extends Disposable { // set that we already restored the position, as some view is shown to the user this._restored = true; const viewId = doc.activeViewId.get(); - if (!isViewDocPage(viewId)) { return; } + if (!isViewDocPage(viewId)) { + return this._abortRestore(); + } const position = this._readPosition(viewId); if (position) { // Ignore error with finding desired cell. @@ -93,6 +98,11 @@ export class CursorMonitor extends Disposable { } } + private _abortRestore() { + this.clear(); + this._restored = true; + } + private _storePosition(pos: ViewCursorPos) { this._store.update(this._key, pos); } diff --git a/app/client/components/DataTables.ts b/app/client/components/DataTables.ts index b014ad2b..db87d5dc 100644 --- a/app/client/components/DataTables.ts +++ b/app/client/components/DataTables.ts @@ -5,27 +5,32 @@ import {setTestState} from 'app/client/lib/testState'; import {TableRec} from 'app/client/models/DocModel'; import {docListHeader, docMenuTrigger} from 'app/client/ui/DocMenuCss'; import {showTransientTooltip} from 'app/client/ui/tooltips'; +import {buildTableName} from 'app/client/ui/WidgetTitle'; import {buttonSelect, cssButtonSelect} from 'app/client/ui2018/buttonSelect'; import * as css from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {menu, menuItem, menuText} from 'app/client/ui2018/menus'; import {confirmModal} from 'app/client/ui2018/modals'; -import {Disposable, dom, fromKo, makeTestId, MultiHolder, styled} from 'grainjs'; +import {Computed, Disposable, dom, fromKo, makeTestId, Observable, styled} from 'grainjs'; const testId = makeTestId('test-raw-data-'); export class DataTables extends Disposable { + private _tables: Observable; + private _view: Observable; constructor(private _gristDoc: GristDoc) { super(); + // Remove tables that we don't have access to. ACL will remove tableId from those tables. + this._tables = Computed.create(this, use => + use(_gristDoc.docModel.rawTables.getObservable()) + .filter(t => Boolean(use(t.tableId)))); + // Get the user id, to remember selected layout on the next visit. + const userId = this._gristDoc.app.topAppModel.appObs.get()?.currentUser?.id ?? 0; + this._view = this.autoDispose(localStorageObs(`u=${userId}:raw:viewType`, "list")); } public buildDom() { - const holder = new MultiHolder(); - // Get the user id, to remember selected layout on the next visit. - const userId = this._gristDoc.app.topAppModel.appObs.get()?.currentUser?.id ?? 0; - const view = holder.autoDispose(localStorageObs(`u=${userId}:raw:viewType`, "list")); return container( - dom.autoDispose(holder), cssTableList( /*************** List section **********/ testId('list'), @@ -33,7 +38,7 @@ export class DataTables extends Disposable { docListHeader('Raw data tables'), cssSwitch( buttonSelect( - view, + this._view, [ {value: 'card', icon: 'TypeTable'}, {value: 'list', icon: 'TypeCardList'}, @@ -44,49 +49,65 @@ export class DataTables extends Disposable { ) ), cssList( - cssList.cls(use => `-${use(view)}`), - dom.forEach(fromKo(this._gristDoc.docModel.allTables.getObservable()), tableRec => + cssList.cls(use => `-${use(this._view)}`), + dom.forEach(this._tables, tableRec => cssItem( testId('table'), - cssItemContent( - cssIcon('TypeTable', - // Element to click in tests. - dom.domComputed(use => `table-id-${use(tableRec.tableId)}`) + cssLeft( + dom.domComputed(tableRec.tableId, (tableId) => + cssGreenIcon( + 'TypeTable', + testId(`table-id-${tableId}`) + ) ), - cssLabels( - cssTitleLine( - cssLine( - dom.text(use2 => use2(use2(tableRec.rawViewSection).title) || use2(tableRec.tableId)), - testId('table-title'), - ) - ), - cssIdLine( - cssIdLineContent( - cssUpperCase("Table id: "), - cssTableId( - testId('table-id'), - dom.text(tableRec.tableId), - ), - { title : 'Click to copy' }, - dom.on('click', async (e, t) => { - e.stopImmediatePropagation(); - e.preventDefault(); - showTransientTooltip(t, 'Table id copied to clipboard', { - key: 'copy-table-id' - }); - await copyToClipboard(tableRec.tableId.peek()); - setTestState({clipboard: tableRec.tableId.peek()}); - }) - ) - ), + ), + cssMiddle( + css60( + testId('table-title'), + dom.domComputed(fromKo(tableRec.rawViewSectionRef), vsRef => { + if (!vsRef) { + // Some very old documents might not have rawViewSection. + return dom('span', dom.text(tableRec.tableNameDef)); + } else { + return dom('div', // to disable flex grow in the widget + dom.domComputed(fromKo(tableRec.rawViewSection), vs => + dom.update( + buildTableName(vs, testId('widget-title')), + dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }), + ) + ) + ); + } + }), + ), + css40( + cssIdHoverWrapper( + cssUpperCase("Table id: "), + cssTableId( + testId('table-id'), + dom.text(tableRec.tableId), + ), + { title : 'Click to copy' }, + dom.on('click', async (e, t) => { + e.stopImmediatePropagation(); + e.preventDefault(); + showTransientTooltip(t, 'Table id copied to clipboard', { + key: 'copy-table-id' + }); + await copyToClipboard(tableRec.tableId.peek()); + setTestState({clipboard: tableRec.tableId.peek()}); + }) + ) ), ), - cssDots(docMenuTrigger( - testId('table-dots'), - icon('Dots'), - menu(() => this._menuItems(tableRec), {placement: 'bottom-start'}), - dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }), - )), + cssRight( + docMenuTrigger( + testId('table-menu'), + icon('Dots'), + menu(() => this._menuItems(tableRec), {placement: 'bottom-start'}), + dom.on('click', (ev) => { ev.stopPropagation(); ev.preventDefault(); }), + ) + ), dom.on('click', () => { const sectionId = tableRec.rawViewSection.peek().getRowId(); if (!sectionId) { @@ -101,17 +122,17 @@ export class DataTables extends Disposable { ); } - private _menuItems(t: TableRec) { + private _menuItems(table: TableRec) { const {isReadonly, docModel} = this._gristDoc; return [ - // TODO: in the upcoming diff - // menuItem(() => this._renameTable(t), "Rename", testId('rename'), - // dom.cls('disabled', isReadonly)), menuItem( - () => this._removeTable(t), + () => this._removeTable(table), 'Remove', testId('menu-remove'), - dom.cls('disabled', use => use(isReadonly) || use(docModel.allTables.getObservable()).length <= 1 ) + dom.cls('disabled', use => use(isReadonly) || ( + // Can't delete last user table, unless it is a hidden table. + use(docModel.allTables.getObservable()).length <= 1 && !use(table.isHidden) + )) ), dom.maybe(isReadonly, () => menuText('You do not have edit access to this document')), ]; @@ -124,10 +145,6 @@ export class DataTables extends Disposable { } confirmModal(`Delete ${t.tableId()} data, and remove it from all pages?`, 'Delete', doRemove); } - - // private async _renameTable(t: TableRec) { - // // TODO: - // } } const container = styled('div', ` @@ -169,23 +186,6 @@ const cssList = styled('div', ` } `); -const cssItemContent = styled('div', ` - display: flex; - flex: 1; - overflow: hidden; - .${cssList.className}-list & { - align-items: center; - } - .${cssList.className}-card & { - align-items: flex-start; - } - @media ${css.mediaXSmall} { - & { - align-items: flex-start !important; - } - } -`); - const cssItem = styled('div', ` display: flex; align-items: center; @@ -197,7 +197,7 @@ const cssItem = styled('div', ` border-color: ${css.colors.slate}; } .${cssList.className}-list & { - height: calc(1em * 40/13); /* 40px for 13px font */ + min-height: calc(1em * 40/13); /* 40px for 13px font */ } .${cssList.className}-card & { width: 300px; @@ -216,67 +216,69 @@ const cssItem = styled('div', ` } `); -const cssIcon = styled(icon, ` - --icon-color: ${css.colors.lightGreen}; - margin-left: 12px; +// Holds icon in top left corner +const cssLeft = 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; + min-width: 0px; + display: flex; + flex-wrap: wrap; + margin-top: 6px; + margin-bottom: 4px; .${cssList.className}-card & { - margin-top: 1px; - } - @media ${css.mediaXSmall} { - & { - margin-top: 1px; - } + margin: 0px: } `); -const cssOverflow = styled('div', ` - overflow: hidden; +const css60 = styled('div', ` + min-width: min(240px, 100%); + display: flex; + flex: 6; `); -const cssLabels = styled(cssOverflow, ` - overflow: hidden; +const css40 = styled('div', ` + min-width: min(240px, 100%); + flex: 4; display: flex; - flex-wrap: wrap; - align-items: center; - flex: 1; `); -const cssLine = styled('span', ` - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -`); -const cssTitleLine = styled(cssOverflow, ` +// 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; display: flex; - min-width: 50%; - .${cssList.className}-card & { - flex-basis: 100%; - } - @media ${css.mediaXSmall} { - & { - flex-basis: 100% !important; - } - } + flex: none; `); -const cssIdLine = styled(cssOverflow, ` - display: flex; - min-width: 40%; - .${cssList.className}-card & { - flex-basis: 100%; - } +const cssGreenIcon = styled(icon, ` + --icon-color: ${css.colors.lightGreen}; `); -const cssIdLineContent = styled(cssOverflow, ` +const cssLine = styled('span', ` + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +`); + +const cssIdHoverWrapper = styled('div', ` display: flex; + overflow: hidden; cursor: default; align-items: baseline; color: ${css.colors.slate}; transition: background 0.05s; padding: 1px 2px; + line-height: 18px; &:hover { background: ${css.colors.lightGrey}; } @@ -301,11 +303,6 @@ const cssUpperCase = styled('span', ` white-space: nowrap; `); -const cssDots = styled('div', ` - flex: none; - margin-right: 8px; -`); - const cssTableList = styled('div', ` overflow-y: auto; position: relative; diff --git a/app/client/components/DetailView.css b/app/client/components/DetailView.css index 3516613c..e68a41fe 100644 --- a/app/client/components/DetailView.css +++ b/app/client/components/DetailView.css @@ -118,6 +118,7 @@ flex-shrink: 0; padding: 0; margin-top: -4px; + text-transform: uppercase; } .grist-single-record__menu__count { diff --git a/app/client/components/EditorMonitor.ts b/app/client/components/EditorMonitor.ts index 26826c4e..e46fb870 100644 --- a/app/client/components/EditorMonitor.ts +++ b/app/client/components/EditorMonitor.ts @@ -66,7 +66,10 @@ export class EditorMonitor extends Disposable { */ private async _listenToReload(doc: GristDoc) { // don't restore on readonly mode or when there is custom nav - if (doc.isReadonly.get() || doc.hasCustomNav.get()) { return; } + if (doc.isReadonly.get() || doc.hasCustomNav.get()) { + this._store.clear(); + return; + } // if we are on raw data view, we need to set the position manually // as currentView observable will not be changed. if (doc.activeViewId.get() === 'data') { @@ -86,7 +89,10 @@ export class EditorMonitor extends Disposable { this._restored = true; const viewId = doc.activeViewId.get(); // if view wasn't rendered (page is displaying history or code view) do nothing - if (!isViewDocPage(viewId)) { return; } + if (!isViewDocPage(viewId)) { + this._store.clear(); + return; + } const lastEdit = this._store.readValue(); if (lastEdit) { // set the cursor at right cell diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index a1c822f3..2be9b1a5 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -7,7 +7,7 @@ import {AccessRules} from 'app/client/aclui/AccessRules'; import {ActionLog} from 'app/client/components/ActionLog'; import * as BaseView from 'app/client/components/BaseView'; import {isNumericLike, isNumericOnly} from 'app/client/components/ChartView'; -import * as CodeEditorPanel from 'app/client/components/CodeEditorPanel'; +import {CodeEditorPanel} from 'app/client/components/CodeEditorPanel'; import * as commands from 'app/client/components/commands'; import {CursorPos} from 'app/client/components/Cursor'; import {CursorMonitor, ViewCursorPos} from "app/client/components/CursorMonitor"; @@ -380,9 +380,9 @@ export class GristDoc extends DisposableWithEvents { return cssViewContentPane(testId('gristdoc'), cssViewContentPane.cls("-contents", use => use(this.activeViewId) === 'data'), dom.domComputed(this.activeViewId, (viewId) => ( - viewId === 'code' ? dom.create((owner) => owner.autoDispose(CodeEditorPanel.create(this))) : - viewId === 'acl' ? dom.create((owner) => owner.autoDispose(AccessRules.create(this, this))) : - viewId === 'data' ? dom.create((owner) => owner.autoDispose(RawData.create(this, this))) : + viewId === 'code' ? dom.create(CodeEditorPanel, this) : + viewId === 'acl' ? dom.create(AccessRules, this) : + viewId === 'data' ? dom.create(RawData, this) : viewId === 'GristDocTour' ? null : dom.create((owner) => (this._viewLayout = ViewLayout.create(owner, this, viewId))) )), @@ -413,12 +413,14 @@ export class GristDoc extends DisposableWithEvents { * null, then moves to a position best suited for optActionGroup (not yet implemented). */ public async moveToCursorPos(cursorPos?: CursorPos, optActionGroup?: MinimalActionGroup): Promise { - if (!cursorPos || cursorPos.sectionId == null) { + if (!cursorPos || !cursorPos.sectionId) { // TODO We could come up with a suitable cursorPos here based on the action itself. // This should only come up if trying to undo/redo after reloading a page (since the cursorPos // associated with the action is only stored in memory of the current JS process). // A function like `getCursorPosForActionGroup(ag)` would also be useful to jump to the best // place from any action in the action log. + // When user deletes table from Raw Data view, the section id will be 0 and undoing that + // operation will move cursor to the empty section row (with id 0). return; } try { @@ -787,6 +789,9 @@ export class GristDoc extends DisposableWithEvents { if (!cursorPos.sectionId) { throw new Error('sectionId required'); } if (!cursorPos.rowId) { throw new Error('rowId required'); } const section = this.docModel.viewSections.getRowModel(cursorPos.sectionId); + if (!section.id.peek()) { + throw new Error(`Section ${cursorPos.sectionId} does not exist`); + } const srcSection = section.linkSrcSection.peek(); if (srcSection.id.peek()) { // We're in a linked section, so we need to recurse to make sure the row we want @@ -867,6 +872,17 @@ export class GristDoc extends DisposableWithEvents { view?.activateEditorAtCursor(options); } + /** + * Renames table. Method exposed primarily for tests. + */ + public async renameTable(tableId: string, newTableName: string) { + const tableRec = this.docModel.allTables.all().find(t => t.tableId.peek() === tableId); + if (!tableRec) { + throw new UserError(`No table with id ${tableId}`); + } + await tableRec.tableName.saveOnly(newTableName); + } + /** * Waits for a view to be ready */ diff --git a/app/client/components/LinkingState.ts b/app/client/components/LinkingState.ts index 5cdddf64..3f27f4ae 100644 --- a/app/client/components/LinkingState.ts +++ b/app/client/components/LinkingState.ts @@ -114,6 +114,9 @@ export class LinkingState extends Disposable { _update(); function _update() { const result: FilterColValues = {filters: {}, operations: {}}; + if (srcSection.isDisposed()) { + return result; + } const srcRowId = srcSection.activeRowId(); for (const c of srcSection.table().groupByColumns()) { const col = c.summarySource(); diff --git a/app/client/components/RawData.ts b/app/client/components/RawData.ts index 15f6eac9..4e11ec20 100644 --- a/app/client/components/RawData.ts +++ b/app/client/components/RawData.ts @@ -22,8 +22,22 @@ export class RawData extends Disposable { this.autoDispose(commands.createGroup(commandGroup, this, true)); this._lightboxVisible = Computed.create(this, use => { const section = use(this._gristDoc.viewModel.activeSection); - return Boolean(section.getRowId()); + return Boolean(use(section.id)) && use(section.isRaw); }); + // 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. + // But by the time we are gone (disposed), active view will be changed, so here we will save the reference. + // TODO: empty view should rather have id = 0, not undefined. Should be fixed soon. + const emptyView = this._gristDoc.docModel.views.rowModels.find(x => x.id.peek() === undefined); + this.autoDispose(this._gristDoc.activeViewId.addListener(() => { + emptyView?.activeSectionId(0); + })); + // Whenever we close lightbox, clear cursor monitor state. + this.autoDispose(this._lightboxVisible.addListener(state => { + if (!state) { + this._gristDoc.cursorMonitor.clear(); + } + })); } public buildDom() { @@ -39,7 +53,8 @@ export class RawData extends Disposable { ), /*************** Lightbox section **********/ dom.domComputedOwned(fromKo(this._gristDoc.viewModel.activeSection), (owner, viewSection) => { - if (!viewSection.getRowId()) { + const sectionId = viewSection.getRowId(); + if (!sectionId || !viewSection.isRaw.peek()) { return null; } ViewSectionHelper.create(owner, this._gristDoc, viewSection); @@ -51,7 +66,7 @@ export class RawData extends Disposable { sectionRowId: viewSection.getRowId(), draggable: false, focusable: false, - onRename: this._renameSection.bind(this) + widgetNameHidden: true }) ), cssCloseButton('CrossBig', @@ -68,12 +83,6 @@ export class RawData extends Disposable { private _close() { this._gristDoc.viewModel.activeSectionId(0); } - - private async _renameSection(name: string) { - // here we will rename primary page for active primary viewSection - const primaryViewName = this._gristDoc.viewModel.activeSection.peek().table.peek().primaryView.peek().name; - await primaryViewName.saveOnly(name); - } } const cssContainer = styled('div', ` diff --git a/app/client/components/ViewLayout.css b/app/client/components/ViewLayout.css index c5585150..a3d9bfb0 100644 --- a/app/client/components/ViewLayout.css +++ b/app/client/components/ViewLayout.css @@ -15,32 +15,15 @@ color: var(--grist-color-slate); font-size: var(--grist-small-font-size); font-weight: 500; - text-transform: uppercase; white-space: nowrap; } -.viewsection_titletext { - cursor: text; - overflow: hidden; -} -.viewsection_titletext_container { - height: max-content; -} - .viewsection_content { background-color: #ffffff; overflow: visible; margin: 12px; } -.viewsection_title_colorbox { - width: 16px; - height: 16px; - border-radius: 8px; - margin: auto .5rem auto 0; - box-shadow: inset 0px 0px 5px rgba(0,0,0,0.5); -} - /* TODO should be switched to use new icon */ .viewsection_drag_indicator { visibility: hidden; diff --git a/app/client/components/ViewLayout.ts b/app/client/components/ViewLayout.ts index eb31fb81..a8723676 100644 --- a/app/client/components/ViewLayout.ts +++ b/app/client/components/ViewLayout.ts @@ -14,15 +14,15 @@ import {ViewRec, ViewSectionRec} from 'app/client/models/DocModel'; import {reportError} from 'app/client/models/errors'; import {filterBar} from 'app/client/ui/FilterBar'; import {viewSectionMenu} from 'app/client/ui/ViewSectionMenu'; +import {buildWidgetTitle} from 'app/client/ui/WidgetTitle'; import {colors, mediaSmall, testId} from 'app/client/ui2018/cssVars'; -import {editableLabel} from 'app/client/ui2018/editableLabel'; +import {icon} from 'app/client/ui2018/icons'; import {DisposableWithEvents} from 'app/common/DisposableWithEvents'; import {mod} from 'app/common/gutil'; -import {computedArray, Disposable, dom, fromKo, Holder, IDomComponent, styled, subscribe} from 'grainjs'; import {Observable} from 'grainjs'; import * as ko from 'knockout'; import * as _ from 'underscore'; -import {icon} from 'app/client/ui2018/icons'; +import {computedArray, Disposable, dom, fromKo, Holder, IDomComponent, styled, subscribe} from 'grainjs'; // tslint:disable:no-console @@ -275,11 +275,11 @@ export function buildViewSectionDom(options: { isResizing?: Observable viewModel?: ViewRec, // Should show drag anchor. - draggable?: boolean /* defaults to true */ + draggable?: boolean, /* defaults to true */ // Should show green bar on the left (but preserves active-section class). - focusable?: boolean /* defaults to true */ - // Custom handler for renaming the section. - onRename?: (name: string) => any + focusable?: boolean, /* defaults to true */ + tableNameHidden?: boolean, + widgetNameHidden?: boolean, }) { const isResizing = options.isResizing ?? Observable.create(null, false); const {gristDoc, sectionRowId, viewModel, draggable = true, focusable = true} = options; @@ -301,13 +301,7 @@ export function buildViewSectionDom(options: { ), dom.maybe((use) => use(use(viewInstance.viewSection.table).summarySourceTable), () => cssSigmaIcon('Pivot', testId('sigma'))), - dom('div.viewsection_titletext_container.flexitem.flexhbox', - dom('span.viewsection_titletext', editableLabel( - fromKo(vs.titleDef), - (val) => options.onRename ? options.onRename(val) : vs.titleDef.saveOnly(val), - testId('viewsection-title'), - )), - ), + buildWidgetTitle(vs, options, testId('viewsection-title'), cssTestClick(testId("viewsection-blank"))), viewInstance.buildTitleControls(), dom('span.viewsection_buttons', dom.create(viewSectionMenu, gristDoc.docModel, vs, gristDoc.isReadonly) @@ -332,6 +326,11 @@ export function buildViewSectionDom(options: { ); } +// With new widgetPopup it is hard to click on viewSection without a activating it, hence we +// add a little blank space to use in test. +const cssTestClick = styled(`div`, ` + min-width: 1px; +`); const cssSigmaIcon = styled(icon, ` bottom: 1px; diff --git a/app/client/lib/FocusLayer.ts b/app/client/lib/FocusLayer.ts index 9bba1f48..4aea1f39 100644 --- a/app/client/lib/FocusLayer.ts +++ b/app/client/lib/FocusLayer.ts @@ -7,6 +7,7 @@ * FocusLayerManager will watch for this element to lose focus or to get disposed, and will * restore focus to the default element. */ +import * as Mousetrap from 'app/client/lib/Mousetrap'; import {arrayRemove} from 'app/common/gutil'; import {RefCountMap} from 'app/common/RefCountMap'; import {Disposable, dom} from 'grainjs'; @@ -21,7 +22,12 @@ export interface FocusLayerOptions { defaultFocusElem: HTMLElement; // When true for an element, that element may hold focus even while this layer is active. - allowFocus: (elem: Element) => boolean; + // Defaults to any element except document.body. + allowFocus?: (elem: Element) => boolean; + + // If set, pause mousetrap keyboard shortcuts while this FocusLayer is active. Without it, arrow + // keys will navigate in a grid underneath this layer, and Enter may open a cell there. + pauseMousetrap?: boolean; // Called when the defaultFocusElem gets focused. onDefaultFocus?: () => void; @@ -139,10 +145,20 @@ export class FocusLayer extends Disposable implements FocusLayerOptions { constructor(options: FocusLayerOptions) { super(); this.defaultFocusElem = options.defaultFocusElem; - this.allowFocus = options.allowFocus; + this.allowFocus = options.allowFocus || (elem => elem !== document.body); this._onDefaultFocus = options.onDefaultFocus; this._onDefaultBlur = options.onDefaultBlur; + // Make sure the element has a tabIndex attribute, to make it focusable. + if (!this.defaultFocusElem.hasAttribute('tabindex')) { + this.defaultFocusElem.setAttribute('tabindex', '-1'); + } + + if (options.pauseMousetrap) { + Mousetrap.setPaused(true); + this.onDispose(() => Mousetrap.setPaused(false)); + } + const managerRefCount = this.autoDispose(_focusLayerManager.use(null)); const manager = managerRefCount.get(); manager.addLayer(this); diff --git a/app/client/models/DocModel.ts b/app/client/models/DocModel.ts index d7a85a4b..63e30380 100644 --- a/app/client/models/DocModel.ts +++ b/app/client/models/DocModel.ts @@ -22,7 +22,7 @@ import {urlState} from 'app/client/models/gristUrlState'; import * as MetaRowModel from 'app/client/models/MetaRowModel'; import * as MetaTableModel from 'app/client/models/MetaTableModel'; import * as rowset from 'app/client/models/rowset'; -import {isHiddenTable} from 'app/common/isHiddenTable'; +import {isHiddenTable, isRawTable} from 'app/common/isHiddenTable'; import {schema, SchemaTypes} from 'app/common/schema'; import {ACLRuleRec, createACLRuleRec} from 'app/client/models/entities/ACLRuleRec'; @@ -135,6 +135,7 @@ export class DocModel { public docInfoRow: DocInfoRec; public allTables: KoArray; + public rawTables: KoArray; public allTableIds: KoArray; // A mapping from tableId to DataTableModel for user-defined tables. @@ -169,6 +170,7 @@ export class DocModel { // An observable array of user-visible tables, sorted by tableId, excluding summary tables. // This is a publicly exposed member. this.allTables = createUserTablesArray(this.tables); + this.rawTables = createRawTablesArray(this.tables); // An observable array of user-visible tableIds. A shortcut mapped from allTables. const allTableIds = ko.computed(() => this.allTables.all().map(t => t.tableId())); @@ -236,3 +238,13 @@ function createUserTablesArray(tablesModel: MetaTableModel): KoArray): KoArray { + const rowSource = new rowset.FilteredRowSource(r => isRawTable(tablesModel.tableData, r)); + rowSource.subscribeTo(tablesModel); + // Create an observable RowModel array based on this rowSource, sorted by tableId. + return tablesModel._createRowSetModel(rowSource, 'tableId'); +} diff --git a/app/client/models/MetaTableModel.js b/app/client/models/MetaTableModel.js index 43272981..1e3a476c 100644 --- a/app/client/models/MetaTableModel.js +++ b/app/client/models/MetaTableModel.js @@ -68,9 +68,19 @@ MetaTableModel.prototype.loadData = function() { * when the row is deleted; in that case lacking such dependency may cause subtle rare bugs. */ MetaTableModel.prototype.getRowModel = function(rowId, optDependOnVersion) { - let r = this.rowModels[rowId] || this.getEmptyRowModel(); + const rowIdModel = this.rowModels[rowId]; + const r = rowIdModel || this.getEmptyRowModel(); if (optDependOnVersion) { - this._rowModelVersions[rowId](); + // Versions are never deleted, so even if the rowModel is deleted, we still have its version + // in this list. + const version = this._rowModelVersions[rowId]; + if (version) { + // Subscribe to updates for rowModel at rowId. + version(); + } else { + // It shouldn't happen, but maybe it would be better to add an empty version observable at rowId. + // If it happens, it means we tried to get non existing row (row that wasn't created previously). + } } return r; }; diff --git a/app/client/models/SearchModel.ts b/app/client/models/SearchModel.ts index fed30866..3aea418c 100644 --- a/app/client/models/SearchModel.ts +++ b/app/client/models/SearchModel.ts @@ -192,7 +192,7 @@ class FinderImpl implements IFinder { // Filter out those we don't have permissions to see (through ACL-tableId will be empty). .filter(t => Boolean(t.tableId.peek())) // sort in order that is the same as on the raw data list page, - .sort((a, b) => nativeCompare(a.tableTitle.peek(), b.tableTitle.peek())) + .sort((a, b) => nativeCompare(a.tableNameDef.peek(), b.tableNameDef.peek())) // get rawViewSection, .map(t => t.rawViewSection.peek()) // and test if it isn't an empty record. diff --git a/app/client/models/entities/PageRec.ts b/app/client/models/entities/PageRec.ts index 2c1f3421..7e5f130c 100644 --- a/app/client/models/entities/PageRec.ts +++ b/app/client/models/entities/PageRec.ts @@ -11,6 +11,18 @@ export function createPageRec(this: PageRec, docModel: DocModel): void { this.view = refRecord(docModel.views, this.viewRef); this.isHidden = ko.pureComputed(() => { const name = this.view().name(); - return !name || (name === 'GristDocTour' && !docModel.showDocTourTable); + const isTableHidden = () => { + const viewId = this.view().id(); + const tables = docModel.rawTables.all(); + const primaryTable = tables.find(t => t.primaryViewId() === viewId); + return !!primaryTable && primaryTable.isHidden(); + }; + // Page is hidden when any of this is true: + // - It has an empty name (or no name at all) + // - It is GristDocTour (unless user wants to see it) + // - It is a page generated for a hidden table TODO: Follow up - don't create + // pages for hidden tables. + // This is used currently only the left panel, to hide pages from the user. + return !name || (name === 'GristDocTour' && !docModel.showDocTourTable) || isTableHidden(); }); } diff --git a/app/client/models/entities/TableRec.ts b/app/client/models/entities/TableRec.ts index 1265ded7..182b231d 100644 --- a/app/client/models/entities/TableRec.ts +++ b/app/client/models/entities/TableRec.ts @@ -1,9 +1,9 @@ import {KoArray} from 'app/client/lib/koArray'; import {DocModel, IRowModel, recordSet, refRecord, ViewSectionRec} from 'app/client/models/DocModel'; import {ColumnRec, ValidationRec, ViewRec} from 'app/client/models/DocModel'; +import * as modelUtil from 'app/client/models/modelUtil'; import {MANUALSORT} from 'app/common/gristTypes'; import * as ko from 'knockout'; -import toUpper = require('lodash/toUpper'); import * as randomcolor from 'randomcolor'; // Represents a user-defined table. @@ -23,10 +23,16 @@ export interface TableRec extends IRowModel<"_grist_Tables"> { // The list of grouped by columns. groupByColumns: ko.Computed; - - // The user-friendly name of the table, which is the same as tableId for non-summary tables, - // and is 'tableId[groupByCols...]' for summary tables. - tableTitle: ko.Computed; + // Grouping description. + groupDesc: ko.PureComputed; + // Name of the data table - title of the rawViewSection + // for summary table it is name of primary table. + tableName: modelUtil.KoSaveableObservable; + // Table name with a default value (which is tableId). + tableNameDef: modelUtil.KoSaveableObservable; + // If user can select this table in various places. + // Note: Some hidden tables can still be visible on RawData view. + isHidden: ko.Computed; tableColor: string; disableAddRemoveRows: ko.Computed; @@ -40,6 +46,10 @@ export function createTableRec(this: TableRec, docModel: DocModel): void { this.primaryView = refRecord(docModel.views, this.primaryViewId); this.rawViewSection = refRecord(docModel.viewSections, this.rawViewSectionRef); this.summarySource = refRecord(docModel.tables, this.summarySourceTable); + this.isHidden = this.autoDispose( + // This is repeated logic from isHiddenTable. + ko.pureComputed(() => !!this.summarySourceTable() || this.tableId()?.startsWith("GristHidden")) + ); // A Set object of colRefs for all summarySourceCols of this table. this.summarySourceColRefs = this.autoDispose(ko.pureComputed(() => new Set( @@ -51,18 +61,12 @@ export function createTableRec(this: TableRec, docModel: DocModel): void { this.groupByColumns = ko.pureComputed(() => this.columns().all().filter(c => c.summarySourceCol())); - const groupByDesc = ko.pureComputed(() => { - const groupBy = this.groupByColumns(); - return groupBy.length ? 'by ' + groupBy.map(c => c.label()).join(", ") : "Totals"; - }); - - // The user-friendly name of the table, which is the same as tableId for non-summary tables, - // and is 'tableId[groupByCols...]' for summary tables. - this.tableTitle = ko.pureComputed(() => { - if (this.summarySourceTable()) { - return toUpper(this.summarySource().tableId()) + " [" + groupByDesc() + "]"; + this.groupDesc = ko.pureComputed(() => { + if (!this.summarySourceTable()) { + return ''; } - return toUpper(this.tableId()); + const groupBy = this.groupByColumns(); + return `[${groupBy.length ? 'by ' + groupBy.map(c => c.label()).join(", ") : "Totals"}]`; }); // TODO: We should save this value and let users change it. @@ -74,4 +78,40 @@ export function createTableRec(this: TableRec, docModel: DocModel): void { this.disableAddRemoveRows = ko.pureComputed(() => Boolean(this.summarySourceTable())); this.supportsManualSort = ko.pureComputed(() => this.columns().all().some(c => c.colId() === MANUALSORT)); + + this.tableName = modelUtil.savingComputed({ + read: () => { + if (this.isDisposed()) { + return ''; + } + if (this.summarySourceTable()) { + return this.summarySource().rawViewSection().title(); + } else { + // Need to be extra careful here, rawViewSection might be disposed. + if (this.rawViewSection().isDisposed()) { + return ''; + } + return this.rawViewSection().title(); + } + }, + write: (setter, val) => { + if (this.summarySourceTable()) { + setter(this.summarySource().rawViewSection().title, val); + } else { + setter(this.rawViewSection().title, val); + } + } + }); + this.tableNameDef = modelUtil.fieldWithDefault( + this.tableName, + // TableId will be null/undefined when ACL will restrict access to it. + ko.computed(() => { + // During table removal, we could be disposed. + if (this.isDisposed()) { + return ''; + } + const table = this.summarySourceTable() ? this.summarySource() : this; + return table.tableId() || ''; + }) + ); } diff --git a/app/client/models/entities/ViewSectionRec.ts b/app/client/models/entities/ViewSectionRec.ts index 860a3554..309a0fad 100644 --- a/app/client/models/entities/ViewSectionRec.ts +++ b/app/client/models/entities/ViewSectionRec.ts @@ -44,8 +44,10 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section"> { table: ko.Computed; - tableTitle: ko.Computed; + // Widget title with a default value titleDef: modelUtil.KoSaveableObservable; + // Default widget title (the one that is used in titleDef). + defaultWidgetTitle: ko.PureComputed; // 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. @@ -166,6 +168,7 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section"> { // List of selected rows selectedRows: Observable; + // Save all filters of fields/columns in the section. saveFilters(): Promise; @@ -279,13 +282,25 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): this.table = refRecord(docModel.tables, this.tableRef); - this.tableTitle = this.autoDispose(ko.pureComputed(() => this.table().tableTitle())); - this.titleDef = modelUtil.fieldWithDefault( - this.title, - () => this.table().tableTitle() + ( - (this.parentKey() === 'record') ? '' : ` ${getWidgetTypes(this.parentKey.peek() as any).label}` - ) - ); + + // The user-friendly name of the table, which is the same as tableId for non-summary tables, + // and is 'tableId[groupByCols...]' for summary tables. + // Consist of 3 parts + // - TableId (or primary table id for summary tables) capitalized + // - Grouping description (table record contains this for summary tables) + // - Widget type description (if not grid) + // All concatenated separated by space. + this.defaultWidgetTitle = this.autoDispose(ko.pureComputed(() => { + const widgetTypeDesc = this.parentKey() !== 'record' ? `${getWidgetTypes(this.parentKey.peek() as any).label}` : ''; + const table = this.table(); + return [ + table.tableNameDef()?.toUpperCase(), // Due to ACL this can be null. + table.groupDesc(), + widgetTypeDesc + ].filter(part => Boolean(part?.trim())).join(' '); + })); + // Widget title. + this.titleDef = modelUtil.fieldWithDefault(this.title, this.defaultWidgetTitle); // 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. diff --git a/app/client/ui/PageWidgetPicker.ts b/app/client/ui/PageWidgetPicker.ts index 59040d8f..17cad902 100644 --- a/app/client/ui/PageWidgetPicker.ts +++ b/app/client/ui/PageWidgetPicker.ts @@ -300,7 +300,8 @@ export class PageWidgetSelect extends Disposable { ), dom.forEach(this._tables, (table) => dom('div', cssEntryWrapper( - cssEntry(cssIcon('TypeTable'), cssLabel(dom.text(table.tableId)), + cssEntry(cssIcon('TypeTable'), + cssLabel(dom.text(use => use(table.tableNameDef) || use(table.tableId))), dom.on('click', () => this._selectTable(table.id())), cssEntry.cls('-selected', (use) => use(this._value.table) === table.id()), testId('table-label') diff --git a/app/client/ui/Pages.ts b/app/client/ui/Pages.ts index e08f1a77..7cae8ee4 100644 --- a/app/client/ui/Pages.ts +++ b/app/client/ui/Pages.ts @@ -3,12 +3,10 @@ import { duplicatePage } from "app/client/components/duplicatePage"; import { GristDoc } from "app/client/components/GristDoc"; import { PageRec } from "app/client/models/DocModel"; import { urlState } from "app/client/models/gristUrlState"; -import { isHiddenTable } from 'app/common/isHiddenTable'; import * as MetaTableModel from "app/client/models/MetaTableModel"; import { find as findInTree, fromTableData, TreeItemRecord, TreeRecord, TreeTableData} from "app/client/models/TreeModel"; import { TreeViewComponent } from "app/client/ui/TreeViewComponent"; -import { confirmModal } from 'app/client/ui2018/modals'; import { buildPageDom, PageActions } from "app/client/ui2018/pages"; import { mod } from 'app/common/gutil'; import { Computed, Disposable, dom, fromKo, observable, Observable } from "grainjs"; @@ -52,7 +50,7 @@ export function buildPagesDom(owner: Disposable, activeDoc: GristDoc, isOpen: Ob } function buildDomFromTable(pagesTable: MetaTableModel, activeDoc: GristDoc, id: number) { - const {docModel, isReadonly} = activeDoc; + const {isReadonly} = activeDoc; const pageName = pagesTable.rowModels[id].view.peek().name; const viewId = pagesTable.rowModels[id].view.peek().id.peek(); const docData = pagesTable.tableData.docData; @@ -61,33 +59,11 @@ function buildDomFromTable(pagesTable: MetaTableModel, activeDoc: Grist onRemove: () => docData.sendAction(['RemoveRecord', '_grist_Views', viewId]), // TODO: duplicate should prompt user for confirmation onDuplicate: () => duplicatePage(activeDoc, id), - isRemoveDisabled: () => false, + // Can't remove last visible page + isRemoveDisabled: () => activeDoc.docModel.visibleDocPages.peek().length <= 1, isReadonly }; - // find a table with a matching primary view - const tableRef = docModel.tables.tableData.findRow('primaryViewId', viewId); - - if (tableRef) { - function doRemove() { - const tableId = docModel.tables.tableData.getValue(tableRef, 'tableId'); - return docData.sendAction(['RemoveTable', tableId]); - } - - // if user removes a primary view, let's confirm first, because this will remove the - // corresponding table and also all pages that are using this table. - // TODO: once we have raw table view, removing page should remove just the view (not the - // table), but for now this is the only way to remove a table in the newui. - actions.onRemove = () => confirmModal( - `Delete ${pageName()} data, and remove it from all pages?`, 'Delete', doRemove); - - // Disable removing the last page. Sometimes hidden pages end up showing in the side panel - // (e.g. GristHidden_import* for aborted imports); those aren't listed in allTables, and we - // should allow removing them. - actions.isRemoveDisabled = () => (docModel.allTables.all().length <= 1) && - !isHiddenTable(docModel.tables.tableData, tableRef); - } - return buildPageDom(fromKo(pageName), actions, urlState().setLinkUrl({docPage: viewId})); } diff --git a/app/client/ui/RightPanel.ts b/app/client/ui/RightPanel.ts index f1b0a689..cc5f286a 100644 --- a/app/client/ui/RightPanel.ts +++ b/app/client/ui/RightPanel.ts @@ -299,7 +299,7 @@ export class RightPanel extends Disposable { }); return dom.maybe(viewConfigTab, (vct) => [ this._disableIfReadonly(), - cssLabel('WIDGET TITLE', + cssLabel(dom.text(use => use(activeSection.isRaw) ? 'DATA TABLE NAME' : 'WIDGET TITLE'), dom.style('margin-bottom', '14px')), cssRow(cssTextInput( Computed.create(owner, (use) => use(activeSection.titleDef)), diff --git a/app/client/ui/Tools.ts b/app/client/ui/Tools.ts index 37ff0772..72945c25 100644 --- a/app/client/ui/Tools.ts +++ b/app/client/ui/Tools.ts @@ -27,8 +27,6 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse gristDoc.docModel.rules.getNumRows() > 0); } owner.autoDispose(gristDoc.docModel.rules.tableData.tableActionEmitter.addListener(updateCanViewAccessRules)); - // TODO: Create global observable to enable raw tools (TO REMOVE once raw data ui has landed) - (window as any).enableRawTools = Observable.create(null, false); updateCanViewAccessRules(); return cssTools( cssTools.cls('-collapsed', (use) => !use(leftPanelOpen)), @@ -48,17 +46,15 @@ export function tools(owner: Disposable, gristDoc: GristDoc, leftPanelOpen: Obse }), testId('access-rules'), ), - // Raw data - for now hidden. - dom.maybe((window as any).enableRawTools, () => - cssPageEntry( - cssPageEntry.cls('-selected', (use) => use(gristDoc.activeViewId) === 'data'), - cssPageLink( - cssPageIcon('Database'), - cssLinkText('Raw data'), - testId('raw'), - urlState().setLinkUrl({docPage: 'data'}) - ), - )), + cssPageEntry( + cssPageEntry.cls('-selected', (use) => use(gristDoc.activeViewId) === 'data'), + cssPageLink( + cssPageIcon('Database'), + cssLinkText('Raw data'), + testId('raw'), + urlState().setLinkUrl({docPage: 'data'}) + ) + ), cssPageEntry( cssPageLink(cssPageIcon('Log'), cssLinkText('Document History'), testId('log'), dom.on('click', () => gristDoc.showTool('docHistory'))) diff --git a/app/client/ui/WidgetTitle.ts b/app/client/ui/WidgetTitle.ts new file mode 100644 index 00000000..271de42e --- /dev/null +++ b/app/client/ui/WidgetTitle.ts @@ -0,0 +1,245 @@ +import {FocusLayer} from 'app/client/lib/FocusLayer'; +import {ViewSectionRec} from 'app/client/models/entities/ViewSectionRec'; +import {basicButton, cssButton, primaryButton} from 'app/client/ui2018/buttons'; +import {colors, vars} from 'app/client/ui2018/cssVars'; +import {cssTextInput} from 'app/client/ui2018/editableLabel'; +import {menuCssClass} from 'app/client/ui2018/menus'; +import {ModalControl} from 'app/client/ui2018/modals'; +import {Computed, dom, DomElementArg, IInputOptions, input, makeTestId, Observable, styled} from 'grainjs'; +import {IOpenController, setPopupToCreateDom} from 'popweasel'; + +const testId = makeTestId('test-widget-title-'); + +interface WidgetTitleOptions { + tableNameHidden?: boolean, + widgetNameHidden?: boolean, +} + +export function buildWidgetTitle(vs: ViewSectionRec, options: WidgetTitleOptions, ...args: DomElementArg[]) { + const title = Computed.create(null, use => use(vs.titleDef)); + return buildRenameWidget(vs, title, options, dom.autoDispose(title), ...args); +} + +export function buildTableName(vs: ViewSectionRec, ...args: DomElementArg[]) { + const title = Computed.create(null, use => use(use(vs.table).tableNameDef)); + return buildRenameWidget(vs, title, { widgetNameHidden: true }, dom.autoDispose(title), ...args); +} + +export function buildRenameWidget( + vs: ViewSectionRec, + title: Observable, + options: WidgetTitleOptions, + ...args: DomElementArg[]) { + return cssTitleContainer( + cssTitle( + testId('text'), + dom.text(title), + // In case titleDef is all blank space, make it visible on hover. + cssTitle.cls("-empty", use => !use(title)?.trim()), + elem => { + setPopupToCreateDom(elem, ctl => buildWidgetRenamePopup(ctl, vs, options), { + placement: 'bottom-start', + trigger: ['click'], + attach: 'body', + boundaries: 'viewport', + }); + } + ), + ...args + ); +} + +function buildWidgetRenamePopup(ctrl: IOpenController, vs: ViewSectionRec, options: WidgetTitleOptions) { + const tableRec = vs.table.peek(); + // If the table is a summary table. + const isSummary = Boolean(tableRec.summarySourceTable.peek()); + // Table name, for summary table it contains also a grouping description, but it is not editable. + // Example: Table1 or Table1 [by B, C] + const tableName = [tableRec.tableNameDef.peek(), tableRec.groupDesc.peek()] + .filter(p => Boolean(p?.trim())).join(' '); + // User input for table name. + const inputTableName = Observable.create(ctrl, tableName); + // User input for widget title. + const inputWidgetTitle = Observable.create(ctrl, vs.title.peek()); + // Placeholder for widget title: + // - when widget title is empty shows a default widget title (what would be shown when title is empty) + // - when widget title is set, shows just a text to override it. + const inputWidgetPlaceholder = !vs.title.peek() ? 'Override widget title' : vs.defaultWidgetTitle.peek(); + + const disableSave = Computed.create(ctrl, (use) => + (use(inputTableName) === tableName || use(inputTableName).trim() === '') && + use(inputWidgetTitle) === vs.title.peek() + ); + + const modalCtl = ModalControl.create(ctrl, () => ctrl.close()); + + const saveTableName = async () => { + // For summary table ignore - though we could rename primary table. + if (isSummary) { return; } + // Can't save an empty name - there are actually no good reasons why we can't have empty table name, + // unfortunately there are some use cases that really on the empty name: + // - For ACL we sometimes may check if tableId is empty (and sometimes if table name). + // - Pages with empty name are not visible by default (and pages are renamed with a table - if their name match). + if (!inputTableName.get().trim()) { return; } + // If value was changed. + if (inputTableName.get() !== tableRec.tableNameDef.peek()) { + await tableRec.tableNameDef.saveOnly(inputTableName.get()); + } + }; + + const saveWidgetTitle = async () => { + // If value was changed. + if (inputWidgetTitle.get() !== vs.title.peek()) { + await vs.title.saveOnly(inputWidgetTitle.get()); + } + }; + const doSave = modalCtl.doWork(() => Promise.all([ + saveTableName(), + saveWidgetTitle() + ]), {close: true}); + + function initialFocus() { + // Set focus on a thing user is likely to change. + // Initial focus is set on tableName unless: + // - if this is a summary table - as it is not editable, + // - if widgetTitle is not empty - so user wants to change it further, + // - if widgetTitle is empty but the default widget name will have type suffix (like Table1 (Card)), so it won't + // be a table name - so user doesn't want the default value. + if ( + !widgetInput || + isSummary || + vs.title.peek() || + ( + !vs.title.peek() && + vs.defaultWidgetTitle.peek().toUpperCase() !== tableRec.tableName.peek().toUpperCase() + )) { + widgetInput?.focus(); + } else if (!isSummary) { + tableInput?.focus(); + } + } + + // Build actual dom that looks like: + // DATA TABLE NAME + // [input] + // WIDGET TITLE + // [input] + // [Save] [Cancel] + let tableInput: HTMLInputElement|undefined; + let widgetInput: HTMLInputElement|undefined; + return cssRenamePopup( + // Create a FocusLayer to keep focus in this popup while it's active, and prevent keyboard + // shortcuts from being seen by the view underneath. + elem => { FocusLayer.create(ctrl, {defaultFocusElem: elem, pauseMousetrap: true}); }, + testId('popup'), + dom.cls(menuCssClass), + dom.maybe(!options.tableNameHidden, () => [ + cssLabel('DATA TABLE NAME'), + // Update tableName on key stroke - this will show the default widget name as we type. + // above this modal. + tableInput = cssInput( + inputTableName, + updateOnKey, + {disabled: isSummary, placeholder: 'Provide a table name'}, + testId('table-name-input') + ), + ]), + dom.maybe(!options.widgetNameHidden, () => [ + cssLabel('WIDGET TITLE'), + widgetInput = cssInput(inputWidgetTitle, updateOnKey, {placeholder: inputWidgetPlaceholder}, + testId('section-name-input') + ), + ]), + cssButtons( + primaryButton('Save', + dom.on('click', doSave), + dom.boolAttr('disabled', use => use(disableSave) || use(modalCtl.workInProgress)), + testId('save'), + ), + basicButton('Cancel', + testId('cancel'), + dom.on('click', () => modalCtl.close()) + ), + ), + dom.onKeyDown({ + Escape: () => modalCtl.close(), + // On enter save or cancel - depending on the change. + Enter: () => disableSave.get() ? modalCtl.close() : doSave(), + }), + elem => { setTimeout(initialFocus, 0); }, + ); +} + +const updateOnKey = {onInput: true}; + +// Leave class for tests. +const cssTitleContainer = styled('div', ` + flex: 1 1 0px; + min-width: 0px; + display: flex; +`); + +const cssTitle = styled('div', ` + cursor: pointer; + overflow: hidden; + border-radius: 3px; + margin: -4px; + padding: 4px; + text-overflow: ellipsis; + align-self: start; + &:hover { + background-color: ${colors.mediumGrey}; + } + &-empty { + min-width: 48px; + min-height: 23px; + } +`); + +const cssRenamePopup = styled('div', ` + display: flex; + flex-direction: column; + min-width: 280px; + padding: 16px; + background-color: white; + border-radius: 2px; + outline: none; +`); + +const cssLabel = styled('label', ` + font-size: ${vars.xsmallFontSize}; + font-weight: ${vars.bigControlTextWeight}; + margin: 0 0 8px 0; + &:not(:first-child) { + margin-top: 16px; + } +`); + +const cssButtons = styled('div', ` + display: flex; + margin-top: 16px; + & > .${cssButton.className}:not(:first-child) { + margin-left: 8px; + } +`); + +const cssInputWithIcon = styled('div', ` + position: relative; + display: flex; + flex-direction: column; +`); + +const cssInput = styled(( + obs: Observable, + opts: IInputOptions, + ...args) => input(obs, opts, cssTextInput.cls(''), ...args), ` + text-overflow: ellipsis; + &:disabled { + color: ${colors.slate}; + background-color: ${colors.lightGrey}; + pointer-events: none; + } + .${cssInputWithIcon.className} > &:disabled { + padding-right: 28px; + } +`); diff --git a/app/client/ui2018/modals.ts b/app/client/ui2018/modals.ts index c1bc437f..c95ae897 100644 --- a/app/client/ui2018/modals.ts +++ b/app/client/ui2018/modals.ts @@ -1,5 +1,4 @@ import {FocusLayer} from 'app/client/lib/FocusLayer'; -import * as Mousetrap from 'app/client/lib/Mousetrap'; import {reportError} from 'app/client/models/errors'; import {bigBasicButton, bigPrimaryButton, cssButton} from 'app/client/ui2018/buttons'; import {colors, mediaSmall, testId, vars} from 'app/client/ui2018/cssVars'; @@ -36,7 +35,7 @@ export interface IModalControl { ): (...args: Args) => Promise; } -class ModalControl extends Disposable implements IModalControl { +export class ModalControl extends Disposable implements IModalControl { private _inProgress = Observable.create(this, 0); private _workInProgress = Computed.create(this, this._inProgress, (use, n) => (n > 0)); private _closePromise: Promise|undefined; @@ -44,13 +43,13 @@ class ModalControl extends Disposable implements IModalControl { constructor( private _doClose: () => void, - private _doFocus: () => void, + private _doFocus?: () => void, ) { super(); } public focus() { - this._doFocus(); + this._doFocus?.(); } public close(): void { @@ -163,11 +162,6 @@ export function modal( const modalDom = cssModalBacker( dom.create((owner) => { - // Pause mousetrap keyboard shortcuts while the modal is shown. Without this, arrow keys - // will navigate in a grid underneath the modal, and Enter may open a cell there. - Mousetrap.setPaused(true); - owner.onDispose(() => Mousetrap.setPaused(false)); - const focus = () => dialog.focus(); const ctl = ModalControl.create(owner, doClose, focus); close = () => ctl.close(); @@ -181,6 +175,9 @@ export function modal( FocusLayer.create(owner, { defaultFocusElem: dialog, allowFocus: (elem) => (elem !== document.body), + // Pause mousetrap keyboard shortcuts while the modal is shown. Without this, arrow keys + // will navigate in a grid underneath the modal, and Enter may open a cell there. + pauseMousetrap: true }); return dialog; }), diff --git a/app/client/widgets/NumericTextBox.ts b/app/client/widgets/NumericTextBox.ts index 698c70a8..209db25e 100644 --- a/app/client/widgets/NumericTextBox.ts +++ b/app/client/widgets/NumericTextBox.ts @@ -56,7 +56,7 @@ export class NumericTextBox extends NTextBox { const defaultMin = Computed.create(holder, resolved, (use, res) => res.minimumFractionDigits); const defaultMax = Computed.create(holder, resolved, (use, res) => res.maximumFractionDigits); const docCurrency = Computed.create(holder, docSettings, (use, settings) => - settings.currency ?? LocaleCurrency.getCurrency(settings.locale) + settings.currency ?? LocaleCurrency.getCurrency(settings.locale ?? 'en-US') ); // Save a value as the given property in this.options() observable. Set it, save, and revert diff --git a/app/common/Locales.ts b/app/common/Locales.ts index 0e0e3fa2..aa1be38e 100644 --- a/app/common/Locales.ts +++ b/app/common/Locales.ts @@ -49,7 +49,7 @@ LocaleCurrencyMap["SS"] = "SSP"; LocaleCurrencyMap["XK"] = "EUR"; const currenciesCodes = Object.values(LocaleCurrencyMap); export function getCurrency(code: string) { - const currency = LocaleCurrency.getCurrency(code); + const currency = LocaleCurrency.getCurrency(code ?? 'en-US'); // Fallback to USD return currency ?? DEFAULT_CURRENCY; } diff --git a/app/common/NumberFormat.ts b/app/common/NumberFormat.ts index ccfc8922..bd7afb6c 100644 --- a/app/common/NumberFormat.ts +++ b/app/common/NumberFormat.ts @@ -38,7 +38,7 @@ export interface NumberFormatOptions extends FormatOptions { } export function getCurrency(options: NumberFormatOptions, docSettings: DocumentSettings): string { - return options.currency || docSettings.currency || LocaleCurrency.getCurrency(docSettings.locale); + return options.currency || docSettings.currency || LocaleCurrency.getCurrency(docSettings.locale ?? 'en-US'); } export function buildNumberFormat(options: NumberFormatOptions, docSettings: DocumentSettings): Intl.NumberFormat { diff --git a/app/common/TableData.ts b/app/common/TableData.ts index 1f06baad..132be213 100644 --- a/app/common/TableData.ts +++ b/app/common/TableData.ts @@ -167,12 +167,14 @@ export class TableData extends ActionDispatcher implements SkippableRows { * Given a column name, returns a function that takes a rowId and returns the value for that * column of that row. The returned function is faster than getValue() calls. */ - public getRowPropFunc(colId: string): undefined | UIRowFunc { - const colData = this._columns.get(colId); - if (!colData) { return undefined; } - const values = colData.values; + public getRowPropFunc(colId: string): UIRowFunc { const rowMap = this._rowMap; - return function(rowId: UIRowId) { return values[rowMap.get(rowId as number)!]; }; + return (rowId: UIRowId) => { + const colData = this._columns.get(colId); + if (!colData) { return undefined; } + const values = colData.values; + return values[rowMap.get(rowId as number)!]; + }; } // By default, no rows are skippable, all are kept. diff --git a/app/common/isHiddenTable.ts b/app/common/isHiddenTable.ts index b40bf387..e0e319ed 100644 --- a/app/common/isHiddenTable.ts +++ b/app/common/isHiddenTable.ts @@ -7,6 +7,12 @@ import {TableData} from "./TableData"; */ export function isHiddenTable(tablesData: TableData, tableRef: UIRowId): boolean { const tableId = tablesData.getValue(tableRef, 'tableId') as string|undefined; - return tablesData.getValue(tableRef, 'summarySourceTable') !== 0 || - Boolean(tableId?.startsWith('GristHidden')); + return !isRawTable(tablesData, tableRef) || Boolean(tableId?.startsWith('GristHidden')); +} + +/** + * Return whether a table identified by the rowId of its metadata record should be visible on Raw Data page. + */ +export function isRawTable(tablesData: TableData, tableRef: UIRowId): boolean { + return tablesData.getValue(tableRef, 'summarySourceTable') === 0; } diff --git a/app/server/lib/TimeQuery.ts b/app/server/lib/TimeQuery.ts index c2f26565..0ef8e450 100644 --- a/app/server/lib/TimeQuery.ts +++ b/app/server/lib/TimeQuery.ts @@ -131,13 +131,15 @@ export class TimeLayout { public fields: TimeQuery; public columns: TimeQuery; public views: TimeQuery; + public sections: TimeQuery; constructor(public tc: TimeCursor) { - this.tables = new TimeQuery(tc, '_grist_Tables', ['tableId', 'primaryViewId']); + this.tables = new TimeQuery(tc, '_grist_Tables', ['tableId', 'primaryViewId', 'rawViewSectionRef']); this.fields = new TimeQuery(tc, '_grist_Views_section_field', ['parentId', 'parentPos', 'colRef']); this.columns = new TimeQuery(tc, '_grist_Tables_column', ['parentId', 'colId']); this.views = new TimeQuery(tc, '_grist_Views', ['id', 'name']); + this.sections = new TimeQuery(tc, '_grist_Views_section', ['id', 'title']); } /** update from TimeCursor */ @@ -146,6 +148,7 @@ export class TimeLayout { await this.columns.update(); await this.fields.update(); await this.views.update(); + await this.sections.update(); } public getColumnOrder(tableId: string): string[] { @@ -158,7 +161,7 @@ export class TimeLayout { } public getTableName(tableId: string): string { - const primaryViewId = this.tables.one({tableId}).primaryViewId; - return this.views.one({id: primaryViewId}).name; + const rawViewSectionRef = this.tables.one({tableId}).rawViewSectionRef; + return this.sections.one({id: rawViewSectionRef}).title; } } diff --git a/sandbox/grist/test_useractions.py b/sandbox/grist/test_useractions.py index 33db7d80..a08511c0 100644 --- a/sandbox/grist/test_useractions.py +++ b/sandbox/grist/test_useractions.py @@ -454,14 +454,16 @@ class TestUserActions(test_engine.EngineTestCase): [ 4, 'Table1', 3], ]) - # Update the names in a few views, and ensure that primary ones cause tables to get renamed. + # Update the names in a few views, and ensure that primary ones won't cause tables to + # get renamed. self.apply_user_action(['BulkUpdateRecord', '_grist_Views', [2,3,4], {'name': ['A', 'B', 'C']}]) + self.assertTableData('_grist_Tables', cols="subset", data=[ [ 'id', 'tableId', 'primaryViewId' ], [ 1, 'Schools', 1], [ 2, 'GristSummary_7_Schools', 0], - [ 3, 'C', 4], + [ 3, 'Table1', 4], ]) self.assertTableData('_grist_Views', cols="subset", data=[ [ 'id', 'name', 'primaryViewTable' ], @@ -471,6 +473,97 @@ class TestUserActions(test_engine.EngineTestCase): [ 4, 'C', 3] ]) + # Now rename a table (by raw view section) and make sure that a view with the same name + # was renamed + self.apply_user_action(['UpdateRecord', '_grist_Views_section', 2, + {'title': 'Bars'}]) + + self.assertTableData('_grist_Tables', cols="subset", data=[ + ['id', 'tableId'], + [1, 'Bars', 1], + [2, 'GristSummary_4_Bars', 0], + [3, 'Table1', 4], + ]) + self.assertTableData('_grist_Views', cols="subset", data=[ + ['id', 'name'], + [1, 'Bars'], + [2, 'A'], + [3, 'B'], + [4, 'C'] + ]) + + # Now rename tables so that two tables will have same names, to test if only the view + # with a page will be renamed. + self.apply_user_action(['UpdateRecord', '_grist_Views_section', 2, + {'title': 'A'}]) + + self.assertTableData('_grist_Tables', cols="subset", data=[ + ['id', 'tableId'], + [1, 'A', 1], + [2, 'GristSummary_1_A', 0], + [3, 'Table1', 4], + ]) + self.assertTableData('_grist_Views', cols="subset", data=[ + ['id', 'name'], + [1, 'A'], + [2, 'A'], + [3, 'B'], + [4, 'C'] + ]) + + self.apply_user_action(['UpdateRecord', '_grist_Views_section', 2, + {'title': 'Z'}]) + + self.assertTableData('_grist_Tables', cols="subset", data=[ + ['id', 'tableId'], + [1, 'Z', 1], + [2, 'GristSummary_1_Z', 0], + [3, 'Table1', 4], + ]) + self.assertTableData('_grist_Views', cols="subset", data=[ + ['id', 'name'], + [1, 'Z'], + [2, 'Z'], + [3, 'B'], + [4, 'C'] + ]) + + # Add new table, with a view with the same name (Z) and make sure it won't be renamed + self.apply_user_action(['AddTable', 'Stations', [ + {'id': 'city', 'type': 'Text'}, + ]]) + + # Replacing only a page name (though primary) + self.apply_user_action(['UpdateRecord', '_grist_Views', 5, {'name': 'Z'}]) + self.assertTableData('_grist_Views', cols="subset", data=[ + ['id', 'name'], + [1, 'Z'], + [2, 'Z'], + [3, 'B'], + [4, 'C'], + [5, 'Z'] + ]) + + # Rename table Z to Schools. Primary view for Stations (Z) should not be renamed. + self.apply_user_action(['UpdateRecord', '_grist_Views_section', 2, + {'title': 'Schools'}]) + + self.assertTableData('_grist_Tables', cols="subset", data=[ + ['id', 'tableId'], + [1, 'Schools'], + [2, 'GristSummary_7_Schools'], + [3, 'Table1'], + [4, 'Stations'], + ]) + self.assertTableData('_grist_Views', cols="subset", data=[ + ['id', 'name'], + [1, 'Schools'], + [2, 'Schools'], + [3, 'B'], + [4, 'C'], + [5, 'Z'] + ]) + #---------------------------------------------------------------------- def test_section_removes(self): @@ -531,7 +624,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. - self.apply_user_action(['UpdateRecord', '_grist_Views', 4, {'name': 'C'}]) + # 7 is id of raw view section for the Tabl1 table + self.apply_user_action(['UpdateRecord', '_grist_Views_section', 7, {'title': 'C'}]) self.assertEqual(count_calls[0], 1) self.assertTableData('_grist_Tables', cols="subset", data=[ diff --git a/sandbox/grist/useractions.py b/sandbox/grist/useractions.py index 2074ac85..78796902 100644 --- a/sandbox/grist/useractions.py +++ b/sandbox/grist/useractions.py @@ -681,22 +681,33 @@ class UserActions(object): make_acl_updates() - @override_action('BulkUpdateRecord', '_grist_Views') - def _updateViewRecords(self, table_id, row_ids, col_values): - # If we change a view's name, and that view is a primary view, change - # its table's tableId as well. - if 'name' in col_values: + @override_action('BulkUpdateRecord', '_grist_Views_section') + def _updateViewSections(self, table_id, row_ids, col_values): + # If we change a raw section name, rename also the table. Table name is a title of the RAW + # section. TableId is derived from the tableName (or is autogenerated if the tableName is blank) + if 'title' in col_values: rename_table_recs = [] rename_names = [] - rename_section_recs = [] for i, rec, values in self._bulk_action_iter(table_id, row_ids, col_values): - table = rec.primaryViewTable - if table: - rename_table_recs.append(table) - rename_section_recs.append(table.rawViewSectionRef) - rename_names.append(values['name']) + if rec.isRaw: + rename_table_recs.append(rec.tableRef) + rename_names.append(values['title']) + + # Renaming a table may sometimes rename pages: For any pages whose name matches + # the table name, rename those page to match (provided it contains a section with this + # table). + + # Get all sections with this table + sections = self._docmodel.view_sections.lookupRecords(tableRef=rec.tableRef) + # Get the views of those sections + views = {s.parentId for s in sections if s.parentId is not None and s.parentId.id != 0} + # Filter them by the old table name (which may be empty - than by tableId) + related_views = [v for v in views if v.name == (rec.title or rec.tableRef.tableId)] + # Update the views immediately + if related_views: + self._docmodel.update(related_views, name=[values['title']] * len(related_views)) + self._docmodel.update(rename_table_recs, tableId=rename_names) - self._docmodel.update(rename_section_recs, title=rename_names) self.doBulkUpdateRecord(table_id, row_ids, col_values) @@ -972,7 +983,7 @@ class UserActions(object): remove_table_recs.extend(st for t in remove_table_recs for st in t.summaryTables) # If other tables have columns referring to this table, remove them. - self._docmodel.remove(self._collect_back_references(remove_table_recs)) + self.doRemoveColumns(self._collect_back_references(remove_table_recs)) # Remove all view sections and fields for all tables being removed. # Bypass the check for raw data view sections. @@ -1014,6 +1025,9 @@ class UserActions(object): if any(c.summarySourceCol for c in col_recs): raise ValueError("RemoveColumn: cannot remove a group-by column from a summary table") + self.doRemoveColumns(col_recs) + + def doRemoveColumns(self, col_recs): # We need to remove group-by columns based on the columns being removed. To ensure we don't end # up with multiple summary tables with the same breakdown, we'll implement this by using # UpdateSummaryViewSection() on all the affected sections. @@ -1030,7 +1044,7 @@ class UserActions(object): # Remove this column from any sort specs to which it belongs. parent_sections = {section for c in col_recs for section in c.parentId.viewSections} - removed_col_refs = set(row_ids) + removed_col_refs = set((c.id for c in col_recs)) re_sort_sections = [] re_sort_specs = [] for section in parent_sections: @@ -1081,7 +1095,7 @@ class UserActions(object): # Remove metadata records, but prepare schema actions before the metadata is cleared. removals = [actions.RemoveColumn(c.parentId.tableId, c.colId) for c in all_removals] - self.doBulkRemoveRecord(table_id, [int(c) for c in all_removals]) + self.doBulkRemoveRecord('_grist_Tables_column', [int(c) for c in all_removals]) # Finally do the schema actions to remove the columns. for action in removals: diff --git a/static/icons/icons.css b/static/icons/icons.css index a9628849..f3dae919 100644 --- a/static/icons/icons.css +++ b/static/icons/icons.css @@ -46,7 +46,7 @@ --icon-Copy: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTExLDMgTDExLDE1IEwyLDE1IEwyLDMgTDExLDMgWiBNMTAsMy45OTkgTDMsMy45OTkgTDMsMTMuOTk5IEwxMCwxMy45OTkgTDEwLDMuOTk5IFogTTE0LDAgTDE0LDEyIEwxMS41LDEyIEwxMS41LDExIEwxMywxMSBMMTMsMSBMNiwxIEw2LDIuNSBMNSwyLjUgTDUsMCBMMTQsMCBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4='); --icon-CrossBig: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEzLjQxNDIxMzYsMTIgTDE5LjcwNzEwNjgsMTguMjkyODkzMiBDMjAuMDk3NjMxMSwxOC42ODM0MTc1IDIwLjA5NzYzMTEsMTkuMzE2NTgyNSAxOS43MDcxMDY4LDE5LjcwNzEwNjggQzE5LjMxNjU4MjUsMjAuMDk3NjMxMSAxOC42ODM0MTc1LDIwLjA5NzYzMTEgMTguMjkyODkzMiwxOS43MDcxMDY4IEwxMiwxMy40MTQyMTM2IEw1LjcwNzEwNjc4LDE5LjcwNzEwNjggQzUuMzE2NTgyNDksMjAuMDk3NjMxMSA0LjY4MzQxNzUxLDIwLjA5NzYzMTEgNC4yOTI4OTMyMiwxOS43MDcxMDY4IEMzLjkwMjM2ODkzLDE5LjMxNjU4MjUgMy45MDIzNjg5MywxOC42ODM0MTc1IDQuMjkyODkzMjIsMTguMjkyODkzMiBMMTAuNTg1Nzg2NCwxMiBMNC4yOTI4OTMyMiw1LjcwNzEwNjc4IEMzLjkwMjM2ODkzLDUuMzE2NTgyNDkgMy45MDIzNjg5Myw0LjY4MzQxNzUxIDQuMjkyODkzMjIsNC4yOTI4OTMyMiBDNC42ODM0MTc1MSwzLjkwMjM2ODkzIDUuMzE2NTgyNDksMy45MDIzNjg5MyA1LjcwNzEwNjc4LDQuMjkyODkzMjIgTDEyLDEwLjU4NTc4NjQgTDE4LjI5Mjg5MzIsNC4yOTI4OTMyMiBDMTguNjgzNDE3NSwzLjkwMjM2ODkzIDE5LjMxNjU4MjUsMy45MDIzNjg5MyAxOS43MDcxMDY4LDQuMjkyODkzMjIgQzIwLjA5NzYzMTEsNC42ODM0MTc1MSAyMC4wOTc2MzExLDUuMzE2NTgyNDkgMTkuNzA3MTA2OCw1LjcwNzEwNjc4IEwxMy40MTQyMTM2LDEyIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); --icon-CrossSmall: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTguNSw3LjUgTDEyLjUsNy41IEMxMi43NzYxNDI0LDcuNSAxMyw3LjcyMzg1NzYzIDEzLDggQzEzLDguMjc2MTQyMzcgMTIuNzc2MTQyNCw4LjUgMTIuNSw4LjUgTDguNSw4LjUgTDguNSwxMi41IEM4LjUsMTIuNzc2MTQyNCA4LjI3NjE0MjM3LDEzIDgsMTMgQzcuNzIzODU3NjMsMTMgNy41LDEyLjc3NjE0MjQgNy41LDEyLjUgTDcuNSw4LjUgTDMuNSw4LjUgQzMuMjIzODU3NjMsOC41IDMsOC4yNzYxNDIzNyAzLDggQzMsNy43MjM4NTc2MyAzLjIyMzg1NzYzLDcuNSAzLjUsNy41IEw3LjUsNy41IEw3LjUsMy41IEM3LjUsMy4yMjM4NTc2MyA3LjcyMzg1NzYzLDMgOCwzIEM4LjI3NjE0MjM3LDMgOC41LDMuMjIzODU3NjMgOC41LDMuNSBMOC41LDcuNSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iIHRyYW5zZm9ybT0icm90YXRlKC00NSA4IDgpIi8+PC9zdmc+'); - --icon-Database: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJjdXJyZW50Q29sb3IiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBjbGFzcz0iZmVhdGhlciBmZWF0aGVyLWRhdGFiYXNlIj48ZWxsaXBzZSBjeD0iMTIiIGN5PSI1IiByeD0iOSIgcnk9IjMiIHN0cm9rZS13aWR0aD0iMS41Ii8+PHBhdGggZD0iTTMgNXYxNGMwIDEuNjYgNCAzIDkgM3M5LTEuMzQgOS0zVjUiIHN0cm9rZS13aWR0aD0iMS41Ii8+PC9zdmc+'); + --icon-Database: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTYiIHdpZHRoPSIxNiI+PGcgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBmaWxsPSJub25lIiBzdHJva2U9IiMyMTIxMjEiIHN0cm9rZS1taXRlcmxpbWl0PSIxMCIgY2xhc3M9Im5jLWljb24td3JhcHBlciIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PGVsbGlwc2UgY3g9IjgiIGN5PSIzIiByeD0iNi41IiByeT0iMi41IiBkYXRhLWNhcD0iYnV0dCIvPjxwYXRoIGQ9Ik0xLjUsNi41VjEzIGMwLDEuMzgxLDIuOTEsMi41LDYuNSwyLjVzNi41LTEuMTE5LDYuNS0yLjVWNi41IiBkYXRhLWNhcD0iYnV0dCIvPjwvZz48L3N2Zz4='); --icon-Dots: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTgsOS41IEM3LjE3MTU3Mjg4LDkuNSA2LjUsOC44Mjg0MjcxMiA2LjUsOCBDNi41LDcuMTcxNTcyODggNy4xNzE1NzI4OCw2LjUgOCw2LjUgQzguODI4NDI3MTIsNi41IDkuNSw3LjE3MTU3Mjg4IDkuNSw4IEM5LjUsOC44Mjg0MjcxMiA4LjgyODQyNzEyLDkuNSA4LDkuNSBaIE0xMi41LDkuNSBDMTEuNjcxNTcyOSw5LjUgMTEsOC44Mjg0MjcxMiAxMSw4IEMxMSw3LjE3MTU3Mjg4IDExLjY3MTU3MjksNi41IDEyLjUsNi41IEMxMy4zMjg0MjcxLDYuNSAxNCw3LjE3MTU3Mjg4IDE0LDggQzE0LDguODI4NDI3MTIgMTMuMzI4NDI3MSw5LjUgMTIuNSw5LjUgWiBNMy41LDkuNSBDMi42NzE1NzI4OCw5LjUgMiw4LjgyODQyNzEyIDIsOCBDMiw3LjE3MTU3Mjg4IDIuNjcxNTcyODgsNi41IDMuNSw2LjUgQzQuMzI4NDI3MTIsNi41IDUsNy4xNzE1NzI4OCA1LDggQzUsOC44Mjg0MjcxMiA0LjMyODQyNzEyLDkuNSAzLjUsOS41IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); --icon-Download: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEyLDEzLjI5Mjg5MzIgTDE0LjE0NjQ0NjYsMTEuMTQ2NDQ2NiBDMTQuMzQxNzA4OCwxMC45NTExODQ1IDE0LjY1ODI5MTIsMTAuOTUxMTg0NSAxNC44NTM1NTM0LDExLjE0NjQ0NjYgQzE1LjA0ODgxNTUsMTEuMzQxNzA4OCAxNS4wNDg4MTU1LDExLjY1ODI5MTIgMTQuODUzNTUzNCwxMS44NTM1NTM0IEwxMS44NTM1NTM0LDE0Ljg1MzU1MzQgQzExLjY1ODI5MTIsMTUuMDQ4ODE1NSAxMS4zNDE3MDg4LDE1LjA0ODgxNTUgMTEuMTQ2NDQ2NiwxNC44NTM1NTM0IEw4LjE0NjQ0NjYxLDExLjg1MzU1MzQgQzcuOTUxMTg0NDYsMTEuNjU4MjkxMiA3Ljk1MTE4NDQ2LDExLjM0MTcwODggOC4xNDY0NDY2MSwxMS4xNDY0NDY2IEM4LjM0MTcwODc2LDEwLjk1MTE4NDUgOC42NTgyOTEyNCwxMC45NTExODQ1IDguODUzNTUzMzksMTEuMTQ2NDQ2NiBMMTEsMTMuMjkyODkzMiBMMTEsNy41IEMxMSw3LjIyMzg1NzYzIDExLjIyMzg1NzYsNyAxMS41LDcgQzExLjc3NjE0MjQsNyAxMiw3LjIyMzg1NzYzIDEyLDcuNSBMMTIsMTMuMjkyODkzMiBaIE0xLjA4NTM1Mjg1LDExIEMxLjI5MTI3MTA2LDExLjU4MjU5NjIgMS44NDY4OTA1OSwxMiAyLjUsMTIgTDYuNSwxMiBDNi43NzYxNDIzNywxMiA3LDEyLjIyMzg1NzYgNywxMi41IEM3LDEyLjc3NjE0MjQgNi43NzYxNDIzNywxMyA2LjUsMTMgTDIuNSwxMyBDMS4xMTkyODgxMywxMyAxLjM4Nzc3ODc4ZS0xNiwxMS44ODA3MTE5IDAsMTAuNSBDMCwxMC4yMjM4NTc2IDAuMjIzODU3NjI1LDEwIDAuNSwxMCBMNi41LDEwIEM2Ljc3NjE0MjM3LDEwIDcsMTAuMjIzODU3NiA3LDEwLjUgQzcsMTAuNzc2MTQyNCA2Ljc3NjE0MjM3LDExIDYuNSwxMSBMMS4wODUzNTI4NSwxMSBaIE0yLDguNSBDMiw4Ljc3NjE0MjM3IDEuNzc2MTQyMzcsOSAxLjUsOSBDMS4yMjM4NTc2Myw5IDEsOC43NzYxNDIzNyAxLDguNSBMMSwyLjUgQzEsMS42NzE1NzI4OCAxLjY3MTU3Mjg4LDEgMi41LDEgTDEzLjUsMSBDMTQuMzI4NDI3MSwxIDE1LDEuNjcxNTcyODggMTUsMi41IEwxNSw4LjUgQzE1LDguNzc2MTQyMzcgMTQuNzc2MTQyNCw5IDE0LjUsOSBDMTQuMjIzODU3Niw5IDE0LDguNzc2MTQyMzcgMTQsOC41IEwxNCwyLjUgQzE0LDIuMjIzODU3NjMgMTMuNzc2MTQyNCwyIDEzLjUsMiBMMi41LDIgQzIuMjIzODU3NjMsMiAyLDIuMjIzODU3NjMgMiwyLjUgTDIsOC41IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); --icon-DragDrop: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTYuNSwzIEM2Ljc3NjE0MjM3LDMgNywzLjIyMzg1NzYzIDcsMy41IEw3LDEyLjUgQzcsMTIuNzc2MTQyNCA2Ljc3NjE0MjM3LDEzIDYuNSwxMyBDNi4yMjM4NTc2MywxMyA2LDEyLjc3NjE0MjQgNiwxMi41IEw2LDMuNSBDNiwzLjIyMzg1NzYzIDYuMjIzODU3NjMsMyA2LjUsMyBaIE05LjUsMyBDOS43NzYxNDIzNywzIDEwLDMuMjIzODU3NjMgMTAsMy41IEwxMCwxMi41IEMxMCwxMi43NzYxNDI0IDkuNzc2MTQyMzcsMTMgOS41LDEzIEM5LjIyMzg1NzYzLDEzIDksMTIuNzc2MTQyNCA5LDEyLjUgTDksMy41IEM5LDMuMjIzODU3NjMgOS4yMjM4NTc2MywzIDkuNSwzIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); diff --git a/static/ui-icons/UI/Database.svg b/static/ui-icons/UI/Database.svg index 80048a70..3da6e23b 100644 --- a/static/ui-icons/UI/Database.svg +++ b/static/ui-icons/UI/Database.svg @@ -1,67 +1 @@ - - - - - - image/svg+xml - - - - - - - - - +database \ No newline at end of file diff --git a/test/nbrowser/ActionLog.ts b/test/nbrowser/ActionLog.ts index 98f2ec79..3284ec81 100644 --- a/test/nbrowser/ActionLog.ts +++ b/test/nbrowser/ActionLog.ts @@ -125,11 +125,8 @@ describe('ActionLog', function() { await item2.find('table td:nth-child(2)').click(); assert.equal(await gu.getActiveCell().getText(), 'f'); - // Delete the page and table for Table1Renamed. - await gu.openPageMenu('Table1Renamed'); - await driver.find('.grist-floating-menu .test-docpage-remove').click(); - await driver.findWait('.test-modal-confirm', 500).click(); - await gu.waitForServer(); + // Delete Table1Renamed. + await gu.removeTable('Table1Renamed'); await driver.findContent('.action_log label', /All tables/).find('input').click(); const item4 = await getActionLogItem(4); @@ -143,6 +140,9 @@ describe('ActionLog', function() { it("should filter cell changes and renames by table", async function() { // Have Table2, now add some more + // We are at Raw Data view now (since we deleted a table). + assert.match(await driver.getCurrentUrl(), /p\/data$/); + await gu.getPageItem('Table2').click(); await gu.enterGridRows({rowNum: 1, col: 0}, [['2']]); await gu.addNewTable(); // Table1 await gu.enterGridRows({rowNum: 1, col: 0}, [['1']]); diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index 86b70b93..a673c15d 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -186,11 +186,11 @@ export async function selectAll() { /** * Returns a WebElementPromise for the .viewsection_content element for the section which contains - * the given RegExp content. + * the given text (case insensitive) content. */ export function getSection(sectionOrTitle: string|WebElement): WebElement|WebElementPromise { if (typeof sectionOrTitle !== 'string') { return sectionOrTitle; } - return driver.find(`.test-viewsection-title[value="${sectionOrTitle}" i]`) + return driver.findContent(`.test-viewsection-title`, new RegExp("^" + escapeRegExp(sectionOrTitle) + "$", 'i')) .findClosest('.viewsection_content'); } @@ -198,8 +198,8 @@ export function getSection(sectionOrTitle: string|WebElement): WebElement|WebEle * Click into a section without disrupting cursor positions. */ export async function selectSectionByTitle(title: string) { - await driver.find(`.test-viewsection-title[value="${title}" i]`) - .findClosest('.viewsection_titletext_container').click(); + // .test-viewsection is a special 1px width element added for tests only. + await driver.findContent(`.test-viewsection-title`, title).find(".test-viewsection-blank").click(); } @@ -845,12 +845,15 @@ export async function addNewTable() { } export interface PageWidgetPickerOptions { - summarize?: RegExp[]; // Optional list of patterns to match Group By columns. selectBy?: RegExp|string; // Optional pattern of SELECT BY option to pick. + summarize?: (RegExp|string)[]; // Optional list of patterns to match Group By columns. } // Add a new page using the 'Add New' menu and wait for the new page to be shown. -export async function addNewPage(typeRe: RegExp, tableRe: RegExp, options?: PageWidgetPickerOptions) { +export async function addNewPage( + typeRe: RegExp|'Table'|'Card'|'Card List'|'Chart'|'Custom', + tableRe: RegExp|string, + options?: PageWidgetPickerOptions) { const url = await driver.getCurrentUrl(); // Click the 'Page' entry in the 'Add New' menu @@ -874,9 +877,12 @@ export async function addNewSection(typeRe: RegExp, tableRe: RegExp, options?: P await selectWidget(typeRe, tableRe, options); } -// Select type and table that matches respectivelly typeRe and tableRe and save. The widget picker +// Select type and table that matches respectively typeRe and tableRe and save. The widget picker // must be already opened when calling this function. -export async function selectWidget(typeRe: RegExp, tableRe: RegExp, options: PageWidgetPickerOptions = {}) { +export async function selectWidget( + typeRe: RegExp|string, + tableRe: RegExp|string, + options: PageWidgetPickerOptions = {}) { const tableEl = driver.findContent('.test-wselect-table', tableRe); @@ -937,11 +943,33 @@ export async function renamePage(oldName: string|RegExp, newName: string) { } /** - * Rename a table. TODO at the moment it's done by renaming the "primary" page for this table. - * Once "raw data views" are supported, they will be used to rename tables. + * Removes a page from the page menu, checks if the page is actually removable. */ -export async function renameTable(oldName: RegExp|string, newName: string) { - return renamePage(oldName, newName); +export async function removePage(name: string|RegExp) { + await openPageMenu(name); + assert.equal(await driver.find('.test-docpage-remove').matches('.disabled'), false); + await driver.find('.test-docpage-remove').click(); + await waitForServer(); +} + +/** + * Checks if a page can be removed. + */ + export async function canRemovePage(name: string|RegExp) { + await openPageMenu(name); + const isDisabled = await driver.find('.test-docpage-remove').matches('.disabled'); + await driver.sendKeys(Key.ESCAPE); + return !isDisabled; +} + +/** + * Renames a table using exposed method from gristDoc. Use renameActiveTable to use the UI. + */ +export async function renameTable(tableId: string, newName: string) { + await driver.executeScript(` + return window.gristDocPageModel.gristDoc.get().renameTable(arguments[0], arguments[1]); + `, tableId, newName); + await waitForServer(); } /** @@ -955,6 +983,23 @@ export async function renameColumn(col: IColHeader, newName: string) { await waitForServer(); } +/** + * Removes a table using RAW data view. Return back a current url. + */ +export async function removeTable(tableId: string) { + const back = await driver.getCurrentUrl(); + await driver.find(".test-tools-raw").click(); + const tableIdList = await driver.findAll('.test-raw-data-table-id', e => e.getText()); + const tableIndex = tableIdList.indexOf(tableId); + assert.isTrue(tableIndex >= 0, `No raw table with id ${tableId}`); + 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-modal-confirm").click(); + await waitForServer(); + return back; +} /** * Click the Undo button and wait for server. If optCount is given, click Undo that many times. @@ -978,7 +1023,12 @@ export async function begin(invariant: () => any = () => true) { const start = await undoStackPointer(); const previous = await invariant(); return async () => { - await undo(await undoStackPointer() - start); + // We will be careful here and await every time for the server and check js errors. + const count = await undoStackPointer() - start; + for (let i = 0; i < count; ++i) { + await undo(); + await checkForErrors(); + } assert.deepEqual(await invariant(), previous); }; } @@ -1127,17 +1177,12 @@ export async function searchPrev() { await driver.find('.test-tb-search-prev').click(); } -export function getCurrentSectionName() { - return driver.find('.active_section .test-viewsection-title').value(); -} - export function getCurrentPageName() { return driver.find('.test-treeview-itemHeader.selected').find('.test-docpage-label').getText(); } export async function getActiveRawTableName() { - const title = await driver.findWait('.test-raw-data-overlay .test-viewsection-title', 100).value(); - return title; + return await driver.findWait('.test-raw-data-overlay .test-viewsection-title', 100).getText(); } export function getSearchInput() { @@ -1172,6 +1217,18 @@ export async function openRawTable(tableId: string) { await driver.find(`.test-raw-data-table .test-raw-data-table-id-${tableId}`).click(); } +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-raw-data-widget-title') + .click(); + const input = await driver.find(".test-widget-title-table-name-input"); + await input.doClear(); + await input.click(); + await driver.sendKeys(newName, Key.ENTER); + await waitForServer(); +} + export async function isRawTableOpened() { return await driver.find('.test-raw-data-close-button').isPresent(); } @@ -1428,10 +1485,6 @@ export async function getCurrentUrlId() { return decodeUrl({}, new URL(await driver.getCurrentUrl())).doc; } -export async function getActiveSectionTitle() { - return driver.find('.active_section .test-viewsection-title').value(); -} - export function getToasts(): Promise { return driver.findAll('.test-notifier-toast-wrapper', (el) => el.getText()); } @@ -2257,6 +2310,57 @@ export async function waitForAnchor() { await driver.wait(async () => (await getTestState()).anchorApplied, 2000); } +export async function getActiveSectionTitle(timeout?: number) { + return await driver.findWait('.active_section .test-viewsection-title', timeout ?? 0).getText(); +} + +export async function getSectionTitle(timeout?: number) { + return await driver.findWait('.test-viewsection-title', timeout ?? 0).getText(); +} + +export async function getSectionTitles() { + return await driver.findAll('.test-viewsection-title', el => el.getText()); +} + +export async function renameSection(sectionTitle: string, name: string) { + const renameWidget = driver.findContent(`.test-viewsection-title`, sectionTitle); + await renameWidget.find(".test-widget-title-text").click(); + await driver.find(".test-widget-title-section-name-input").click(); + await selectAll(); + await driver.sendKeys(name || Key.DELETE, Key.ENTER); + await waitForServer(); +} + +export async function renameActiveSection(name: string) { + await driver.find(".active_section .test-viewsection-title .test-widget-title-text").click(); + await driver.find(".test-widget-title-section-name-input").click(); + await selectAll(); + await driver.sendKeys(name || Key.DELETE, Key.ENTER); + await waitForServer(); +} + +/** + * Renames active data table using widget title popup (from active section). + */ +export async function renameActiveTable(name: string) { + await driver.find(".active_section .test-viewsection-title .test-widget-title-text").click(); + await driver.find(".test-widget-title-table-name-input").click(); + await selectAll(); + await driver.sendKeys(name, Key.ENTER); + await waitForServer(); +} + +export async function setWidgetUrl(url: string) { + await driver.find('.test-config-widget-url').click(); + // First clear textbox. + await clearInput(); + if (url) { + await sendKeys(url); + } + await sendKeys(Key.ENTER); + await waitForServer(); +} + } // end of namespace gristUtils stackWrapOwnMethods(gristUtils);