mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +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