(core) New Columns Menu

Summary:
A menu to be shown when new colum button is added. It's give access to various diffrent shortcuts, like adding new column, unhiding existing ones, fast adding lookup columns or trigger one (authoriship or timestamp). Design document can be found here: https://grist.quip.com/CTgxAQv9Ghjt/Add-Columns-more-easily
To turn on this menu flag GRIST_NEW_COLUMN_MENU to 1

Test Plan: UI tests suite under nbrowser/GridViewNewColumnMenu.ts

Reviewers: jarek, georgegevoian

Reviewed By: georgegevoian

Subscribers: paulfitz

Differential Revision: https://phab.getgrist.com/D4074
This commit is contained in:
Jakub Serafin
2023-10-13 15:01:12 +02:00
parent 0cadb93d25
commit 2521db4c55
10 changed files with 558 additions and 36 deletions

View File

@@ -34,7 +34,7 @@ const {onDblClickMatchElem} = require('app/client/lib/dblclick');
// Grist UI Components
const {dom: grainjsDom, Holder, Computed} = require('grainjs');
const {closeRegisteredMenu, menu} = require('../ui2018/menus');
const {calcFieldsCondition} = require('../ui/GridViewMenus');
const {calcFieldsCondition, ColumnAddMenuOld} = require('../ui/GridViewMenus');
const {ColumnAddMenu, ColumnContextMenu, MultiColumnMenu, freezeAction} = require('../ui/GridViewMenus');
const {RowContextMenu} = require('../ui/RowContextMenu');
@@ -50,6 +50,8 @@ 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');
@@ -836,7 +838,7 @@ GridView.prototype.deleteRows = async function(rowIds) {
GridView.prototype.addNewColumn = function() {
this.insertColumn(this.viewSection.viewFields().peekLength)
.then(() => this.scrollPaneRight());
.then(() => this.scrollPaneRight());
};
GridView.prototype.insertColumn = async function(index) {
@@ -857,6 +859,33 @@ GridView.prototype.insertColumn = async function(index) {
this.currentEditingColumnIndex(index);
};
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);
};
@@ -1105,6 +1134,28 @@ 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
@@ -1298,15 +1349,7 @@ GridView.prototype.buildDom = function() {
this._modField = dom('div.column_name.mod-add-column.field',
'+',
kd.style("width", PLUS_WIDTH + 'px'),
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 (this.viewSection.hiddenColumns().length === 0) {
ev.stopImmediatePropagation(); // Don't open the menu defined below
this.addNewColumn();
}
}),
menu((ctl => ColumnAddMenu(this, this.viewSection)))
addColumnMenu(this, this.viewSection),
)
))
)

View File

