mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	(core) Polish new Add Column menu
Summary: Fixes and features for the unreleased Add Column menu. Test Plan: Manual. Reviewers: jarek Reviewed By: jarek Subscribers: jarek Differential Revision: https://phab.getgrist.com/D4076
This commit is contained in:
		
							parent
							
								
									7f091cf057
								
							
						
					
					
						commit
						f1cf92aca1
					
				| @ -30,18 +30,24 @@ const {reportWarning} = require('app/client/models/errors'); | ||||
| const {reportUndo} = require('app/client/components/modals'); | ||||
| 
 | ||||
| const {onDblClickMatchElem} = require('app/client/lib/dblclick'); | ||||
| const {FocusLayer} = require('app/client/lib/FocusLayer'); | ||||
| 
 | ||||
| // Grist UI Components
 | ||||
| const {dom: grainjsDom, Holder, Computed} = require('grainjs'); | ||||
| const {closeRegisteredMenu, menu} = require('../ui2018/menus'); | ||||
| const {calcFieldsCondition, ColumnAddMenuOld} = require('../ui/GridViewMenus'); | ||||
| const {ColumnAddMenu, ColumnContextMenu, MultiColumnMenu, freezeAction} = require('../ui/GridViewMenus'); | ||||
| const {RowContextMenu} = require('../ui/RowContextMenu'); | ||||
| 
 | ||||
| const {setPopupToCreateDom} = require('popweasel'); | ||||
| const {CellContextMenu} = require('app/client/ui/CellContextMenu'); | ||||
| const {testId, isNarrowScreen} = require('app/client/ui2018/cssVars'); | ||||
| const {contextMenu} = require('app/client/ui/contextMenu'); | ||||
| const { | ||||
|   buildAddColumnMenu, | ||||
|   buildColumnContextMenu, | ||||
|   buildMultiColumnMenu, | ||||
|   buildOldAddColumnMenu, | ||||
|   calcFieldsCondition, | ||||
|   freezeAction, | ||||
| } = require('app/client/ui/GridViewMenus'); | ||||
| const {mouseDragMatchElem} = require('app/client/ui/mouseDrag'); | ||||
| const {menuToggle} = require('app/client/ui/MenuToggle'); | ||||
| const {descriptionInfoTooltip, showTooltip} = require('app/client/ui/tooltips'); | ||||
| @ -50,7 +56,6 @@ 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 {FieldBuilder} = require("../widgets/FieldBuilder"); | ||||
| const {GRIST_NEW_COLUMN_MENU} = require("../models/features"); | ||||
| 
 | ||||
