mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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:
parent
0cadb93d25
commit
2521db4c55
@ -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),
|
||||
)
|
||||
))
|
||||
)
|
||||
|
@ -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,
|
||||
}]
|
||||
);
|
||||
|
@ -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) {
|
||||
|
@ -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.
|
||||
|
@ -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.
|
||||
|
@ -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;
|
||||
|
@ -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(),
|
||||
|
@ -10,6 +10,7 @@ processes = []
|
||||
APP_STATIC_URL="https://{APP_NAME}.fly.dev"
|
||||
ALLOWED_WEBHOOK_DOMAINS="webhook.site"
|
||||
PERMITTED_CUSTOM_WIDGETS="calendar"
|
||||
GRIST_NEW_COLUMN_MENU="true"
|
||||
GRIST_SINGLE_ORG="docs"
|
||||
PORT = "8080"
|
||||
FLY_DEPLOY_EXPIRATION = "{FLY_DEPLOY_EXPIRATION}"
|
||||
|
231
test/nbrowser/GridViewNewColumnMenu.ts
Normal file
231
test/nbrowser/GridViewNewColumnMenu.ts
Normal file
@ -0,0 +1,231 @@
|
||||
import {driver, Key, WebElement} from "mocha-webdriver";
|
||||
import {DocCreationInfo} from "app/common/DocListAPI";
|
||||
import {UserAPIImpl} from "app/common/UserAPI";
|
||||
import {assert} from "chai";
|
||||
import * as gu from "./gristUtils";
|
||||
import {setupTestSuite} from "./testUtils";
|
||||
|
||||
|
||||
describe('GridViewNewColumnMenu', function () {
|
||||
if(process.env.GRIST_NEW_COLUMN_MENU) {
|
||||
this.timeout('5m');
|
||||
const cleanup = setupTestSuite();
|
||||
|
||||
//helpers
|
||||
let session: gu.Session, doc: DocCreationInfo, apiImpl: UserAPIImpl;
|
||||
|
||||
before(async function () {
|
||||
session = await gu.session().login();
|
||||
await createEmptyDoc('ColumnMenu');
|
||||
});
|
||||
|
||||
this.afterEach(async function () {
|
||||
await closeAddColumnMenu();
|
||||
});
|
||||
describe('menu composition', function () {
|
||||
|
||||
it('simple columns, should have add column and shortcuts', async function () {
|
||||
const menu = await openAddColumnIcon();
|
||||
await hasAddNewColumMenu(menu);
|
||||
await hasShortcuts(menu);
|
||||
});
|
||||
|
||||
it('have lookup columns, should have add column, shortcuts and lookup section ', async function () {
|
||||
const createReferenceTable = async () => {
|
||||
await apiImpl.applyUserActions(doc.id, [
|
||||
['AddTable', 'Reference', [
|
||||
{id: "Name"},
|
||||
{id: "Age"},
|
||||
{id: "City"}]],
|
||||
]);
|
||||
await apiImpl.applyUserActions(doc.id, [
|
||||
['AddRecord', 'Reference', null, {Name: "Bob", Age: 12, City: "New York"}],
|
||||
['AddRecord', 'Reference', null, {Name: "Robert", Age: 34, City: "Łódź"}],
|
||||
]);
|
||||
};
|
||||
|
||||
const addReferenceColumnToManinTable = async () => {
|
||||
//add reference column
|
||||
await apiImpl.applyUserActions(doc.id, [
|
||||
['AddColumn', 'Table1', 'Reference', {type: 'Ref:Reference'}],
|
||||
]);
|
||||
};
|
||||
|
||||
await createReferenceTable();
|
||||
await addReferenceColumnToManinTable();
|
||||
await gu.reloadDoc();
|
||||
|
||||
//open menu
|
||||
const menu = await openAddColumnIcon();
|
||||
// check if all three sections are present
|
||||
await hasAddNewColumMenu(menu);
|
||||
await hasShortcuts(menu);
|
||||
await hasLookupMenu(menu, 'Reference');
|
||||
//TODO - remove reference column somehow.
|
||||
await apiImpl.applyUserActions(doc.id, [["RemoveColumn", "Table1", "Reference"]]);
|
||||
await gu.reloadDoc();
|
||||
});
|
||||
});
|
||||
|
||||
describe('column creation', function () {
|
||||
it('should show rename menu after new column click', async function () {
|
||||
const menu = await openAddColumnIcon();
|
||||
await menu.findWait('.test-new-columns-menu-add-new', 100).click();
|
||||
await driver.findWait('.test-column-title-popup', 100, 'rename menu is not present');
|
||||
await gu.undo();
|
||||
});
|
||||
|
||||
it('should create new column', async function () {
|
||||
const menu = await openAddColumnIcon();
|
||||
await menu.findWait('.test-new-columns-menu-add-new', 100).click();
|
||||
//discard rename menu
|
||||
await driver.findWait('.test-column-title-close', 100).click();
|
||||
//check if new column is present
|
||||
const columns = await gu.getColumnNames();
|
||||
assert.include(columns, 'D', 'new column is not present');
|
||||
assert.lengthOf(columns, 4, 'wrong number of columns');
|
||||
await gu.undo();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hidden columns', function () {
|
||||
it('no hidden column in document, section should not be present', async function () {
|
||||
const menu = await openAddColumnIcon();
|
||||
const isHiddenSectionPresent = await menu.find(".new-columns-menu-hidden-columns").isPresent();
|
||||
assert.isFalse(isHiddenSectionPresent, 'hidden section is present');
|
||||
await closeAddColumnMenu();
|
||||
});
|
||||
|
||||
describe('inline menu section', function () {
|
||||
before(async function () {
|
||||
await gu.addColumn('Add1');
|
||||
await gu.addColumn('Add2');
|
||||
await gu.addColumn('Add3');
|
||||
});
|
||||
|
||||
it('1 to 5 hidden columns, secion should be inline', async function () {
|
||||
const checkSection = async (...columns: string[]) => {
|
||||
const menu = await openAddColumnIcon();
|
||||
await menu.findWait(".test-new-columns-menu-hidden-columns", 100,
|
||||
'hidden section is not present');
|
||||
for (const column of columns) {
|
||||
const isColumnPresent = await menu.find(`.test-new-columns-menu-hidden-columns-${column}`).isPresent();
|
||||
assert.isTrue(isColumnPresent, `column ${column} is not present`);
|
||||
}
|
||||
await closeAddColumnMenu();
|
||||
};
|
||||
|
||||
await gu.openWidgetPanel();
|
||||
await gu.moveToHidden('A');
|
||||
await checkSection('A');
|
||||
await gu.moveToHidden('B');
|
||||
await gu.moveToHidden('C');
|
||||
await gu.moveToHidden('Add1');
|
||||
await gu.moveToHidden('Add2');
|
||||
await checkSection('A', 'B', 'C', 'Add1', 'Add2');
|
||||
await gu.undo(5);
|
||||
});
|
||||
|
||||
it('inline button should show column at the end of the table', async function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('submenu section', function () {
|
||||
it('more than 5 hidden columns, section should be in submenu', async function () {
|
||||
});
|
||||
|
||||
it('submenu should be searchable', async function () {
|
||||
});
|
||||
|
||||
it('submenu button should show column at the end of the table', async function () {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('lookups', function () {
|
||||
before(async function () {
|
||||
//save current state
|
||||
});
|
||||
|
||||
after(async function () {
|
||||
//restore current state
|
||||
});
|
||||
it('should show columns in menu with lookup', async function () {
|
||||
});
|
||||
it('should create formula column with data from selected column', async function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('shortucts', function () {
|
||||
describe('Timestamp', function () {
|
||||
it('created at - should create new column with date triggered on create');
|
||||
});
|
||||
|
||||
describe('Timestamp', function () {
|
||||
it('created at - should create new column with date triggered on create', function () {
|
||||
|
||||
});
|
||||
it('modified at - should create new column with date triggered on change', function () {
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authorship', function () {
|
||||
it('created by - should create new column with author name triggered on create', function () {
|
||||
|
||||
});
|
||||
it('modified by - should create new column with author name triggered on change', function () {
|
||||
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
async function createEmptyDoc(docName: string) {
|
||||
session = await gu.session().login();
|
||||
const docId = await session.tempNewDoc(cleanup, docName);
|
||||
doc = {id: docId, title: docName};
|
||||
apiImpl = session.createHomeApi();
|
||||
}
|
||||
|
||||
async function openAddColumnIcon() {
|
||||
const isMenuPresent = await driver.find(".test-new-columns-menu").isPresent();
|
||||
if (!isMenuPresent) {
|
||||
await driver.findWait(".mod-add-column", 100).click();
|
||||
}
|
||||
return driver.findWait(".test-new-columns-menu", 100);
|
||||
}
|
||||
|
||||
async function closeAddColumnMenu() {
|
||||
const isMenuPresent = await driver.find(".test-new-columns-menu").isPresent();
|
||||
if (isMenuPresent) {
|
||||
await driver.sendKeys(Key.ESCAPE);
|
||||
assert.isFalse(await driver.wait(driver.find(".test-new-columns-menu").isPresent(), 100),
|
||||
'menu is still present after close by escape');
|
||||
}
|
||||
}
|
||||
|
||||
const hasAddNewColumMenu = async (menu: WebElement) => {
|
||||
await checkInMenu(menu, '.test-new-columns-menu-add-new', 'add new column menu is not present');
|
||||
};
|
||||
|
||||
const checkInMenu = async (menu: WebElement, selector: string, message: string) => {
|
||||
const element = await menu.findWait(selector, 100, message);
|
||||
assert.exists(element, message);
|
||||
return element;
|
||||
};
|
||||
|
||||
const hasShortcuts = async (menu: WebElement) => {
|
||||
await checkInMenu(menu, '.test-new-columns-menu-shortcuts', 'shortcuts section is not present');
|
||||
await checkInMenu(menu, '.test-new-columns-menu-shortcuts-timestamp',
|
||||
'timestamp shortcuts section is not present');
|
||||
await checkInMenu(menu, '.test-new-columns-menu-shortcuts-author', 'authorship shortcuts section is not present');
|
||||
};
|
||||
|
||||
const hasLookupMenu = async (menu: WebElement, tableName: string) => {
|
||||
await checkInMenu(menu, '.test-new-columns-menu-lookups', 'lookup section is not present');
|
||||
await checkInMenu(menu, `.test-new-columns-menu-lookups-${tableName}`,
|
||||
`lookup section for ${tableName} is not present`);
|
||||
};
|
||||
}
|
||||
});
|
@ -15,6 +15,7 @@ import * as PluginApi from 'app/plugin/grist-plugin-api';
|
||||
import {csvDecodeRow} from 'app/common/csvFormat';
|
||||
import { AccessLevel } from 'app/common/CustomWidget';
|
||||
import { decodeUrl } from 'app/common/gristUrls';
|
||||
import { isAffirmative } from "app/common/gutil";
|
||||
import { FullUser, UserProfile } from 'app/common/LoginSessionAPI';
|
||||
import { resetOrg } from 'app/common/resetOrg';
|
||||
import { UserAction } from 'app/common/DocActions';
|
||||
@ -2328,6 +2329,9 @@ export function hexToRgb(hex: string) {
|
||||
export async function addColumn(name: string, type?: string) {
|
||||
await scrollIntoView(await driver.find('.active_section .mod-add-column'));
|
||||
await driver.find('.active_section .mod-add-column').click();
|
||||
if (isAffirmative(process.env.GRIST_NEW_COLUMN_MENU)) {
|
||||
await driver.findWait('.test-new-columns-menu-add-new', 100).click();
|
||||
}
|
||||
// If we are on a summary table, we could be see a menu helper
|
||||
const menu = (await driver.findAll('.grist-floating-menu'))[0];
|
||||
if (menu) {
|
||||
|
Loading…
Reference in New Issue
Block a user