(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:
George Gevoian 2023-10-17 14:14:54 -04:00
parent 7f091cf057
commit f1cf92aca1
14 changed files with 661 additions and 341 deletions

View File

@ -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,6 +1969,11 @@ GridView.prototype._scrollColumnIntoView = function(colIndex) {
// If there are some frozen columns.
if (this.numFrozen.peek() && colIndex < this.numFrozen.peek()) { return; }
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;
@ -2014,6 +1988,94 @@ GridView.prototype._scrollColumnIntoView = function(colIndex) {
// 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) {

View File

@ -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

View File

@ -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,

View File

@ -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';

View File

@ -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,

View File

@ -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);

View File

@ -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]);
};
}

View File

@ -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
// FIXME: remove once New Column menu is enabled by default.
export function buildOldAddColumnMenu(gridView: GridView, viewSection: ViewSectionRec) {
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){
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');
function buildHiddenColumnsMenuItems(gridView: GridView, index?: number) {
const {viewSection} = gridView;
const hiddenColumns = viewSection.hiddenColumns();
if (hiddenColumns.length === 0) { return null; }
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,
}
}, {nestInActiveBundle: true});
};
);
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');
// 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;

View File

@ -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'),

View File

@ -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 = [
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(
cssSearchField(
dom.on('input', (_ev, elem) => searchCriteria.set(elem.value)),
{placeholder: '🔍\uFE0E\t' + t("Search columns")}
)
cssMenuSearch(
cssMenuSearchIcon('Search'),
cssMenuSearchInput(
dom.autoDispose(searchValue),
dom.on('input', (_ev, elem) => { setSearchValue(elem.value); }),
{placeholder: searchInputPlaceholder},
),
),
),
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 ?? []));
});
}),
];
return [...searchInput, ...menuFunc(searchCriteria)];
}
// 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};
}
`);

View File

@ -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};

View File

@ -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;
}

View File

@ -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",

View File

@ -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"