| const t = makeT('GridView'); | ||||
| @ -209,6 +214,8 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) { | ||||
|   // Holds column index that is hovered, works only in full-edit formula mode.
 | ||||
|   this.hoverColumn = ko.observable(-1); | ||||
| 
 | ||||
|   this._insertColumnIndex = ko.observable(null); | ||||
| 
 | ||||
|   // Checks if there is active formula editor for a column in this table.
 | ||||
|   this.editingFormula = ko.pureComputed(() => { | ||||
|     const isEditing = this.gristDoc.docModel.editingFormula(); | ||||
| @ -303,8 +310,22 @@ GridView.gridCommands = { | ||||
|   // Re-define editField after fieldEditSave to make it take precedence for the Enter key.
 | ||||
|   editField: function() { closeRegisteredMenu(); this.scrollToCursor(true); this.activateEditorAtCursor(); }, | ||||
| 
 | ||||
|   insertFieldBefore: function() { this.insertColumn(this.cursor.fieldIndex()); }, | ||||
|   insertFieldAfter: function() { this.insertColumn(this.cursor.fieldIndex() + 1); }, | ||||
|   insertFieldBefore: function() { | ||||
|     if (GRIST_NEW_COLUMN_MENU()) { | ||||
|       this._openInsertColumnMenu(this.cursor.fieldIndex()); | ||||
|     } else { | ||||
|       // FIXME: remove once New Column menu is enabled by default.
 | ||||
|       this.insertColumn(null, {index: this.cursor.fieldIndex()}); | ||||
|     } | ||||
|   }, | ||||
|   insertFieldAfter: function() { | ||||
|     if (GRIST_NEW_COLUMN_MENU()) { | ||||
|       this._openInsertColumnMenu(this.cursor.fieldIndex() + 1); | ||||
|     } else { | ||||
|       // FIXME: remove once New Column menu is enabled by default.
 | ||||
|       this.insertColumn(null, {index: this.cursor.fieldIndex() + 1}); | ||||
|     } | ||||
|   }, | ||||
|   renameField: function() { this.renameColumn(this.cursor.fieldIndex()); }, | ||||
|   hideFields: function() { this.hideFields(this.getSelection()); }, | ||||
|   deleteFields: function() { | ||||
| @ -836,60 +857,26 @@ GridView.prototype.deleteRows = async function(rowIds) { | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| GridView.prototype.addNewColumn = function() { | ||||
|   this.insertColumn(this.viewSection.viewFields().peekLength) | ||||
|     .then(() => this.scrollPaneRight()); | ||||
| }; | ||||
| 
 | ||||
| GridView.prototype.insertColumn = async function(index) { | ||||
|   const pos = tableUtil.fieldInsertPositions(this.viewSection.viewFields(), index)[0]; | ||||
|   var action = ['AddColumn', null, {"_position": pos}]; | ||||
|   await this.gristDoc.docData.bundleActions('Insert column', async () => { | ||||
|     const colInfo = await this.tableModel.sendTableAction(action); | ||||
|     if (!this.viewSection.isRaw.peek()){ | ||||
|       const fieldInfo = { | ||||
|         colRef: colInfo.colRef, | ||||
|         parentPos: pos, | ||||
|         parentId: this.viewSection.id.peek() | ||||
|       }; | ||||
|       await this.gristDoc.docModel.viewFields.sendTableAction(['AddRecord', null, fieldInfo]); | ||||
|     } | ||||
|   }); | ||||
| GridView.prototype.insertColumn = async function(colId = null, options = {}) { | ||||
|   const { | ||||
|     colInfo = {}, | ||||
|     index = this.viewSection.viewFields().peekLength, | ||||
|     skipPopup = false | ||||
|   } = options; | ||||
|   const newColInfo = await this.viewSection.insertColumn(colId, {colInfo, index}); | ||||
|   this.selectColumn(index); | ||||
|   this.currentEditingColumnIndex(index); | ||||
|   if (!skipPopup) { this.currentEditingColumnIndex(index); } | ||||
|   return newColInfo; | ||||
| }; | ||||
| 
 | ||||
| if(GRIST_NEW_COLUMN_MENU) { | ||||
|   GridView.prototype.addNewColumnWithoutRenamePopup = async function() { | ||||
|     const index = this.viewSection.viewFields().peekLength; | ||||
|     const pos = tableUtil.fieldInsertPositions(this.viewSection.viewFields(), index)[0]; | ||||
|     var action = ['AddColumn', null, {"_position": pos}]; | ||||
|     await this.gristDoc.docData.bundleActions('Insert column', async () => { | ||||
|       const colInfo = await this.tableModel.sendTableAction(action); | ||||
|       if (!this.viewSection.isRaw.peek()) { | ||||
|         const fieldInfo = { | ||||
|           colRef: colInfo.colRef, | ||||
|           parentPos: pos, | ||||
|           parentId: this.viewSection.id.peek() | ||||
|         }; | ||||
|         await this.gristDoc.docModel.viewFields.sendTableAction(['AddRecord', null, fieldInfo]); | ||||
|       } | ||||
|     }); | ||||
|     const builder = new FieldBuilder(this.gristDoc, this.viewSection.viewFields().peek()[this.viewSection.viewFields().peekLength - 1], this.cursor); | ||||
|     return builder; | ||||
|   }; | ||||
| 
 | ||||
|   GridView.prototype.addNewFormulaColumn = async function(formula, name) { | ||||
|     const builder = await this.addNewColumnWithoutRenamePopup(); | ||||
|     await builder.gristDoc.convertToFormula(builder.field.colRef.peek(), formula); | ||||
|     return builder; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| GridView.prototype.renameColumn = function(index) { | ||||
|   this.currentEditingColumnIndex(index); | ||||
| }; | ||||
| 
 | ||||
| GridView.prototype.scrollPaneLeft = function() { | ||||
|   this.scrollPane.scrollLeft = 0; | ||||
| }; | ||||
| 
 | ||||
| GridView.prototype.scrollPaneRight = function() { | ||||
|   this.scrollPane.scrollLeft = this.scrollPane.scrollWidth; | ||||
| }; | ||||
| @ -899,16 +886,12 @@ GridView.prototype.selectColumn = function(colIndex) { | ||||
|   this.cellSelector.currentSelectType(selector.COL); | ||||
| }; | ||||
| 
 | ||||
| GridView.prototype.showColumn = function(colId, index) { | ||||
|   let fieldPos = tableUtil.fieldInsertPositions(this.viewSection.viewFields(), index, 1)[0]; | ||||
|   let colInfo = { | ||||
|     parentId: this.viewSection.id(), | ||||
|     colRef: colId, | ||||
|     parentPos: fieldPos | ||||
|   }; | ||||
|   return this.gristDoc.docModel.viewFields.sendTableAction(['AddRecord', null, colInfo]) | ||||
|   .then(() => this.selectColumn(index)) | ||||
|   .then(() => this.scrollPaneRight()); | ||||
| GridView.prototype.showColumn = async function( | ||||
|   colRef, | ||||
|   index = this.viewSection.viewFields().peekLength | ||||
| ) { | ||||
|   await this.viewSection.showColumn(colRef, index); | ||||
|   this.selectColumn(index); | ||||
| }; | ||||
| 
 | ||||
| // TODO: Replace alerts with custom notifications
 | ||||
| @ -1134,28 +1117,6 @@ GridView.prototype.buildDom = function() { | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const addColumnMenu = (gridView, viewSection)=> { | ||||
|     if(GRIST_NEW_COLUMN_MENU()) | ||||
|     { | ||||
|       return menu(ctl => [ColumnAddMenu(gridView, viewSection), testId('new-columns-menu')]); | ||||
|     } | ||||
|     else { | ||||
|       return [ | ||||
|         dom.on('click', ev => { | ||||
|           // If there are no hidden columns, clicking the plus just adds a new column.
 | ||||
|           // If there are hidden columns, display a dropdown menu.
 | ||||
|           if (viewSection.hiddenColumns().length === 0) { | ||||
|             ev.stopImmediatePropagation(); // Don't open the menu defined below
 | ||||
|             this.addNewColumn(); | ||||
|           } | ||||
|         }), | ||||
|         menu((ctl => ColumnAddMenuOld(gridView, viewSection))) | ||||
|       ] | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
|   return dom( | ||||
|     'div.gridview_data_pane.flexvbox', | ||||
|     // offset for frozen columns - how much move them to the left
 | ||||
| @ -1343,13 +1304,15 @@ GridView.prototype.buildDom = function() { | ||||
|                   testId('column-menu-trigger'), | ||||
|                 ), | ||||
|                 dom('div.selection'), | ||||
|                 // FIXME: remove once New Column menu is enabled by default.
 | ||||
|                 GRIST_NEW_COLUMN_MENU() ? this._buildInsertColumnMenu({field}) : null, | ||||
|               ); | ||||
|             }), | ||||
|             this.isPreview ? null : kd.maybe(() => !this.gristDoc.isReadonlyKo(), () => ( | ||||
|               this._modField = dom('div.column_name.mod-add-column.field', | ||||
|                 '+', | ||||
|                 kd.style("width", PLUS_WIDTH + 'px'), | ||||
|                 addColumnMenu(this, this.viewSection), | ||||
|                 this._buildInsertColumnMenu(), | ||||
|               ) | ||||
|             )) | ||||
|           ) | ||||
| @ -1504,7 +1467,10 @@ GridView.prototype.buildDom = function() { | ||||
|         kd.foreach(v.viewFields(), function(field) { | ||||
|           // Whether the cell has a cursor (possibly in an inactive view section).
 | ||||
|           var isCellSelected = ko.computed(() => | ||||
|             isRowActive() && field._index() === self.cursor.fieldIndex()); | ||||
|             isRowActive() && | ||||
|             field._index() === self.cursor.fieldIndex() && | ||||
|             self._insertColumnIndex() === null | ||||
|           ); | ||||
| 
 | ||||
|           // Whether the cell is active: has the cursor in the active section.
 | ||||
|           var isCellActive = ko.computed(() => isCellSelected() && v.hasFocus()); | ||||
| @ -1529,6 +1495,8 @@ GridView.prototype.buildDom = function() { | ||||
| 
 | ||||
|           return dom( | ||||
|             'div.field', | ||||
|             kd.toggleClass('field-insert-before', () => | ||||
|               self._insertColumnIndex() === field._index()), | ||||
|             kd.style('--frozen-position', () => ko.unwrap(self.frozenPositions.at(field._index()))), | ||||
|             kd.toggleClass("frozen", () => ko.unwrap(self.frozenMap.at(field._index()))), | ||||
|             kd.toggleClass('scissors', isCopyActive), | ||||
| @ -1541,8 +1509,9 @@ GridView.prototype.buildDom = function() { | ||||
|             //TODO: Ensure that fields in a row resize when
 | ||||
|             //a cell in that row becomes larger
 | ||||
|             kd.style('borderRightWidth', v.borderWidthPx), | ||||
| 
 | ||||
|             kd.toggleClass('selected', isSelected), | ||||
|             // Optional icon. Currently only use to show formula icon.
 | ||||
|             dom('div.field-icon'), | ||||
|             fieldBuilder.buildDomWithCursor(row, isCellActive, isCellSelected), | ||||
|             dom('div.selection'), | ||||
|           ); | ||||
| @ -1881,9 +1850,9 @@ GridView.prototype.columnContextMenu = function(ctl, copySelection, field, filte | ||||
|   const options = this._getColumnMenuOptions(copySelection); | ||||
| 
 | ||||
|   if (selectedColIds.length > 1 && selectedColIds.includes(field.column().colId())) { | ||||
|     return MultiColumnMenu(options); | ||||
|     return buildMultiColumnMenu(options); | ||||
|   } else { | ||||
|     return ColumnContextMenu({ | ||||
|     return buildColumnContextMenu({ | ||||
|       filterOpenFunc: () => filterTriggerCtl.open(), | ||||
|       sortSpec: this.gristDoc.viewModel.activeSection.peek().activeSortSpec.peek(), | ||||
|       colId: field.column.peek().id.peek(), | ||||
| @ -2000,20 +1969,113 @@ GridView.prototype._scrollColumnIntoView = function(colIndex) { | ||||
|   // If there are some frozen columns.
 | ||||
|   if (this.numFrozen.peek() && colIndex < this.numFrozen.peek()) { return; } | ||||
| 
 | ||||
|   const offset = this.colRightOffsets.peek().getSumTo(colIndex); | ||||
|   if (colIndex === 0) { | ||||
|     this.scrollPaneLeft(); | ||||
|   } else if (colIndex === this.viewSection.viewFields().peekLength - 1) { | ||||
|     this.scrollPaneRight(); | ||||
|   } else { | ||||
|     const offset = this.colRightOffsets.peek().getSumTo(colIndex); | ||||
| 
 | ||||
|   const rowNumsWidth = this._cornerDom.clientWidth; | ||||
|   const viewWidth = this.scrollPane.clientWidth - rowNumsWidth; | ||||
|   const fieldWidth = this.colRightOffsets.peek().getValue(colIndex) + 1; // +1px border
 | ||||
|     const rowNumsWidth = this._cornerDom.clientWidth; | ||||
|     const viewWidth = this.scrollPane.clientWidth - rowNumsWidth; | ||||
|     const fieldWidth = this.colRightOffsets.peek().getValue(colIndex) + 1; // +1px border
 | ||||
| 
 | ||||
|   // Left and right pixel edge of 'viewport', starting from edge of row nums.
 | ||||
|   const frozenWidth = this.frozenWidth.peek(); | ||||
|   const leftEdge = this.scrollPane.scrollLeft + frozenWidth; | ||||
|   const rightEdge = leftEdge + (viewWidth - frozenWidth); | ||||
|     // Left and right pixel edge of 'viewport', starting from edge of row nums.
 | ||||
|     const frozenWidth = this.frozenWidth.peek(); | ||||
|     const leftEdge = this.scrollPane.scrollLeft + frozenWidth; | ||||
|     const rightEdge = leftEdge + (viewWidth - frozenWidth); | ||||
| 
 | ||||
|   // If cell doesn't fit onscreen, scroll to fit.
 | ||||
|   const scrollShift = offset - gutil.clamp(offset, leftEdge, rightEdge - fieldWidth); | ||||
|   this.scrollPane.scrollLeft = this.scrollPane.scrollLeft + scrollShift; | ||||
|     // If cell doesn't fit onscreen, scroll to fit.
 | ||||
|     const scrollShift = offset - gutil.clamp(offset, leftEdge, rightEdge - fieldWidth); | ||||
|     this.scrollPane.scrollLeft = this.scrollPane.scrollLeft + scrollShift; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Attaches the Add Column menu. | ||||
|  * | ||||
|  * The menu can be triggered in two ways, depending on the presence of a `field` | ||||
|  * in `options`. | ||||
|  * | ||||
|  * If a field is present, the menu is triggered only when `_insertColumnIndex` is set | ||||
|  * to the index of the field the menu is attached to. | ||||
|  * | ||||
|  * If a field is not present, the menu is triggered either when `_insertColumnIndex` | ||||
|  * is set to `-1` or when the attached element is clicked. In practice, there will | ||||
|  * only be one element attached this way: the "+" field, which appears at the end of | ||||
|  * the GridView. | ||||
|  */ | ||||
| GridView.prototype._buildInsertColumnMenu = function(options = {}) { | ||||
|   if (GRIST_NEW_COLUMN_MENU()) { | ||||
|     const {field} = options; | ||||
|     const triggers = []; | ||||
|     if (!field) { triggers.push('click'); } | ||||
| 
 | ||||
|     return [ | ||||
|       field ? kd.toggleClass('field-insert-before', () => | ||||
|         this._insertColumnIndex() === field._index()) : null, | ||||
|       menu( | ||||
|         ctl => { | ||||
|           ctl.onDispose(() => this._insertColumnIndex(null)); | ||||
| 
 | ||||
|           let index = this._insertColumnIndex.peek(); | ||||
|           if (index === null || index === -1) { | ||||
|             index = undefined; | ||||
|           } | ||||
| 
 | ||||
|           return [ | ||||
|             buildAddColumnMenu(this, index), | ||||
|             elem => { FocusLayer.create(ctl, {defaultFocusElem: elem, pauseMousetrap: true}); }, | ||||
|             testId('new-columns-menu'), | ||||
|           ]; | ||||
|         }, | ||||
|         { | ||||
|           modifiers: { | ||||
|             offset: { | ||||
|               offset: '8,8', | ||||
|             }, | ||||
|           }, | ||||
|           selectOnOpen: true, | ||||
|           trigger: [ | ||||
|             ...triggers, | ||||
|             (_, ctl) => { | ||||
|               ctl.autoDispose(this._insertColumnIndex.subscribe((index) => { | ||||
|                 if (field?._index() === index || (!field && index === -1)) { | ||||
|                   ctl.open(); | ||||
|                 } else if (!ctl.isDisposed()) { | ||||
|                   ctl.close(); | ||||
|                 } | ||||
|               })); | ||||
|             }, | ||||
|           ], | ||||
|         } | ||||
|       ), | ||||
|     ]; | ||||
|   } else { | ||||
|     // FIXME: remove once New Column menu is enabled by default.
 | ||||
|     return [ | ||||
|       dom.on('click', async ev => { | ||||
|         // If there are no hidden columns, clicking the plus just adds a new column.
 | ||||
|         // If there are hidden columns, display a dropdown menu.
 | ||||
|         if (this.viewSection.hiddenColumns().length === 0) { | ||||
|           // Don't open the menu defined below.
 | ||||
|           ev.stopImmediatePropagation(); | ||||
|           await this.insertColumn(); | ||||
|         } | ||||
|       }), | ||||
|       menu((() => buildOldAddColumnMenu(this, this.viewSection))), | ||||
|     ] | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| GridView.prototype._openInsertColumnMenu = function(columnIndex) { | ||||
|   if (columnIndex < this.viewSection.viewFields().peekLength) { | ||||
|     this._scrollColumnIntoView(columnIndex); | ||||
|     this._insertColumnIndex(columnIndex); | ||||
|   } else { | ||||
|     this.scrollPaneRight(); | ||||
|     this._insertColumnIndex(-1); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| function buildStyleOption(owner, computedRule, optionName) { | ||||
|  | ||||
| @ -14,7 +14,7 @@ import {DocComm} from 'app/client/components/DocComm'; | ||||
| import * as DocConfigTab from 'app/client/components/DocConfigTab'; | ||||
| import {Drafts} from "app/client/components/Drafts"; | ||||
| import {EditorMonitor} from "app/client/components/EditorMonitor"; | ||||
| import * as GridView from 'app/client/components/GridView'; | ||||
| import GridView from 'app/client/components/GridView'; | ||||
| import {importFromFile, selectAndImport} from 'app/client/components/Importer'; | ||||
| import {RawDataPage, RawDataPopup} from 'app/client/components/RawDataPage'; | ||||
| import {ActionGroupWithCursorPos, UndoStack} from 'app/client/components/UndoStack'; | ||||
| @ -785,7 +785,7 @@ export class GristDoc extends DisposableWithEvents { | ||||
|   } | ||||
| 
 | ||||
|   public getTableModel(tableId: string): DataTableModel { | ||||
|     return this.docModel.dataTables[tableId]; | ||||
|     return this.docModel.getTableModel(tableId); | ||||
|   } | ||||
| 
 | ||||
|   // Get a DataTableModel, possibly wrapped to include diff data if a comparison is
 | ||||
|  | ||||
| @ -61,6 +61,20 @@ | ||||
|   background-color: var(--field-background-color, unset); | ||||
| } | ||||
| 
 | ||||
| /* The vertical line indicating where a column will be inserted when the | ||||
|  * Add Column menu is open. */ | ||||
| .field.field-insert-before::before { | ||||
|   content: ''; | ||||
|   position: absolute; | ||||
|   left: 0px; | ||||
|   top: 0px; | ||||
|   /* Overlap the top/bottom table borders so that the line appears uninterrupted. */ | ||||
|   bottom: -1px; | ||||
|   z-index: var(--grist-insert-column-line-z-index); | ||||
|   width: 3px; | ||||
|   background-color: var(--grist-theme-widget-active-border, #16B378); | ||||
| } | ||||
| 
 | ||||
| /** Similar order is for detail view, but there is no row rules */ | ||||
| .g_record_detail_value { | ||||
|   background-color: var(--grist-diff-background-color, | ||||
|  | ||||
							
								
								
									
										25
									
								
								app/client/declarations.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										25
									
								
								app/client/declarations.d.ts
									
									
									
									
										vendored
									
									
								
							| @ -81,6 +81,31 @@ declare module "app/client/components/BaseView" { | ||||
|   export = BaseView; | ||||
| } | ||||
| 
 | ||||
| declare module 'app/client/components/GridView' { | ||||
|   import BaseView from 'app/client/components/BaseView'; | ||||
|   import {GristDoc} from 'app/client/components/GristDoc'; | ||||
|   import {ColInfo, NewColInfo} from 'app/client/models/entities/ViewSectionRec'; | ||||
| 
 | ||||
|   interface InsertColOptions { | ||||
|     colInfo?: ColInfo; | ||||
|     index?: number; | ||||
|     skipPopup?: boolean; | ||||
|   } | ||||
| 
 | ||||
|   namespace GridView {} | ||||
| 
 | ||||
|   class GridView extends BaseView { | ||||
|     public static create(...args: any[]): any; | ||||
| 
 | ||||
|     public gristDoc: GristDoc; | ||||
| 
 | ||||
|     constructor(gristDoc: GristDoc, viewSectionModel: any, isPreview?: boolean); | ||||
|     public insertColumn(colId?: string|null, options?: InsertColOptions): Promise<NewColInfo>; | ||||
|     public showColumn(colRef: number, index?: number): Promise<void>; | ||||
|   } | ||||
|   export = GridView; | ||||
| } | ||||
| 
 | ||||
| declare module "app/client/components/ViewConfigTab" { | ||||
|   import {GristDoc} from 'app/client/components/GristDoc'; | ||||
|   import {Disposable} from 'app/client/lib/dispose'; | ||||
|  | ||||
| @ -239,6 +239,10 @@ export class DocModel { | ||||
|       && this.allTableIds.all().includes('GristDocTutorial')); | ||||
|   } | ||||
| 
 | ||||
|   public getTableModel(tableId: string) { | ||||
|     return this.dataTables[tableId]; | ||||
|   } | ||||
| 
 | ||||
|   private _metaTableModel<TName extends keyof SchemaTypes, TRow extends IRowModel<TName>>( | ||||
|     tableId: TName, | ||||
|     rowConstructor: (this: TRow, docModel: DocModel) => void, | ||||
|  | ||||
| @ -10,6 +10,7 @@ import randomcolor from 'randomcolor'; | ||||
| // Represents a user-defined table.
 | ||||
| export interface TableRec extends IRowModel<"_grist_Tables"> { | ||||
|   columns: ko.Computed<KoArray<ColumnRec>>; | ||||
|   visibleColumns: ko.Computed<ColumnRec[]>; | ||||
|   validations: ko.Computed<KoArray<ValidationRec>>; | ||||
| 
 | ||||
|   primaryView: ko.Computed<ViewRec>; | ||||
| @ -45,6 +46,8 @@ export interface TableRec extends IRowModel<"_grist_Tables"> { | ||||
| 
 | ||||
| export function createTableRec(this: TableRec, docModel: DocModel): void { | ||||
|   this.columns = recordSet(this, docModel.columns, 'parentId', {sortBy: 'parentPos'}); | ||||
|   this.visibleColumns = this.autoDispose(ko.pureComputed(() => | ||||
|     this.columns().all().filter(c => !c.isHiddenCol()))); | ||||
|   this.validations = recordSet(this, docModel.validations, 'tableRef'); | ||||
| 
 | ||||
|   this.primaryView = refRecord(docModel.views, this.primaryViewId); | ||||
|  | ||||
| @ -2,6 +2,7 @@ import BaseView from 'app/client/components/BaseView'; | ||||
| import {SequenceNEVER, SequenceNum} from 'app/client/components/Cursor'; | ||||
| import {EmptyFilterColValues, LinkingState} from 'app/client/components/LinkingState'; | ||||
| import {KoArray} from 'app/client/lib/koArray'; | ||||
| import {fieldInsertPositions} from 'app/client/lib/tableUtil'; | ||||
| import {ColumnToMapImpl} from 'app/client/models/ColumnToMap'; | ||||
| import { | ||||
|   ColumnRec, | ||||
| @ -23,14 +24,36 @@ import {getWidgetTypes} from "app/client/ui/widgetTypesMap"; | ||||
| import {FilterColValues} from "app/common/ActiveDocAPI"; | ||||
| import {AccessLevel, ICustomWidget} from 'app/common/CustomWidget'; | ||||
| import {UserAction} from 'app/common/DocActions'; | ||||
| import {RecalcWhen} from 'app/common/gristTypes'; | ||||
| import {arrayRepeat} from 'app/common/gutil'; | ||||
| import {Sort} from 'app/common/SortSpec'; | ||||
| import {ColumnsToMap, WidgetColumnMap} from 'app/plugin/CustomSectionAPI'; | ||||
| import {CursorPos, UIRowId} from 'app/plugin/GristAPI'; | ||||
| import {GristObjCode} from 'app/plugin/GristData'; | ||||
| import {Computed, Holder, Observable} from 'grainjs'; | ||||
| import * as ko from 'knockout'; | ||||
| import defaults = require('lodash/defaults'); | ||||
| 
 | ||||
| export interface InsertColOptions { | ||||
|   colInfo?: ColInfo; | ||||
|   index?: number; | ||||
| } | ||||
| 
 | ||||
| export interface ColInfo { | ||||
|   label?: string; | ||||
|   type?: string; | ||||
|   isFormula?: boolean; | ||||
|   formula?: string; | ||||
|   recalcWhen?: RecalcWhen; | ||||
|   recalcDeps?: [GristObjCode.List, ...number[]]|null; | ||||
|   widgetOptions?: string; | ||||
| } | ||||
| 
 | ||||
| export interface NewColInfo { | ||||
|   colId: string; | ||||
|   colRef: number; | ||||
| } | ||||
| 
 | ||||
| // Represents a section of user views, now also known as a "page widget" (e.g. a view may contain
 | ||||
| // a grid section and a chart section).
 | ||||
| export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleOwner { | ||||
| @ -231,6 +254,10 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO | ||||
| 
 | ||||
|   // Saves custom definition (bundles change)
 | ||||
|   saveCustomDef(): Promise<void>; | ||||
| 
 | ||||
|   insertColumn(colId?: string|null, options?: InsertColOptions): Promise<NewColInfo>; | ||||
| 
 | ||||
|   showColumn(colRef: number, index?: number): Promise<void> | ||||
| } | ||||
| 
 | ||||
| export type WidgetMappedColumn = number|number[]|null; | ||||
| @ -304,7 +331,7 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): | ||||
|   this.linkedSections = recordSet(this, docModel.viewSections, 'linkSrcSectionRef'); | ||||
| 
 | ||||
|   // All table columns associated with this view section, excluding any hidden helper columns.
 | ||||
|   this.columns = this.autoDispose(ko.pureComputed(() => this.table().columns().all().filter(c => !c.isHiddenCol()))); | ||||
|   this.columns = this.autoDispose(ko.pureComputed(() => this.table().visibleColumns())); | ||||
|   this.editingFormula = ko.pureComputed({ | ||||
|     read: () => docModel.editingFormula(), | ||||
|     write: val => { | ||||
| @ -766,4 +793,36 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): | ||||
|     const list = this.view().activeCollapsedSections(); | ||||
|     return list.includes(this.id()); | ||||
|   })); | ||||
| 
 | ||||
|   this.insertColumn = async (colId: string|null = null, options: InsertColOptions = {}) => { | ||||
|     const {colInfo = {}, index = this.viewFields().peekLength} = options; | ||||
|     const parentPos = fieldInsertPositions(this.viewFields(), index)[0]; | ||||
|     const action = ['AddColumn', colId, { | ||||
|       ...colInfo, | ||||
|       '_position': parentPos, | ||||
|     }]; | ||||
|     let newColInfo: NewColInfo; | ||||
|     await docModel.docData.bundleActions('Insert column', async () => { | ||||
|       newColInfo = await docModel.dataTables[this.tableId.peek()].sendTableAction(action); | ||||
|       if (!this.isRaw.peek()) { | ||||
|         const fieldInfo = { | ||||
|           colRef: newColInfo.colRef, | ||||
|           parentId: this.id.peek(), | ||||
|           parentPos, | ||||
|         }; | ||||
|         await docModel.viewFields.sendTableAction(['AddRecord', null, fieldInfo]); | ||||
|       } | ||||
|     }); | ||||
|     return newColInfo!; | ||||
|   }; | ||||
| 
 | ||||
|   this.showColumn = async (colRef: number, index = this.viewFields().peekLength) => { | ||||
|     const parentPos = fieldInsertPositions(this.viewFields(), index, 1)[0]; | ||||
|     const colInfo = { | ||||
|       colRef, | ||||
|       parentId: this.id.peek(), | ||||
|       parentPos, | ||||
|     }; | ||||
|     await docModel.viewFields.sendTableAction(['AddRecord', null, colInfo]); | ||||
|   }; | ||||
| } | ||||
|  | ||||
| @ -1,240 +1,316 @@ | ||||
| import {allCommands} from 'app/client/components/commands'; | ||||
| import GridView from 'app/client/components/GridView'; | ||||
| import {makeT} from 'app/client/lib/localization'; | ||||
| import {ViewSectionRec} from 'app/client/models/DocModel'; | ||||
| import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; | ||||
| import {testId, theme} from 'app/client/ui2018/cssVars'; | ||||
| import {icon} from 'app/client/ui2018/icons'; | ||||
| import { | ||||
|   enhanceBySearch, | ||||
|   menuDivider, | ||||
|   menuIcon, | ||||
|   menuItem, | ||||
|   menuItemCmd, | ||||
|   menuItemSubmenu, | ||||
|   menuSubHeader, | ||||
|   menuText | ||||
|   menuText, | ||||
|   searchableMenu, | ||||
| } from 'app/client/ui2018/menus'; | ||||
| import {Sort} from 'app/common/SortSpec'; | ||||
| import {dom, DomElementArg, Observable, styled} from 'grainjs'; | ||||
| import {dom, DomElementArg, styled} from 'grainjs'; | ||||
| import {RecalcWhen} from "../../common/gristTypes"; | ||||
| import {GristDoc} from "../components/GristDoc"; | ||||
| import {ColumnRec} from "../models/entities/ColumnRec"; | ||||
| import {FieldBuilder} from "../widgets/FieldBuilder"; | ||||
| import isEqual = require('lodash/isEqual'); | ||||
| 
 | ||||
| const t = makeT('GridViewMenus'); | ||||
| 
 | ||||
| //encapsulation over the view that menu will be generated for
 | ||||
| interface IView { | ||||
|   gristDoc: GristDoc; | ||||
|   //adding new column to the view, and return a FieldBuilder that can be used to further modify the column
 | ||||
|   addNewColumn: () => Promise<null>; | ||||
|   addNewColumnWithoutRenamePopup: () => Promise<FieldBuilder>; | ||||
|   showColumn: (colId: number, atIndex: number) => void; | ||||
|   //Add new colum to the view as formula column, with given column name and
 | ||||
|   //formula equation.
 | ||||
|   // Return a FieldBuilder that can be used to further modify the column
 | ||||
|   addNewFormulaColumn(formula: string, columnName: string): Promise<FieldBuilder>; | ||||
| } | ||||
| 
 | ||||
| interface IViewSection { | ||||
|   viewFields: any; | ||||
|   hiddenColumns: any; | ||||
|   columns: any; | ||||
| } | ||||
| 
 | ||||
| interface IColumnInfo{ | ||||
|   colId: string; | ||||
|   label: string; | ||||
|   index: number; | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| // Section for "Show hidden column" in a colum menu.
 | ||||
| // If there are no hidden columns - don't show the section.
 | ||||
| // If there is more that X - show submenu
 | ||||
| function MenuHideColumnSection(gridView: IView, viewSection: IViewSection){ | ||||
|   //function to generate the list with name of hidden columns and unhinging them on click
 | ||||
|   const listOfHiddenColumns = viewSection.hiddenColumns().map((col: any, index: number): IColumnInfo => { return { | ||||
|     colId:col.id(), label: col.label(), index: viewSection.columns().findIndex((c: any) => c.id() === col.id()), | ||||
|   }; }); | ||||
| 
 | ||||
|   //Generating dom and hadling actions in menu section for hidden columns - allow to unhide it.
 | ||||
|   const hiddenColumnMenu = () => { | ||||
|     //if there is more than 5 hidden columns - show submenu
 | ||||
|     if(listOfHiddenColumns.length > 5){ | ||||
|       return[ | ||||
|         menuItemSubmenu( | ||||
|           (ctl: any)=>{ | ||||
|             // enhance this submenu by adding search bar on the top. enhanceBySearch is doing basically two things:
 | ||||
|             // adding search bar, and expose searchCriteria observable to be used to generate list of items to be shown
 | ||||
|             return enhanceBySearch((searchCriteria)=> { | ||||
|               // put all hidden columns into observable
 | ||||
|               const hiddenColumns: Array<IColumnInfo> = listOfHiddenColumns; | ||||
|               const dynamicHiddenColumnsList =  Observable.create<any[]>(null, hiddenColumns); | ||||
|               // when search criteria changes - filter the list of hidden columns and update the observable
 | ||||
|               searchCriteria.addListener((sc: string) => { | ||||
|                 return dynamicHiddenColumnsList.set( | ||||
|                   hiddenColumns.filter((c: IColumnInfo) => c.label.includes(sc))); | ||||
|               }); | ||||
|               // generate a list of menu items from the observable
 | ||||
|               return [ | ||||
|                 // each hidden column is a menu item that will call showColumn on click
 | ||||
|                 // and place column at the end of the table
 | ||||
|                 dom.forEach(dynamicHiddenColumnsList, | ||||
|                   (col: any) => menuItem( | ||||
|                       ()=>{ gridView.showColumn(col.colId, viewSection.columns().length); }, | ||||
|                       col.label //column label as menu item text
 | ||||
|                   ) | ||||
|                 ) | ||||
|               ]; | ||||
|             }); | ||||
|           }, | ||||
|           {}, //options - we do not need any for this submenu
 | ||||
|           t("Show hidden columns"), //text of the submenu
 | ||||
|           {class: menuItem.className} // style of the submenu
 | ||||
|         ) | ||||
|       ]; | ||||
|       // in case there are less than five hidden columns - show them all in the main level of the menu
 | ||||
|     } else { | ||||
|       // generate a list of menu items from the list of hidden columns
 | ||||
|      return listOfHiddenColumns.map((col: any) => | ||||
|        menuItem( | ||||
|          ()=> { gridView.showColumn(col.colId, viewSection.columns().length); }, | ||||
|          col.label, //column label as menu item text
 | ||||
|          testId(`new-columns-menu-hidden-columns-${col.label.replace(' ', '-')}`) | ||||
|        ) | ||||
|      ); | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
| 
 | ||||
|   return dom.maybe(() => viewSection.hiddenColumns().length > 0, ()=>[ | ||||
|     menuDivider(), | ||||
|     menuSubHeader(t("Hidden Columns"), testId('new-columns-menu-hidden-columns')), | ||||
|     hiddenColumnMenu()] | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| function MenuShortcuts(gridView: IView){ | ||||
| // FIXME: remove once New Column menu is enabled by default.
 | ||||
| export function buildOldAddColumnMenu(gridView: GridView, viewSection: ViewSectionRec) { | ||||
|   return [ | ||||
|     menuDivider(), | ||||
|     menuSubHeader(t("Shortcuts"), testId('new-columns-menu-shortcuts')), | ||||
|     menuItemSubmenu((ctl: any)=>[ | ||||
|       menuItem( | ||||
|         () => addNewColumnWithTimestamp(gridView, false), t("Apply to new records"), | ||||
|         testId('new-columns-menu-shortcuts-timestamp-new') | ||||
|       ), | ||||
|       menuItem( | ||||
|         () => addNewColumnWithTimestamp(gridView, true), t("Apply on record changes"), | ||||
|         testId('new-columns-menu-shortcuts-timestamp-change') | ||||
|       ), | ||||
|     ], {}, t("Timestamp"), testId('new-columns-menu-shortcuts-timestamp')), | ||||
|     menuItemSubmenu((ctl: any)=>[ | ||||
|       menuItem( | ||||
|         () => addNewColumnWithAuthor(gridView, false), t("Apply to new records"), | ||||
|         testId('new-columns-menu-shortcuts-author-new') | ||||
|       ), | ||||
|       menuItem( | ||||
|         () => addNewColumnWithAuthor(gridView, true), t("Apply on record changes"), | ||||
|         testId('new-columns-menu-shortcuts-author-change') | ||||
|       ), | ||||
| 
 | ||||
|     ], {}, t("Authorship"), testId('new-columns-menu-shortcuts-author')), | ||||
|   ]; } | ||||
| 
 | ||||
| function MenuLookups(viewSection: IViewSection, gridView: IView){ | ||||
|   return [ | ||||
|     menuDivider(), | ||||
|     menuSubHeader(t("Lookups"), testId('new-columns-menu-lookups')), | ||||
|     buildLookupsOptions(viewSection, gridView) | ||||
|   ]; | ||||
| } | ||||
| 
 | ||||
| function buildLookupsOptions(viewSection: IViewSection, gridView: IView){ | ||||
|   const referenceCollection = viewSection.columns().filter((e: ColumnRec)=> e.pureType()=="Ref"); | ||||
| 
 | ||||
|   if(referenceCollection.length == 0){ | ||||
|     return menuText(()=>{}, t("no reference column"), testId('new-columns-menu-lookups-none')); | ||||
|   } | ||||
|   //TODO: Make search work - right now enhanceBySearch searchQuery parameter is not subscribed and menu items are
 | ||||
|   // not updated when search query changes. Filter the columns names based on search query observable (like in
 | ||||
|   // MenuHideColumnSection)
 | ||||
|   return referenceCollection.map((ref: any) => menuItemSubmenu((ctl) => { | ||||
|     return enhanceBySearch((searchQuery) => [ | ||||
|       ...ref.refTable().columns().all().map((col: ColumnRec) => | ||||
|         menuItem( | ||||
|           async () => { | ||||
|             await gridView.addNewFormulaColumn(`$${ref.label()}.${col.label()}`, | ||||
|               `${ref.label()}_${col.label()}`); | ||||
|           }, col.label() | ||||
|         ) | ||||
|       ) | ||||
|     ]); | ||||
|   }, {}, ref.label(), {class: menuItem.className}, testId(`new-columns-menu-lookups-${ref.label()}`))); | ||||
| } | ||||
| 
 | ||||
| // Old version of column menu
 | ||||
| // TODO: This is only valid as long as feature flag GRIST_NEW_COLUMN_MENU is existing in the system.
 | ||||
| //  Once it is removed (so production is working only with the new column menu, this function should be removed as well.
 | ||||
| export function ColumnAddMenuOld(gridView: IView, viewSection: IViewSection) { | ||||
|   return [ | ||||
|     menuItem(() => gridView.addNewColumn(), t("Add Column")), | ||||
|     menuItem(async () => { await gridView.insertColumn(); }, t("Add Column")), | ||||
|     menuDivider(), | ||||
|     ...viewSection.hiddenColumns().map((col: any) => menuItem( | ||||
|       () => { | ||||
|         gridView.showColumn(col.id(), viewSection.viewFields().peekLength); | ||||
|         // .then(() => gridView.scrollPaneRight());
 | ||||
|       async () => { | ||||
|         await gridView.showColumn(col.id()); | ||||
|       }, t("Show column {{- label}}", {label: col.label()}))) | ||||
|   ]; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Creates a menu to add a new column. | ||||
|  */ | ||||
| export function ColumnAddMenu(gridView: IView, viewSection: IViewSection) { | ||||
| export function buildAddColumnMenu(gridView: GridView, index?: number) { | ||||
|   return [ | ||||
|     menuItem( | ||||
|       async () => { await gridView.addNewColumn(); }, | ||||
|       `+ ${t("Add Column")}`, | ||||
|       testId('new-columns-menu-add-new') | ||||
|       async () => { await gridView.insertColumn(null, {index}); }, | ||||
|       menuIcon('Plus'), | ||||
|       t("Add Column"), | ||||
|       testId('new-columns-menu-add-new'), | ||||
|     ), | ||||
|     MenuHideColumnSection(gridView, viewSection), | ||||
|     MenuLookups(viewSection, gridView), | ||||
|     MenuShortcuts(gridView), | ||||
|     buildHiddenColumnsMenuItems(gridView, index), | ||||
|     buildLookupsMenuItems(gridView, index), | ||||
|     buildShortcutsMenuItems(gridView, index), | ||||
|   ]; | ||||
| } | ||||
| 
 | ||||
| //TODO: figure out how to change columns names;
 | ||||
| const addNewColumnWithTimestamp = async (gridView: IView, triggerOnUpdate: boolean) => { | ||||
|   await gridView.gristDoc.docData.bundleActions('Add new column with timestamp', async () => { | ||||
|     const column = await gridView.addNewColumnWithoutRenamePopup(); | ||||
|     if (!triggerOnUpdate) { | ||||
|       await column.gristDoc.convertToTrigger(column.origColumn.id.peek(), 'NOW()', RecalcWhen.DEFAULT); | ||||
|       await column.field.displayLabel.setAndSave(t('Created At')); | ||||
|       await column.field.column.peek().type.setAndSave('DateTime'); | ||||
|     } else { | ||||
|       await column.gristDoc.convertToTrigger(column.origColumn.id.peek(), 'NOW()', RecalcWhen.MANUAL_UPDATES); | ||||
|       await column.field.displayLabel.setAndSave(t('Last Updated At')); | ||||
|       await column.field.column.peek().type.setAndSave('DateTime'); | ||||
|     } | ||||
|   }, {nestInActiveBundle: true}); | ||||
| }; | ||||
| function buildHiddenColumnsMenuItems(gridView: GridView, index?: number) { | ||||
|   const {viewSection} = gridView; | ||||
|   const hiddenColumns = viewSection.hiddenColumns(); | ||||
|   if (hiddenColumns.length === 0) { return null; } | ||||
| 
 | ||||
| const addNewColumnWithAuthor = async (gridView: IView, triggerOnUpdate: boolean) => { | ||||
|   await gridView.gristDoc.docData.bundleActions('Add new column with author', async () => { | ||||
|     const column = await gridView.addNewColumnWithoutRenamePopup(); | ||||
|     if (!triggerOnUpdate) { | ||||
|       await column.gristDoc.convertToTrigger(column.origColumn.id.peek(), 'user.Name', RecalcWhen.DEFAULT); | ||||
|       await column.field.displayLabel.setAndSave(t('Created By')); | ||||
|       await column.field.column.peek().type.setAndSave('Text'); | ||||
|     } else { | ||||
|       await column.gristDoc.convertToTrigger(column.origColumn.id.peek(), 'user.Name', RecalcWhen.MANUAL_UPDATES); | ||||
|       await column.field.displayLabel.setAndSave(t('Last Updated By')); | ||||
|       await column.field.column.peek().type.setAndSave('Text'); | ||||
|     } | ||||
|   }, {nestInActiveBundle: true}); | ||||
| }; | ||||
|   return [ | ||||
|     menuDivider(), | ||||
|     menuSubHeader(t('Hidden Columns'), testId('new-columns-menu-hidden-columns')), | ||||
|     hiddenColumns.length > 5 | ||||
|       ? [ | ||||
|         menuItemSubmenu( | ||||
|           () => { | ||||
|             return searchableMenu( | ||||
|               hiddenColumns.map((col) => ({ | ||||
|                 cleanText: col.label().trim().toLowerCase(), | ||||
|                 label: col.label(), | ||||
|                 action: async () => { await gridView.showColumn(col.id(), index); }, | ||||
|               })), | ||||
|               {searchInputPlaceholder: t('Search columns')} | ||||
|             ); | ||||
|           }, | ||||
|           {allowNothingSelected: true}, | ||||
|           t('Show hidden columns'), | ||||
|         ), | ||||
|       ] | ||||
|       : hiddenColumns.map((col: ColumnRec) => | ||||
|         menuItem( | ||||
|           async () => { | ||||
|             await gridView.showColumn(col.id(), index); | ||||
|           }, | ||||
|           col.label(), | ||||
|         ) | ||||
|       ), | ||||
|   ]; | ||||
| } | ||||
| 
 | ||||
| function buildShortcutsMenuItems(gridView: GridView, index?: number) { | ||||
|   return [ | ||||
|     menuDivider(), | ||||
|     menuSubHeader(t("Shortcuts"), testId('new-columns-menu-shortcuts')), | ||||
|     buildTimestampMenuItems(gridView, index), | ||||
|     buildAuthorshipMenuItems(gridView, index), | ||||
|     buildDetectDuplicatesMenuItems(gridView, index), | ||||
|     buildUUIDMenuItem(gridView, index), | ||||
|   ]; | ||||
| } | ||||
| 
 | ||||
| function buildTimestampMenuItems(gridView: GridView, index?: number) { | ||||
|   return menuItemSubmenu(() => [ | ||||
|     menuItem( | ||||
|       async () => { | ||||
|         await gridView.insertColumn(t('Created At'), { | ||||
|           colInfo: { | ||||
|             label: t('Created At'), | ||||
|             type: 'DateTime', | ||||
|             isFormula: false, | ||||
|             formula: 'NOW()', | ||||
|             recalcWhen: RecalcWhen.DEFAULT, | ||||
|             recalcDeps: null, | ||||
|           }, | ||||
|           index, | ||||
|           skipPopup: true, | ||||
|         }); | ||||
|       }, | ||||
|       t("Apply to new records"), | ||||
|       testId('new-columns-menu-shortcuts-timestamp-new'), | ||||
|     ), | ||||
|     menuItem( | ||||
|       async () => { | ||||
|         await gridView.insertColumn(t('Last Updated At'), { | ||||
|           colInfo: { | ||||
|             label: t('Last Updated At'), | ||||
|             type: 'DateTime', | ||||
|             isFormula: false, | ||||
|             formula: 'NOW()', | ||||
|             recalcWhen: RecalcWhen.MANUAL_UPDATES, | ||||
|             recalcDeps: null, | ||||
|           }, | ||||
|           index, | ||||
|           skipPopup: true, | ||||
|         }); | ||||
|       }, | ||||
|       t("Apply on record changes"), | ||||
|       testId('new-columns-menu-shortcuts-timestamp-change'), | ||||
|     ), | ||||
|   ], {}, t("Timestamp"), testId('new-columns-menu-shortcuts-timestamp')); | ||||
| } | ||||
| 
 | ||||
| function buildAuthorshipMenuItems(gridView: GridView, index?: number) { | ||||
|   return menuItemSubmenu(() => [ | ||||
|     menuItem( | ||||
|       async () => { | ||||
|         await gridView.insertColumn(t('Created By'), { | ||||
|           colInfo: { | ||||
|             label: t('Created By'), | ||||
|             type: 'Text', | ||||
|             isFormula: false, | ||||
|             formula: 'user.Name', | ||||
|             recalcWhen: RecalcWhen.DEFAULT, | ||||
|             recalcDeps: null, | ||||
|           }, | ||||
|           index, | ||||
|           skipPopup: true, | ||||
|         }); | ||||
|       }, | ||||
|       t("Apply to new records"), | ||||
|       testId('new-columns-menu-shortcuts-author-new') | ||||
|     ), | ||||
|     menuItem( | ||||
|       async () => { | ||||
|         await gridView.insertColumn(t('Last Updated By'), { | ||||
|           colInfo: { | ||||
|             label: t('Last Updated By'), | ||||
|             type: 'Text', | ||||
|             isFormula: false, | ||||
|             formula: 'user.Name', | ||||
|             recalcWhen: RecalcWhen.MANUAL_UPDATES, | ||||
|             recalcDeps: null, | ||||
|           }, | ||||
|           index, | ||||
|           skipPopup: true, | ||||
|         }); | ||||
|       }, | ||||
|       t("Apply on record changes"), | ||||
|       testId('new-columns-menu-shortcuts-author-change') | ||||
|     ), | ||||
|   ], {}, t("Authorship"), testId('new-columns-menu-shortcuts-author')); | ||||
| } | ||||
| 
 | ||||
| function buildDetectDuplicatesMenuItems(gridView: GridView, index?: number) { | ||||
|   const {viewSection} = gridView; | ||||
|   return menuItemSubmenu( | ||||
|     () => searchableMenu( | ||||
|       viewSection.columns().map((col) => ({ | ||||
|         cleanText: col.label().trim().toLowerCase(), | ||||
|         label: col.label(), | ||||
|         action: async () => { | ||||
|           await gridView.gristDoc.docData.bundleActions(t('Adding duplicates column'), async () => { | ||||
|             const newColInfo = await gridView.insertColumn( | ||||
|               t('Duplicate in {{- label}}', {label: col.label()}), | ||||
|               { | ||||
|                 colInfo: { | ||||
|                   label: t('Duplicate in {{- label}}', {label: col.label()}), | ||||
|                   type: 'Bool', | ||||
|                   isFormula: true, | ||||
|                   formula: `True if len(${col.table().tableId()}.lookupRecords(` + | ||||
|                     `${col.colId()}=$${col.colId()})) > 1 else False`, | ||||
|                   recalcWhen: RecalcWhen.DEFAULT, | ||||
|                   recalcDeps: null, | ||||
|                   widgetOptions: JSON.stringify({ | ||||
|                     rulesOptions: [{ | ||||
|                       fillColor: '#ffc23d', | ||||
|                       textColor: '#262633', | ||||
|                     }], | ||||
|                   }), | ||||
|                 }, | ||||
|                 index, | ||||
|                 skipPopup: true, | ||||
|               } | ||||
|             ); | ||||
| 
 | ||||
|             // TODO: do the steps below as part of the AddColumn action.
 | ||||
|             const newField = viewSection.viewFields().all() | ||||
|               .find(field => field.colId() === newColInfo.colId); | ||||
|             if (!newField) { | ||||
|               throw new Error(`Unable to find field for column ${newColInfo.colId}`); | ||||
|             } | ||||
| 
 | ||||
|             await newField.addEmptyRule(); | ||||
|             const newRule = newField.rulesCols()[0]; | ||||
|             if (!newRule) { | ||||
|               throw new Error(`Unable to find conditional rule for field ${newField.label()}`); | ||||
|             } | ||||
| 
 | ||||
|             await newRule.formula.setAndSave(`$${newColInfo.colId}`); | ||||
|           }, {nestInActiveBundle: true}); | ||||
|         }, | ||||
|       })), | ||||
|       {searchInputPlaceholder: t('Search columns')} | ||||
|     ), | ||||
|     {allowNothingSelected: true}, | ||||
|     t('Detect Duplicates in...'), | ||||
|     testId('new-columns-menu-shortcuts-duplicates'), | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| function buildUUIDMenuItem(gridView: GridView, index?: number) { | ||||
|   return menuItem( | ||||
|     async () => { | ||||
|       await gridView.gristDoc.docData.bundleActions(t('Adding UUID column'), async () => { | ||||
|         // First create a formula column so that UUIDs are computed for existing cells.
 | ||||
|         const {colRef} = await gridView.insertColumn(t('UUID'), { | ||||
|           colInfo: { | ||||
|             label: t('UUID'), | ||||
|             type: 'Text', | ||||
|             isFormula: true, | ||||
|             formula: 'UUID()', | ||||
|             recalcWhen: RecalcWhen.DEFAULT, | ||||
|             recalcDeps: null, | ||||
|           }, | ||||
|           index, | ||||
|           skipPopup: true, | ||||
|         }); | ||||
| 
 | ||||
|         // Then convert it to a trigger formula, so that UUIDs aren't re-computed.
 | ||||
|         //
 | ||||
|         // TODO: remove this step and do it as part of the AddColumn action.
 | ||||
|         await gridView.gristDoc.convertToTrigger(colRef, 'UUID()'); | ||||
|       }, {nestInActiveBundle: true}); | ||||
|     }, | ||||
|     t('UUID'), | ||||
|     testId('new-columns-menu-shortcuts-uuid'), | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| function buildLookupsMenuItems(gridView: GridView, index?: number) { | ||||
|   const {viewSection} = gridView; | ||||
|   const columns = viewSection.columns(); | ||||
|   const references = columns.filter((c) => c.pureType() === 'Ref'); | ||||
| 
 | ||||
|   return [ | ||||
|     menuDivider(), | ||||
|     menuSubHeader( | ||||
|       t('Lookups'), | ||||
|       testId('new-columns-menu-lookups'), | ||||
|     ), | ||||
|     references.length === 0 | ||||
|       ? [ | ||||
|         menuText( | ||||
|           t('No reference columns.'), | ||||
|           testId('new-columns-menu-lookups-none'), | ||||
|         ), | ||||
|       ] | ||||
|       : references.map((ref) => menuItemSubmenu( | ||||
|         () => { | ||||
|           return searchableMenu( | ||||
|             ref.refTable()?.visibleColumns().map((col) => ({ | ||||
|               cleanText: col.label().trim().toLowerCase(), | ||||
|               label: col.label(), | ||||
|               action: async () => { | ||||
|                 await gridView.insertColumn(t(`${ref.label()}_${col.label()}`), { | ||||
|                   colInfo: { | ||||
|                     label: `${ref.label()}_${col.label()}`, | ||||
|                     isFormula: true, | ||||
|                     formula: `$${ref.colId()}.${col.colId()}`, | ||||
|                     recalcWhen: RecalcWhen.DEFAULT, | ||||
|                     recalcDeps: null, | ||||
|                   }, | ||||
|                   index, | ||||
|                   skipPopup: true, | ||||
|                 }); | ||||
|               }, | ||||
|             })) ?? [], | ||||
|             {searchInputPlaceholder: t('Search columns')} | ||||
|           ); | ||||
|         }, | ||||
|         {allowNothingSelected: true}, | ||||
|         ref.label(), | ||||
|         testId(`new-columns-menu-lookups-${ref.label()}`), | ||||
|       )), | ||||
|   ]; | ||||
| } | ||||
| 
 | ||||
| export interface IMultiColumnContextMenu { | ||||
|   // For multiple selection, true/false means the value applies to all columns, 'mixed' means it's
 | ||||
| @ -261,7 +337,7 @@ export function calcFieldsCondition(fields: ViewFieldRec[], condition: (f: ViewF | ||||
|   return fields.every(condition) ? true : (fields.some(condition) ? "mixed" : false); | ||||
| } | ||||
| 
 | ||||
| export function ColumnContextMenu(options: IColumnContextMenu) { | ||||
| export function buildColumnContextMenu(options: IColumnContextMenu) { | ||||
|   const { disableModify, filterOpenFunc, colId, sortSpec, isReadonly } = options; | ||||
| 
 | ||||
|   const disableForReadonlyColumn = dom.cls('disabled', Boolean(disableModify) || isReadonly); | ||||
| @ -318,7 +394,7 @@ export function ColumnContextMenu(options: IColumnContextMenu) { | ||||
|     menuItemCmd(allCommands.renameField, t("Rename column"), disableForReadonlyColumn), | ||||
|     freezeMenuItemCmd(options), | ||||
|     menuDivider(), | ||||
|     MultiColumnMenu((options.disableFrozenMenu = true, options)), | ||||
|     buildMultiColumnMenu((options.disableFrozenMenu = true, options)), | ||||
|     testId('column-menu'), | ||||
|   ]; | ||||
| } | ||||
| @ -331,7 +407,7 @@ export function ColumnContextMenu(options: IColumnContextMenu) { | ||||
|  * We offer both options if data columns are selected. If only formulas, only the second option | ||||
|  * makes sense. | ||||
|  */ | ||||
| export function MultiColumnMenu(options: IMultiColumnContextMenu) { | ||||
| export function buildMultiColumnMenu(options: IMultiColumnContextMenu) { | ||||
|   const disableForReadonlyColumn = dom.cls('disabled', Boolean(options.disableModify) || options.isReadonly); | ||||
|   const disableForReadonlyView = dom.cls('disabled', options.isReadonly); | ||||
|   const num: number = options.numColumns; | ||||
|  | ||||
| @ -136,6 +136,7 @@ export const vars = { | ||||
|   toastBg: new CustomProp('toast-bg', '#040404'), | ||||
| 
 | ||||
|   /* Z indexes */ | ||||
|   insertColumnLineZIndex: new CustomProp('insert-column-line-z-index', '20'), | ||||
|   menuZIndex: new CustomProp('menu-z-index', '999'), | ||||
|   modalZIndex: new CustomProp('modal-z-index', '999'), | ||||
|   onboardingBackdropZIndex: new CustomProp('onboarding-backdrop-z-index', '999'), | ||||
|  | ||||
| @ -12,6 +12,7 @@ import { | ||||
|   BindableValue, Computed, dom, DomElementArg, DomElementMethod, IDomArgs, | ||||
|   MaybeObsArray, MutableObsArray, Observable, styled | ||||
| } from 'grainjs'; | ||||
| import debounce from 'lodash/debounce'; | ||||
| import * as weasel from 'popweasel'; | ||||
| 
 | ||||
| const t = makeT('menus'); | ||||
| @ -49,36 +50,70 @@ export function menu(createFunc: weasel.MenuCreateFunc, options?: weasel.IMenuOp | ||||
|   return weasel.menu(wrappedCreateFunc, {...defaults, ...options}); | ||||
| } | ||||
| 
 | ||||
| const cssSearchField = styled('input', | ||||
|   'border: none;'+ | ||||
|   'background-color: transparent;'+ | ||||
|   'padding: 8px 24px 4px 24px;'+ | ||||
|   '&:focus {outline: none;}' | ||||
| ); | ||||
| export function enhanceBySearch(  menuFunc: (searchCriteria: Observable<string>) => DomElementArg[]): DomElementArg[] | ||||
| { | ||||
|     const searchCriteria = Observable.create(null, ''); | ||||
|     const searchInput = [ | ||||
|       menuItemStatic( | ||||
|         cssSearchField( | ||||
|           dom.on('input', (_ev, elem) => searchCriteria.set(elem.value)), | ||||
|           {placeholder: '🔍\uFE0E\t' + t("Search columns")} | ||||
|         ) | ||||
| export interface SearchableMenuOptions { | ||||
|   searchInputPlaceholder?: string; | ||||
| } | ||||
| 
 | ||||
| export interface SearchableMenuItem { | ||||
|   cleanText: string; | ||||
|   label: string; | ||||
|   action: (item: HTMLElement) => void; | ||||
|   args?: DomElementArg[]; | ||||
| } | ||||
| 
 | ||||
| export function searchableMenu( | ||||
|   menuItems: MaybeObsArray<SearchableMenuItem>, | ||||
|   options: SearchableMenuOptions = {} | ||||
| ): DomElementArg[] { | ||||
|   const {searchInputPlaceholder} = options; | ||||
| 
 | ||||
|   const searchValue = Observable.create(null, ''); | ||||
|   const setSearchValue = debounce((value) => { searchValue.set(value); }, 100); | ||||
| 
 | ||||
|   return [ | ||||
|     menuItemStatic( | ||||
|       cssMenuSearch( | ||||
|         cssMenuSearchIcon('Search'), | ||||
|         cssMenuSearchInput( | ||||
|           dom.autoDispose(searchValue), | ||||
|           dom.on('input', (_ev, elem) => { setSearchValue(elem.value); }), | ||||
|           {placeholder: searchInputPlaceholder}, | ||||
|         ), | ||||
|       ), | ||||
|       menuDivider(), | ||||
|     ]; | ||||
|     return [...searchInput, ...menuFunc(searchCriteria)]; | ||||
|     ), | ||||
|     menuDivider(), | ||||
|     dom.domComputed(searchValue, (value) => { | ||||
|       const cleanSearchValue = value.trim().toLowerCase(); | ||||
|       return dom.forEach(menuItems, (item) => { | ||||
|         if (!item.cleanText.includes(cleanSearchValue)) { return null; } | ||||
| 
 | ||||
|         return menuItem(item.action, item.label, ...(item.args ?? [])); | ||||
|       }); | ||||
|     }), | ||||
|   ]; | ||||
| } | ||||
| 
 | ||||
| // TODO Weasel doesn't allow other options for submenus, but probably should.
 | ||||
| export type ISubMenuOptions = weasel.ISubMenuOptions & weasel.IPopupOptions; | ||||
| export type ISubMenuOptions = | ||||
|   weasel.ISubMenuOptions & | ||||
|   weasel.IPopupOptions & | ||||
|   {allowNothingSelected?: boolean}; | ||||
| 
 | ||||
| export function menuItemSubmenu( | ||||
|   submenu: weasel.MenuCreateFunc, | ||||
|   options: ISubMenuOptions, | ||||
|   ...args: DomElementArg[] | ||||
| ): Element { | ||||
|   return weasel.menuItemSubmenu(submenu, {...defaults, ...options}, ...args); | ||||
|   return weasel.menuItemSubmenu( | ||||
|     submenu, | ||||
|     { | ||||
|       ...defaults, | ||||
|       expandIcon: () => icon('Expand'), | ||||
|       ...options, | ||||
|     }, | ||||
|     dom.cls(cssMenuItemSubmenu.className), | ||||
|     ...args | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| export const cssMenuElem = styled('div', ` | ||||
| @ -449,7 +484,7 @@ export const menuSubHeader = styled('div', ` | ||||
|   font-size: ${vars.xsmallFontSize}; | ||||
|   text-transform: uppercase; | ||||
|   font-weight: ${vars.bigControlTextWeight}; | ||||
|   padding: 8px 24px 16px 24px; | ||||
|   padding: 8px 24px 8px 24px; | ||||
|   cursor: default; | ||||
| `);
 | ||||
| 
 | ||||
| @ -669,3 +704,43 @@ const cssCheckboxText = styled(cssLabelText, ` | ||||
| const cssUpgradeTextButton = styled(textButton, ` | ||||
|   font-size: ${vars.smallFontSize}; | ||||
| `);
 | ||||
| 
 | ||||
| const cssMenuItemSubmenu = styled('div', ` | ||||
|   color: ${theme.menuItemFg}; | ||||
|   --icon-color: ${theme.menuItemFg}; | ||||
|   .${weasel.cssMenuItem.className}-sel { | ||||
|     color: ${theme.menuItemSelectedFg}; | ||||
|     --icon-color: ${theme.menuItemSelectedFg}; | ||||
|   } | ||||
|   &.disabled { | ||||
|     cursor: default; | ||||
|     color: ${theme.menuItemDisabledFg}; | ||||
|     --icon-color: ${theme.menuItemDisabledFg}; | ||||
|   } | ||||
| `);
 | ||||
| 
 | ||||
| const cssMenuSearch = styled('div', ` | ||||
|   display: flex; | ||||
|   column-gap: 8px; | ||||
|   align-items: center; | ||||
|   padding: 8px 16px; | ||||
| `);
 | ||||
| 
 | ||||
| const cssMenuSearchIcon = styled(icon, ` | ||||
|   flex-shrink: 0; | ||||
|   --icon-color: ${theme.menuItemIconFg}; | ||||
| `);
 | ||||
| 
 | ||||
| const cssMenuSearchInput = styled('input', ` | ||||
|   color: ${theme.inputFg}; | ||||
|   background-color: ${theme.inputBg}; | ||||
|   flex-grow: 1; | ||||
|   font-size: ${vars.mediumFontSize}; | ||||
|   padding: 0px; | ||||
|   border: none; | ||||
|   outline: none; | ||||
| 
 | ||||
|   &::placeholder { | ||||
|     color: ${theme.inputPlaceholderFg}; | ||||
|   } | ||||
| `);
 | ||||
|  | ||||
| @ -189,7 +189,7 @@ const cssAttachmentWidget = styled('div', ` | ||||
| const cssAttachmentIcon = styled('div.glyphicon.glyphicon-paperclip', ` | ||||
|   position: absolute; | ||||
|   top: 2px; | ||||
|   left: 2px; | ||||
|   left: 5px; | ||||
|   padding: 2px; | ||||
|   background-color: ${theme.attachmentsCellIconBg}; | ||||
|   color: ${theme.attachmentsCellIconFg}; | ||||
|  | ||||
| @ -10,7 +10,9 @@ | ||||
|     color: #D0D0D0; | ||||
|   } | ||||
| 
 | ||||
|   .formula_field::before, .formula_field_edit::before, .formula_field_sidepane::before { | ||||
|   .formula_field .field-icon, | ||||
|   .formula_field_edit::before, | ||||
|   .formula_field_sidepane::before { | ||||
|     /* based on standard icon styles */ | ||||
|     content: ""; | ||||
|     position: absolute; | ||||
| @ -28,13 +30,13 @@ | ||||
|     cursor: pointer; | ||||
|   } | ||||
| 
 | ||||
|   .formula_field::before, .formula_field_edit::before { | ||||
|   .formula_field .field-icon, .formula_field_edit::before { | ||||
|     background-color: #D0D0D0; | ||||
|   } | ||||
|   .formula_field_edit:not(.readonly)::before { | ||||
|     background-color: var(--grist-color-cursor); | ||||
|   } | ||||
|   .formula_field.invalid::before { | ||||
|   .formula_field.invalid .field-icon { | ||||
|     background-color: white; | ||||
|     color: #ffb6c1; | ||||
|   } | ||||
|  | ||||
| @ -172,7 +172,7 @@ | ||||
|     "piscina": "3.2.0", | ||||
|     "plotly.js-basic-dist": "2.13.2", | ||||
|     "popper-max-size-modifier": "0.2.0", | ||||
|     "popweasel": "0.1.18", | ||||
|     "popweasel": "0.1.20", | ||||
|     "qrcode": "1.5.0", | ||||
|     "randomcolor": "0.5.3", | ||||
|     "redis": "3.1.1", | ||||
|  | ||||
							
								
								
									
										11
									
								
								yarn.lock
									
									
									
									
									
								
							
							
						
						
									
										11
									
								
								yarn.lock
									
									
									
									
									
								
							| @ -4221,7 +4221,7 @@ grain-rpc@0.1.7: | ||||
|     events "^1.1.1" | ||||
|     ts-interface-checker "^1.0.0" | ||||
| 
 | ||||
| grainjs@1.0.2, grainjs@^1.0.1: | ||||
| grainjs@1.0.2: | ||||
|   version "1.0.2" | ||||
|   resolved "https://registry.npmjs.org/grainjs/-/grainjs-1.0.2.tgz" | ||||
|   integrity sha512-wrj8TqpgxTGOKHpTlMBxMeX2uS3lTvXj4ROLKC+EZNM7J6RHQLGjMzMqWtiryBnMhGIBlbCicMNFppCrK1zv9w== | ||||
| @ -6455,12 +6455,11 @@ popper.js@1.15.0: | ||||
|   resolved "https://registry.npmjs.org/popper.js/-/popper.js-1.15.0.tgz" | ||||
|   integrity sha512-w010cY1oCUmI+9KwwlWki+r5jxKfTFDVoadl7MSrIujHU5MJ5OR6HTDj6Xo8aoR/QsA56x8jKjA59qGH4ELtrA== | ||||
| 
 | ||||
| popweasel@0.1.18: | ||||
|   version "0.1.18" | ||||
|   resolved "https://registry.npmjs.org/popweasel/-/popweasel-0.1.18.tgz" | ||||
|   integrity sha512-F7+QRcnkj963ahDGURcZpucONfxhFWtlXLABawzaW7J/iOfcKMFRyjqluCebOLnBROPPBrih4g2qbq7KdQ0WMw== | ||||
| popweasel@0.1.20: | ||||
|   version "0.1.20" | ||||
|   resolved "https://registry.yarnpkg.com/popweasel/-/popweasel-0.1.20.tgz#b69af57b08288dce398c2105cb1e8a9f4e0e324c" | ||||
|   integrity sha512-iG51KFrHL49YuWTeI2yGby8BdNewdtxiKRv6y+Pyh1CkRKenLFu5CPMaKDRLbfiQJeZ/t67WW0e9ggWTt09ClA== | ||||
|   dependencies: | ||||
|     grainjs "^1.0.1" | ||||
|     lodash "^4.17.15" | ||||
|     popper.js "1.15.0" | ||||
| 
 | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user