@@ -47,7 +47,6 @@ import {IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
import {linkFromId, selectBy} from 'app/client/ui/selectBy';
import {WebhookPage} from 'app/client/ui/WebhookPage';
import {startWelcomeTour} from 'app/client/ui/WelcomeTour';
import {AttachedCustomWidgets, IAttachedCustomWidget, IWidgetType} from 'app/common/widgetTypes';
import {PlayerState, YouTubePlayer} from 'app/client/ui/YouTubePlayer';
import {isNarrowScreen, mediaSmall, mediaXSmall, testId, theme} from 'app/client/ui2018/cssVars';
import {IconName} from 'app/client/ui2018/IconList';
@@ -69,6 +68,7 @@ import {LocalPlugin} from "app/common/plugin";
import {StringUnion} from 'app/common/StringUnion';
import {TableData} from 'app/common/TableData';
import {DocStateComparison} from 'app/common/UserAPI';
import {AttachedCustomWidgets, IAttachedCustomWidget, IWidgetType} from 'app/common/widgetTypes';
import {CursorPos} from 'app/plugin/GristAPI';
import {
bundleChanges,
@@ -1090,12 +1090,15 @@ export class GristDoc extends DisposableWithEvents {
}
// Convert column to data column with a trigger formula
public async convertToTrigger(colRefs: number, formula: string): Promise<void> {
public async convertToTrigger(
colRefs: number,
formula: string,
recalcWhen: RecalcWhen = RecalcWhen.DEFAULT ): Promise<void> {
return this.docModel.columns.sendTableAction(
['UpdateRecord', colRefs, {
isFormula: false,
formula,
recalcWhen: RecalcWhen.DEFAULT,
recalcWhen: recalcWhen,
recalcDeps: null,
}]
);

View File

@@ -25,6 +25,10 @@ export function WHICH_FORMULA_ASSISTANT() {
return getGristConfig().assistantService;
}
export function GRIST_NEW_COLUMN_MENU(){
return Boolean(getGristConfig().gristNewColumnMenu);
}
export function PERMITTED_CUSTOM_WIDGETS(): Observable<string[]> {
const G = getBrowserGlobals('document', 'window');
if (!G.window.PERMITTED_CUSTOM_WIDGETS) {

View File

@@ -1,30 +1,181 @@
import {allCommands} from 'app/client/components/commands';
import {makeT} from 'app/client/lib/localization';
import { allCommands } from 'app/client/components/commands';
import { ViewFieldRec } from 'app/client/models/entities/ViewFieldRec';
import { testId, theme } from 'app/client/ui2018/cssVars';
import { icon } from 'app/client/ui2018/icons';
import { menuDivider, menuItem, menuItemCmd } from 'app/client/ui2018/menus';
import { Sort } from 'app/common/SortSpec';
import { dom, DomElementArg, styled } from 'grainjs';
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,
menuItem,
menuItemCmd,
menuItemSubmenu,
menuSubHeader,
menuText
} from 'app/client/ui2018/menus';
import {Sort} from 'app/common/SortSpec';
import {dom, DomElementArg, Observable, 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 {
addNewColumn: () => void;
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;
}
/**
* Creates a menu to add a new column. Should be used only when there are hidden columns to display,
* otherwise there is no need for this menu.
*/
export function ColumnAddMenu(gridView: IView, viewSection: IViewSection) {
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){
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")),
menuDivider(),
@@ -35,6 +186,56 @@ export function ColumnAddMenu(gridView: IView, viewSection: IViewSection) {
}, t("Show column {{- label}}", {label: col.label()})))
];
}
/**
* Creates a menu to add a new column.
*/
export function ColumnAddMenu(gridView: IView, viewSection: IViewSection) {
return [
menuItem(
async () => { await gridView.addNewColumn(); },
`+ ${t("Add Column")}`,
testId('new-columns-menu-add-new')
),
MenuHideColumnSection(gridView, viewSection),
MenuLookups(viewSection, gridView),
MenuShortcuts(gridView),
];
}
//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});
};
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});
};
export interface IMultiColumnContextMenu {
// For multiple selection, true/false means the value applies to all columns, 'mixed' means it's
// true for some columns, but not all.

View File

@@ -8,8 +8,10 @@ import { testId, theme, vars } from 'app/client/ui2018/cssVars';
import { IconName } from 'app/client/ui2018/IconList';
import { icon } from 'app/client/ui2018/icons';
import { cssSelectBtn } from 'app/client/ui2018/select';
import { BindableValue, Computed, dom, DomElementArg, DomElementMethod, IDomArgs,
MaybeObsArray, MutableObsArray, Observable, styled } from 'grainjs';
import {
BindableValue, Computed, dom, DomElementArg, DomElementMethod, IDomArgs,
MaybeObsArray, MutableObsArray, Observable, styled
} from 'grainjs';
import * as weasel from 'popweasel';
const t = makeT('menus');
@@ -47,6 +49,27 @@ 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")}
)
),
menuDivider(),
];
return [...searchInput, ...menuFunc(searchCriteria)];
}
// TODO Weasel doesn't allow other options for submenus, but probably should.
export type ISubMenuOptions = weasel.ISubMenuOptions & weasel.IPopupOptions;
@@ -78,7 +101,7 @@ export const cssMenuElem = styled('div', `
}
`);
const menuItemStyle = `
export const menuItemStyle = `
justify-content: flex-start;
align-items: center;
color: ${theme.menuItemFg};
@@ -94,6 +117,8 @@ const menuItemStyle = `
}
`;
export const menuItemStatic = styled('div', menuItemStyle);
export const menuCssClass = cssMenuElem.className;
// Add grist-floating-menu class to support existing browser tests
@@ -376,17 +401,23 @@ export function selectMenu(
items: () => DomElementArg[],
...args: IDomArgs<HTMLDivElement>
) {
const _menu = cssSelectMenuElem(testId('select-menu'));
return cssSelectBtn(
label,
icon('Dropdown'),
menu(
listOfMenuItems(items),
...args,
);
}
export function listOfMenuItems(items: () => DomElementArg[],) {
const _menu = cssSelectMenuElem(testId('select-menu'));
return menu(
items,
{
...weasel.defaultMenuOptions,
menuCssClass: _menu.className + ' grist-floating-menu',
stretchToSelector : `.${cssSelectBtn.className}`,
trigger : [(triggerElem, ctl) => {
stretchToSelector: `.${cssSelectBtn.className}`,
trigger: [(triggerElem, ctl) => {
const isDisabled = () => triggerElem.classList.contains('disabled');
dom.onElem(triggerElem, 'click', () => isDisabled() || ctl.toggle());
dom.onKeyElem(triggerElem as HTMLElement, 'keydown', {
@@ -395,8 +426,6 @@ export function selectMenu(
});
}]
},
),
...args,
);
}
@@ -434,10 +463,12 @@ export const menuText = styled('div', `
cursor: default;
`);
export const menuItem = styled(weasel.menuItem, menuItemStyle);
export const menuItemLink = styled(weasel.menuItemLink, menuItemStyle);
/**
* A version of menuItem which runs the action on next tick, allowing the menu to close even when
* the action causes the disabling of the element being clicked.

View File

@@ -676,6 +676,9 @@ export interface GristLoadConfig {
permittedCustomWidgets?: IAttachedCustomWidget[];
// Feature flag for the new column menu.
gristNewColumnMenu?: boolean;
// Used to determine which disclosure links should be provided to user of
// formula assistance.
assistantService?: 'OpenAI' | undefined;

View File

@@ -79,6 +79,7 @@ export function makeGristConfig(options: MakeGristConfigOptons): GristLoadConfig
featureFormulaAssistant: Boolean(process.env.OPENAI_API_KEY || process.env.ASSISTANT_CHAT_COMPLETION_ENDPOINT),
assistantService: process.env.OPENAI_API_KEY ? 'OpenAI' : undefined,
permittedCustomWidgets: getPermittedCustomWidgets(),
gristNewColumnMenu: isAffirmative(process.env.GRIST_NEW_COLUMN_MENU),
supportEmail: SUPPORT_EMAIL,
userLocale: (req as RequestWithLogin | undefined)?.user?.options?.locale,
telemetry: server?.getTelemetry().getTelemetryConfig(),